320 lines
12 KiB
Python
320 lines
12 KiB
Python
"""FlowDeck — v5.14.0 Synced blocks: a block created once, displayed
|
|
and edited across multiple pages.
|
|
|
|
The ``synced_blocks`` table stores the source-of-truth content. Each
|
|
page that uses a synced block stores a reference in ``page_synced_blocks``.
|
|
Editing the source propagates to all referencing pages (via realtime
|
|
rooms or on next load).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import uuid
|
|
|
|
from app.db import get_conn
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def block_id() -> str:
|
|
return "sb" + uuid.uuid4().hex[:10]
|
|
|
|
|
|
# ── SyncedBlock CRUD ──────────────────────────────────────────────
|
|
|
|
def create_synced_block(workspace: str, title: str, content: list[dict],
|
|
created_by: int | None = None) -> int:
|
|
"""Create a new synced block and return its id."""
|
|
content_json = json.dumps(content, ensure_ascii=False) if content else "[]"
|
|
with get_conn() as conn:
|
|
cur = conn.execute(
|
|
"""INSERT INTO synced_blocks (title, content, created_by, workspace)
|
|
VALUES (?, ?, ?, ?)""",
|
|
(title, content_json, created_by, workspace),
|
|
)
|
|
conn.commit()
|
|
return cur.lastrowid
|
|
|
|
|
|
def get_synced_block(synced_id: int) -> dict | None:
|
|
"""Fetch a synced block by id."""
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM synced_blocks WHERE id=?", (synced_id,)
|
|
).fetchone()
|
|
if not row:
|
|
return None
|
|
return dict(row)
|
|
|
|
|
|
def update_synced_block(synced_id: int, title: str,
|
|
content: list[dict]) -> None:
|
|
"""Update a synced block's title and content."""
|
|
content_json = json.dumps(content, ensure_ascii=False)
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"""UPDATE synced_blocks SET title=?, content=?,
|
|
updated_at=CURRENT_TIMESTAMP WHERE id=?""",
|
|
(title, content_json, synced_id),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
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]:
|
|
"""List all synced blocks in a workspace."""
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT * FROM synced_blocks WHERE workspace=? ORDER BY updated_at DESC",
|
|
(workspace,),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
# ── Page references ───────────────────────────────────────────────
|
|
|
|
def add_page_synced(page_id: int, synced_block_id: int,
|
|
block_index: int = 0) -> None:
|
|
"""Record that a page references a synced block at a given index."""
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"""INSERT OR IGNORE INTO page_synced_blocks
|
|
(page_id, synced_block_id, block_index)
|
|
VALUES (?, ?, ?)""",
|
|
(page_id, synced_block_id, block_index),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def remove_page_synced(page_id: int, synced_block_id: int) -> None:
|
|
"""Remove a page's reference to a synced block."""
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"DELETE FROM page_synced_blocks WHERE page_id=? AND synced_block_id=?",
|
|
(page_id, synced_block_id),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def get_page_synced(page_id: int) -> list[dict]:
|
|
"""Get all synced block references for a page."""
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"""SELECT psb.*, sb.title, sb.content AS synced_content
|
|
FROM page_synced_blocks psb
|
|
JOIN synced_blocks sb ON sb.id=psb.synced_block_id
|
|
WHERE psb.page_id=? ORDER BY psb.block_index""",
|
|
(page_id,),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
# ── Block resolution ──────────────────────────────────────────────
|
|
|
|
def resolve_synced_block(blocks: list[dict]) -> list[dict]:
|
|
"""Replace synced block references with actual content for rendering.
|
|
|
|
A synced block in the block list looks like:
|
|
{"type": "synced", "synced_id": 42, "content": "..."}
|
|
This resolves it to the current content from the synced_blocks table.
|
|
"""
|
|
for b in blocks:
|
|
if b.get("type") == "synced" and b.get("synced_id"):
|
|
sb = get_synced_block(b["synced_id"])
|
|
if sb:
|
|
try:
|
|
resolved = json.loads(sb["content"])
|
|
if isinstance(resolved, list):
|
|
b["_synced_content"] = resolved
|
|
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' 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:
|
|
return
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT content, content_format FROM pages WHERE id=?",
|
|
(page_id,),
|
|
).fetchone()
|
|
if not row or row["content_format"] != "blocks":
|
|
return
|
|
try:
|
|
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
|
|
|
|
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
|
|
if isinstance(b.get("children"), list):
|
|
_walk(b["children"])
|
|
|
|
_walk(blocks)
|
|
|
|
if changed:
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
|
(json.dumps(blocks, ensure_ascii=False), page_id),
|
|
)
|
|
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:
|
|
"""Remove a page's sync reference and return the current content
|
|
so the caller can turn it into an independent block."""
|
|
sb = get_synced_block(synced_block_id)
|
|
if not sb:
|
|
return None
|
|
remove_page_synced(page_id, synced_block_id)
|
|
try:
|
|
return json.loads(sb["content"])
|
|
except (json.JSONDecodeError, TypeError):
|
|
return None
|