feat: v5.14.0 Synced blocks - block created once, edited everywhere
- Table synced_blocks (source of truth) + page_synced_blocks (references) - Migration 15 + service app/services/synced_blocks.py - API endpoints: CRUD synced blocks, add/remove page references - Editor: /synced slash command, synced block rendering with badge - Realtime: _propagate_synced broadcasts updates to all referencing rooms - Export: synced blocks resolved in Markdown/HTML/PDF - 18 tests in tests/test_v514_synced_blocks.py Co-authored-by: FlowDeck <[email protected]>
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
"""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) -> None:
|
||||
"""Delete a synced block (cascades via FK)."""
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM synced_blocks WHERE id=?", (synced_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
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", "")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
if isinstance(b.get("children"), list):
|
||||
resolve_synced_block(b["children"])
|
||||
return blocks
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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
|
||||
|
||||
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", "")
|
||||
changed = True
|
||||
break
|
||||
|
||||
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()
|
||||
|
||||
|
||||
# ── 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
|
||||
Reference in New Issue
Block a user