feat: v6.5.0 Synced blocks production — pages contenu par lignes de DB, résolution serveur à chaque lecture, propagation écrite réelle
FlowDeck CI / lint (push) Successful in 1m27s
FlowDeck CI / test (push) Successful in 10m51s
FlowDeck CI / docker (push) Successful in 1m21s

This commit is contained in:
2026-09-24 08:28:25 -04:00
parent f2f2f3209e
commit 5951c707eb
16 changed files with 990 additions and 50 deletions
+1 -1
View File
@@ -122,7 +122,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="6.4.0",
version="6.5.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+24
View File
@@ -959,3 +959,27 @@ def _migration_sync_version_columns(conn: sqlite3.Connection) -> None:
UPDATE collections SET sync_version = sync_version + 1 WHERE id = NEW.id;
END"""
)
@register(22, "v6.5.0: database row content pages")
def _migration_row_content_pages(conn: sqlite3.Connection) -> None:
"""v6.5.0 — Synced blocks production: content for database rows.
A database row (``collection_pages``) gains a shadow ``pages`` row
(``pages.collection_row_id``) that carries the Notion-style block
content of the row: the full page editor, synced blocks, versions and
realtime all work on it unchanged.
``ON DELETE CASCADE``: deleting a database row deletes its content
page (and ``page_synced_blocks`` cascades from ``pages``).
"""
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
if "collection_row_id" not in cols:
conn.execute(
"ALTER TABLE pages ADD COLUMN collection_row_id INTEGER "
"REFERENCES collection_pages(id) ON DELETE CASCADE"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_pages_row "
"ON pages(collection_row_id) WHERE collection_row_id IS NOT NULL"
)
+11
View File
@@ -802,9 +802,20 @@ async def get_page_v2(page_id: int, request: Request, authorization: str | None
if not row2:
raise HTTPException(404, "Page not found")
d = row_to_dict(row2)
# v6.5.0: resolve synced blocks server-side (fresh content).
if (d.get("content_format") or "blocks") == "blocks" and d.get("content"):
from app.services.synced_blocks import resolve_content_json
d["content"] = resolve_content_json(d["content"], d["content_format"])
return d
d = row_to_dict(row)
# property_values_json already parsed by row_to_dict
# v6.5.0: expose the row's content page when it exists (no lazy
# creation on a read-only endpoint).
content_page_id = conn.execute(
"SELECT id FROM pages WHERE collection_row_id=?",
(page_id,),
).fetchone()
d["content_page_id"] = content_page_id["id"] if content_page_id else None
return d
@router.patch("/pages/{page_id}")
+37 -6
View File
@@ -431,7 +431,7 @@ def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, m
if depth >= max_depth:
return []
rows = conn.execute(
"SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? AND deleted_at IS NULL ORDER BY sort_order ASC, updated_at DESC",
"SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY sort_order ASC, updated_at DESC",
(ws_key, parent_id),
).fetchall()
items = []
@@ -499,7 +499,7 @@ def _load_workspace_pages(ws_cookie: str) -> list:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, "
"is_shared, share_mode, COALESCE(published,0) AS published "
"FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL ORDER BY created_at DESC",
"FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY created_at DESC",
(ws_id,),
).fetchall()
items = []
@@ -916,13 +916,13 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
if ws_key:
rows = conn.execute(
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE workspace=? ORDER BY updated_at DESC",
"WHERE workspace=? AND collection_row_id IS NULL ORDER BY updated_at DESC",
(ws_key,),
).fetchall()
else:
rows = conn.execute(
"SELECT id, title, workspace, updated_at FROM pages "
"ORDER BY updated_at DESC",
"WHERE collection_row_id IS NULL ORDER BY updated_at DESC",
).fetchall()
all_pages = []
for r in rows:
@@ -1125,11 +1125,21 @@ async def update_synced_block_api(request: Request, sid: int):
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body") from None
from app.services.synced_blocks import get_synced_block, update_synced_block
from app.services.synced_blocks import (
get_synced_block,
page_ids_for_synced,
sync_synced_blocks_in_page,
update_synced_block,
)
sb = get_synced_block(sid)
if not sb:
raise HTTPException(404, "Synced block not found")
update_synced_block(sid, body.get("title", sb["title"]), body.get("content", []))
# v6.5.0: rewrite every referencing page's stored content first (DB row
# content pages included), THEN push the realtime update so open rooms
# reload the fresh content from the DB.
for pid in page_ids_for_synced(sid):
sync_synced_blocks_in_page(pid)
from app.services.realtime_server import manager
await manager._propagate_synced(sid)
return {"status": "ok"}
@@ -1138,11 +1148,22 @@ async def update_synced_block_api(request: Request, sid: int):
@router.delete("/api/synced-blocks/{sid}")
async def delete_synced_block_api(request: Request, sid: int):
"""Delete a synced block."""
from app.services.synced_blocks import delete_synced_block, get_synced_block
from app.services.synced_blocks import (
delete_synced_block,
get_synced_block,
mark_synced_block_deleted,
page_ids_for_synced,
)
sb = get_synced_block(sid)
if not sb:
raise HTTPException(404, "Synced block not found")
# v6.5.0: collect referencing pages BEFORE the FK cascade wipes the
# refs, rewrite their stored content (deleted state), then broadcast.
pids = page_ids_for_synced(sid)
delete_synced_block(sid)
mark_synced_block_deleted(sid, pids)
from app.services.realtime_server import manager
await manager._broadcast_synced_to(pids, sid)
return {"status": "ok"}
@@ -1426,6 +1447,9 @@ async def update_page(request: Request, page_id: int, title: str = Query(default
_ensure_page_editable(conn, page_id, user)
if title:
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
# v6.5.0: renaming a database row's content page updates the row.
from app.services.row_pages import sync_page_title_to_row
sync_page_title_to_row(conn, page_id)
if content:
conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content, page_id))
if content_format:
@@ -1473,6 +1497,9 @@ async def save_page_blocks(request: Request, page_id: int):
_ensure_page_editable(conn, page_id, user)
if title:
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
# v6.5.0: renaming a database row's content page updates the row.
from app.services.row_pages import sync_page_title_to_row
sync_page_title_to_row(conn, page_id)
conn.execute(
"UPDATE pages SET content=?, content_format='blocks', updated_at=CURRENT_TIMESTAMP WHERE id=?",
(blocks_json, page_id),
@@ -1974,6 +2001,10 @@ async def view_page(request: Request, page_id: int):
if not row:
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
page = dict(row)
# v6.5.0: synced blocks resolve server-side at read time (fresh content
# even when the stored cache is stale).
from app.services.synced_blocks import resolve_content_json
page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format"))
ws = page.get("workspace", "")
parts = ws.split("/") if "/" in ws else ["", ""]
+29
View File
@@ -499,6 +499,32 @@ async def get_page_api(request: Request, page_id: int):
return dict(page)
@router.get("/pages/{page_id}/open/api")
async def open_row_page_api(request: Request, page_id: int):
"""v6.5.0 — content page of a database row (lazy-created).
Any DB view (table/board/gallery/list/calendar) opens a row through
this endpoint: it returns the shadow ``pages`` id whose full page
editor carries the row's block content (synced blocks included).
"""
with get_conn() as conn:
row = conn.execute(
"SELECT collection_id FROM collection_pages WHERE id=?",
(page_id,),
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Page not found")
coll_id = row["collection_id"]
# v6.0.0: granular collection permissions (same gate as the row itself).
_require_view(coll_id, _session_user(request))
from app.services.row_pages import ensure_row_page
try:
content_page_id = ensure_row_page(page_id)
except KeyError:
raise HTTPException(status_code=404, detail="Page not found") from None
return {"page_id": content_page_id, "row_id": page_id}
@router.put("/pages/{page_id}/api")
async def update_page_api(request: Request, page_id: int):
"""API: update a page's properties."""
@@ -552,6 +578,9 @@ async def update_page_api(request: Request, page_id: int):
WHERE id=?""",
(title, icon, cover_url, position, parent_id, property_values, page_id),
)
# v6.5.0: keep the row's content page title in sync (row → page).
from app.services.row_pages import sync_row_title_to_page
sync_row_title_to_page(conn, page_id)
conn.commit()
await fire_event("page.updated", {
+43 -6
View File
@@ -321,6 +321,9 @@ async def view_page_root(request: Request, page_id: int):
if not row:
return RedirectResponse("/workspaces", status_code=302)
page = dict(row)
# v6.5.0: synced blocks resolve server-side at read time.
from app.services.synced_blocks import resolve_content_json
page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format"))
ws = page.get("workspace", "")
parts = ws.split("/") if "/" in ws else ["", ""]
@@ -885,7 +888,7 @@ async def list_workspace_projects(request: Request):
builtin = []
with get_conn() as conn:
rows = conn.execute(
"SELECT id, title FROM pages WHERE parent_id IS NULL ORDER BY updated_at DESC LIMIT 20"
"SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL ORDER BY updated_at DESC LIMIT 20"
).fetchall()
for r in rows:
count = conn.execute("SELECT COUNT(*) FROM pages WHERE parent_id=?", (r["id"],)).fetchone()[0]
@@ -1081,7 +1084,9 @@ async def get_page_content(page_id: int):
fmt = row["content_format"]
if fmt == "file":
return JSONResponse({"content": "(uploaded file)", "format": fmt})
return {"content": row["content"] or "", "format": fmt}
# v6.5.0: resolve synced blocks server-side (fresh content on read).
from app.services.synced_blocks import resolve_content_json
return {"content": resolve_content_json(row["content"] or "", fmt), "format": fmt}
def _file_page_disk_path(page: dict):
@@ -1185,7 +1190,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | Non
"SELECT id, title, parent_section, content_format, content, page_icon, "
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
"created_at, updated_at FROM pages "
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL "
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL "
"ORDER BY created_at DESC",
(ws_id,),
).fetchall()
@@ -1364,7 +1369,7 @@ async def nav_menu(request: Request, workspace_id: int = None, parent_id: int =
else:
rows = conn.execute(
"SELECT id, title, parent_section, content_format FROM pages "
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL "
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL "
"ORDER BY sort_order ASC, created_at DESC",
(ws_id,),
).fetchall()
@@ -1726,7 +1731,7 @@ async def list_workspaces(request: Request):
uid = user["id"] if user and user.get("id") else 1
with get_conn() as conn:
rows = conn.execute(
"SELECT w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id) as page_count "
"SELECT w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id AND collection_row_id IS NULL) as page_count "
"FROM workspaces w WHERE w.owner_id=? ORDER BY w.created_at DESC",
(uid,),
).fetchall()
@@ -2213,6 +2218,8 @@ h1{font-size:3rem;opacity:.3}</style></head><body><h1>404</h1></body></html>""",
import json as _json
try:
blocks = _json.loads(page["content"])
from app.services.synced_blocks import resolve_synced_block
blocks = resolve_synced_block(blocks)
from app.db import get_conn as _gc
from app.services.wiki_links import token_labels
with _gc() as conn:
@@ -2413,6 +2420,31 @@ def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
f'background:#0a0a0a;"><iframe src="{src}" loading="lazy" frameborder="0" '
f'style="position:absolute;inset:0;width:100%;height:100%;" allowfullscreen allow="autoplay; encrypted-media; picture-in-picture"></iframe></div>'
)
elif t == "synced":
# v6.5.0: render synced block instances (resolved server-side).
if b.get("_synced_deleted"):
html_parts.append(
'<div style="margin:8px 0;padding:8px 12px;border-left:3px solid #e05e5e;'
'background:rgba(224,94,94,.08);border-radius:4px;font-size:13px;opacity:.8;">'
'Deleted synced block</div>'
)
else:
inner = b.get("_synced_content")
if not isinstance(inner, list) or not inner:
try:
import json as _sj
parsed = _sj.loads(b.get("content") or "[]")
inner = parsed if isinstance(parsed, list) else []
except (ValueError, TypeError):
inner = []
inner = [{"type": "paragraph", "content": str(x)} if not isinstance(x, dict) else x
for x in inner]
if inner:
html_parts.append(
'<div style="margin:8px 0;padding-left:12px;'
'border-left:3px solid var(--accent,#4c9aff);">'
+ _render_blocks_public(inner, titles) + '</div>'
)
else:
html_parts.append(f'<p style="margin:4px 0;line-height:1.7;">{c}</p>')
return "\n".join(html_parts)
@@ -2430,9 +2462,11 @@ async def api_page_content(page_id: int):
).fetchone()
if not row:
return JSONResponse({"error": "Not found"}, status_code=404)
# v6.5.0: resolve synced blocks server-side (fresh content on read).
from app.services.synced_blocks import resolve_content_json
return {
"title": row["title"],
"content": row["content"],
"content": resolve_content_json(row["content"], row["content_format"]),
"format": row["content_format"] or "blocks",
}
@@ -2449,6 +2483,9 @@ async def api_rename_page(page_id: int, request: Request):
"UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL",
(title, page_id),
)
# v6.5.0: renaming a database row's content page updates the row.
from app.services.row_pages import sync_page_title_to_row
sync_page_title_to_row(conn, page_id)
conn.commit()
return {"status": "ok", "title": title}
+16 -14
View File
@@ -207,7 +207,11 @@ class RealtimeManager:
room.title = row["title"] or ""
if (row["content_format"] or "") == "blocks" and row["content"]:
try:
room.blocks = ensure_block_ids(json.loads(row["content"]))
blocks = ensure_block_ids(json.loads(row["content"]))
# v6.5.0: rooms serve server-resolved synced blocks so a
# sync_req never returns a stale cached copy.
from app.services.synced_blocks import resolve_synced_block
room.blocks = resolve_synced_block(blocks)
except Exception:
room.blocks = []
return True
@@ -503,19 +507,8 @@ class RealtimeManager:
for r in self._rooms.values()],
}
async def _propagate_synced(self, synced_id: int) -> None:
"""Broadcast a synced-block update to all rooms that reference it."""
from app.db import get_conn as _get_conn
pages: list[int] = []
try:
with _get_conn() as conn:
rows = conn.execute(
"SELECT page_id FROM page_synced_blocks WHERE synced_block_id=?",
(synced_id,),
).fetchall()
pages = [r["page_id"] for r in rows]
except Exception:
return
async def _broadcast_synced_to(self, pages: list[int], synced_id: int) -> None:
"""Broadcast a synced-block event to the given pages' open rooms."""
for pid in pages:
room = self._rooms.get(pid)
if room and room.conns:
@@ -524,5 +517,14 @@ class RealtimeManager:
"synced_id": synced_id,
"version": room.version})
async def _propagate_synced(self, synced_id: int) -> None:
"""Broadcast a synced-block update to all rooms that reference it."""
from app.services.synced_blocks import page_ids_for_synced
try:
pages = page_ids_for_synced(synced_id)
except Exception:
return
await self._broadcast_synced_to(pages, synced_id)
manager = RealtimeManager()
+123
View File
@@ -0,0 +1,123 @@
"""FlowDeck — v6.5.0 database row content pages (synced blocks production).
A database row (``collection_pages``) has no block content of its own:
its Notion-style content lives in a shadow ``pages`` row linked via
``pages.collection_row_id``. The full page editor (synced blocks,
versions, realtime, permissions) is reused unchanged on that page.
``ensure_row_page()`` lazily creates (or heals) the shadow page and is
the single entry point used by the row-open endpoint.
"""
from __future__ import annotations
import logging
from app.db import get_conn
logger = logging.getLogger(__name__)
def _host_workspace(conn, coll: dict) -> tuple[str, int | None]:
"""Workspace (key, id) the shadow page should live in.
Resolution order: full-page database host page → inline parent page →
the collection's own ``workspace_id`` (key left empty).
"""
host = conn.execute(
"SELECT workspace, workspace_id FROM pages "
"WHERE collection_id=? AND deleted_at IS NULL "
"AND collection_row_id IS NULL LIMIT 1",
(coll.get("id"),),
).fetchone()
if not host and coll.get("parent_page_id"):
host = conn.execute(
"SELECT workspace, workspace_id FROM pages WHERE id=?",
(coll["parent_page_id"],),
).fetchone()
if host:
return host["workspace"] or "", host["workspace_id"]
if coll.get("gitea_owner") and coll.get("gitea_repo"):
return f"{coll['gitea_owner']}/{coll['gitea_repo']}", coll.get("workspace_id")
return "", coll.get("workspace_id")
def ensure_row_page(row_id: int) -> int:
"""Return the content page id for a database row, creating it if needed.
- existing live page → returned as-is;
- soft-deleted page → restored (title resynced);
- missing → created with the row's title and the collection workspace.
Raises ``KeyError`` when the row does not exist.
"""
with get_conn() as conn:
row = conn.execute(
"SELECT id, collection_id, title FROM collection_pages WHERE id=?",
(row_id,),
).fetchone()
if not row:
raise KeyError(f"row {row_id} not found")
coll_row = conn.execute(
"SELECT id, workspace_id, parent_page_id, gitea_owner, gitea_repo "
"FROM collections WHERE id=?",
(row["collection_id"],),
).fetchone()
coll = dict(coll_row) if coll_row else {"id": row["collection_id"]}
existing = conn.execute(
"SELECT id, deleted_at, title FROM pages WHERE collection_row_id=?",
(row_id,),
).fetchone()
if existing:
if existing["deleted_at"]:
conn.execute(
"UPDATE pages SET deleted_at=NULL, parent_section='DbRow', "
"title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(row["title"] or existing["title"] or "Untitled", existing["id"]),
)
conn.commit()
return existing["id"]
ws_key, ws_id = _host_workspace(conn, coll)
cur = conn.execute(
"""INSERT INTO pages
(workspace, workspace_id, title, content, content_format,
parent_section, collection_id, collection_row_id)
VALUES (?, ?, ?, '', 'blocks', 'DbRow', ?, ?)""",
(ws_key, ws_id, row["title"] or "Untitled",
row["collection_id"], row_id),
)
page_id = cur.lastrowid
conn.commit()
logger.debug("created content page %s for row %s", page_id, row_id)
return page_id
def sync_row_title_to_page(conn, row_id: int) -> None:
"""Row → page: copy the row's title onto its content page (if any)."""
conn.execute(
"""UPDATE pages SET title=(
SELECT title FROM collection_pages WHERE id=pages.collection_row_id
), updated_at=CURRENT_TIMESTAMP
WHERE collection_row_id=? AND EXISTS (
SELECT 1 FROM collection_pages cp
WHERE cp.id=pages.collection_row_id AND cp.title<>pages.title
)""",
(row_id,),
)
def sync_page_title_to_row(conn, page_id: int) -> None:
"""Page → row: a rename inside the editor propagates to the row."""
conn.execute(
"""UPDATE collection_pages SET title=(
SELECT title FROM pages WHERE id=?
), updated_at=CURRENT_TIMESTAMP
WHERE id=(SELECT collection_row_id FROM pages WHERE id=?)
AND EXISTS (
SELECT 1 FROM pages p
WHERE p.id=? AND p.collection_row_id IS NOT NULL
AND p.title<>collection_pages.title
)""",
(page_id, page_id, page_id),
)
+131 -16
View File
@@ -61,11 +61,17 @@ def update_synced_block(synced_id: int, title: str,
conn.commit()
def delete_synced_block(synced_id: int) -> None:
"""Delete a synced block (cascades via FK)."""
def delete_synced_block(synced_id: int) -> list[int]:
"""Delete a synced block (cascades via FK).
v6.5.0: returns the page ids that referenced it so the caller can
refresh/broadcast them (the FK cascade removes ``page_synced_blocks``).
"""
page_ids = page_ids_for_synced(synced_id)
with get_conn() as conn:
conn.execute("DELETE FROM synced_blocks WHERE id=?", (synced_id,))
conn.commit()
return page_ids
def list_synced_blocks(workspace: str) -> list[dict]:
@@ -136,18 +142,55 @@ def resolve_synced_block(blocks: list[dict]) -> list[dict]:
b["_synced_title"] = sb.get("title", "")
b["_synced_id"] = sb["id"]
b["_synced_updated"] = sb.get("updated_at", "")
b.pop("_synced_deleted", None)
except (json.JSONDecodeError, TypeError):
pass
elif not b.get("_synced_deleted"):
# v6.5.0: source deleted → instances render a clear state
# instead of a stale cache / eternal "Loading…".
b["_synced_deleted"] = True
b.pop("_synced_content", None)
b.pop("_synced_title", None)
if isinstance(b.get("children"), list):
resolve_synced_block(b["children"])
return blocks
def page_ids_for_synced(synced_id: int) -> list[int]:
"""Pages currently referencing a synced block (before any cascade)."""
with get_conn() as conn:
rows = conn.execute(
"SELECT page_id FROM page_synced_blocks WHERE synced_block_id=?",
(synced_id,),
).fetchall()
return [r["page_id"] for r in rows]
def resolve_content_json(content: str, content_format: str | None) -> str:
"""Resolve synced-block references inside a page's stored blocks JSON.
Server-side freshness guarantee (v6.5.0): every read path passes through
this so a stale cached ``_synced_content`` is replaced by the current
source-of-truth content. Non-blocks content is returned unchanged.
"""
if (content_format or "blocks") != "blocks" or not content:
return content
try:
blocks = json.loads(content)
except (json.JSONDecodeError, TypeError):
return content
if not isinstance(blocks, list):
return content
return json.dumps(resolve_synced_block(blocks), ensure_ascii=False)
def sync_synced_blocks_in_page(page_id: int) -> None:
"""Re-resolve all synced blocks in a page's content.
Called after a synced block is updated — refreshes the referencing
pages' block content so they reflect the latest source.
pages' stored content so they reflect the latest source (v6.5.0: this
used to be dead code; it is now invoked by the update/delete routes,
recursively covering blocks nested in columns/toggles).
"""
refs = get_page_synced(page_id)
if not refs:
@@ -163,22 +206,48 @@ def sync_synced_blocks_in_page(page_id: int) -> None:
blocks = json.loads(row["content"])
except (json.JSONDecodeError, TypeError):
return
if not isinstance(blocks, list):
return
sources: dict[int, dict] = {r["synced_block_id"]: get_synced_block(r["synced_block_id"])
for r in refs}
changed = False
for ref in refs:
sb = get_synced_block(ref["synced_block_id"])
if sb:
try:
resolved = json.loads(sb["content"])
except (json.JSONDecodeError, TypeError):
continue
for b in blocks:
if b.get("type") == "synced" and b.get("synced_id") == ref["synced_block_id"]:
b["content"] = json.dumps(resolved, ensure_ascii=False) if isinstance(resolved, list) else json.dumps(resolved)
b["_synced_content"] = resolved
b["_synced_title"] = sb.get("title", "")
def _walk(bl: list[dict]) -> None:
nonlocal changed
for b in bl:
if b.get("type") == "synced" and b.get("synced_id"):
sid = b["synced_id"]
if sid not in sources:
sources[sid] = get_synced_block(sid)
sb = sources.get(sid)
if sb:
try:
resolved = json.loads(sb["content"])
except (json.JSONDecodeError, TypeError):
continue
content_str = (json.dumps(resolved, ensure_ascii=False)
if isinstance(resolved, list)
else json.dumps(resolved))
title = sb.get("title", "")
if (b.get("content") != content_str
or b.get("_synced_content") != resolved
or b.get("_synced_title") != title
or b.get("_synced_deleted")):
b["content"] = content_str
b["_synced_content"] = resolved
b["_synced_title"] = title
b.pop("_synced_deleted", None)
changed = True
elif not b.get("_synced_deleted"):
b["_synced_deleted"] = True
b.pop("_synced_content", None)
b.pop("_synced_title", None)
changed = True
break
if isinstance(b.get("children"), list):
_walk(b["children"])
_walk(blocks)
if changed:
with get_conn() as conn:
@@ -189,6 +258,52 @@ def sync_synced_blocks_in_page(page_id: int) -> None:
conn.commit()
def mark_synced_block_deleted(synced_id: int, page_ids: list[int]) -> None:
"""Flag a deleted synced block's instances as ``_synced_deleted``.
v6.5.0 — called after ``delete_synced_block()``: the FK cascade already
removed ``page_synced_blocks`` refs, so ``sync_synced_blocks_in_page``
cannot be used here; this rewrites the stored blocks directly so every
surface (editor, offline sync, export) sees the deleted state.
"""
for pid in page_ids:
with get_conn() as conn:
row = conn.execute(
"SELECT content, content_format FROM pages WHERE id=?",
(pid,),
).fetchone()
if not row or row["content_format"] != "blocks":
continue
try:
blocks = json.loads(row["content"])
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(blocks, list):
continue
changed = False
def _walk(bl: list[dict]) -> None:
nonlocal changed
for b in bl:
if (b.get("type") == "synced"
and b.get("synced_id") == synced_id
and not b.get("_synced_deleted")):
b["_synced_deleted"] = True
b.pop("_synced_content", None)
b.pop("_synced_title", None)
changed = True
if isinstance(b.get("children"), list):
_walk(b["children"])
_walk(blocks)
if changed:
conn.execute(
"UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(blocks, ensure_ascii=False), pid),
)
conn.commit()
# ── Unsync: convert synced block to independent copy ──────────────
def unsync_block(page_id: int, synced_block_id: int) -> list[dict] | None:
+15 -1
View File
@@ -1412,7 +1412,21 @@
};
}
var iframe = document.getElementById('db-side-peek-iframe');
if (iframe) { iframe.src = '/pages/'+pageId+'?embed=1'; panel.style.display = 'flex'; }
// v6.5.0: a database row id is NOT a pages id (raw /pages/{rowId}
// opened an unrelated page with the same id) — resolve the row's
// content page first (lazy-created), then load the real editor.
resolveRowContentPage(pageId).then(function(pid){
var fr = document.getElementById('db-side-peek-iframe');
if (fr) { fr.src = '/pages/'+pid+'?embed=1'; panel.style.display = 'flex'; }
});
if (iframe) panel.style.display = 'flex';
}
function resolveRowContentPage(rowId){
return fetch('/db/pages/'+rowId+'/open/api')
.then(function(r){ return r.ok ? r.json() : null; })
.then(function(d){ return (d && d.page_id) ? d.page_id : rowId; })
.catch(function(){ return rowId; });
}
})();
</script>
+7
View File
@@ -570,6 +570,13 @@ window.__fdEditorScriptsLoaded = true;
return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="text-align:center;padding:60px 20px;background:var(--bg-secondary);border:1px dashed var(--border);border-radius:8px;cursor:pointer;" onclick="E.setEmbedUrl('${b.id}')"><div style="font-size:48px;margin-bottom:12px;">🔗</div><div style="font-size:14px;color:var(--text-dim);">Click to add embed URL</div></div></div>`;
}
if(b.type==='synced'){
if(b._synced_deleted){
// v6.5.0: source deleted → clear state instead of stale cache.
return `<div class="block-wrapper synced-block-wrapper" data-id="${b.id}" style="border-left:3px solid #e05e5e;background:var(--bg-secondary);opacity:.8">
<div class="synced-badge" style="font-size:11px;color:#e05e5e;padding:4px 8px;font-weight:600;">🔗 Deleted synced block</div>
<div style="color:var(--text-dim);padding:0 8px 8px;font-size:12px;">The original synced block was deleted — this copy no longer updates.</div>
</div>`;
}
var syncedTitle=b._synced_title||'Synced block';
var syncedContent=b._synced_content||[];
var sid=b.synced_id||'';