feat: v5.14.0 Synced blocks - block created once, edited everywhere
FlowDeck CI / lint (push) Failing after 57s
FlowDeck CI / test (push) Successful in 6m34s
FlowDeck CI / docker (push) Successful in 59s

- 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:
2026-09-17 20:08:09 -04:00
co-authored by FlowDeck
parent f9da57c9e0
commit bf3d1ac0cc
9 changed files with 744 additions and 15 deletions
+28
View File
@@ -529,6 +529,20 @@ def blocks_to_markdown(blocks: list) -> str:
out.append(f"[{url}]({url})" if url else "[embed]")
elif t == "table":
out.append(_table_to_markdown(b))
elif t == "synced":
synced_id = b.get("synced_id")
if synced_id:
try:
from app.services.synced_blocks import get_synced_block
sb = get_synced_block(synced_id)
if sb and sb.get("content"):
resolved = json.loads(sb["content"])
if isinstance(resolved, list):
out.append(blocks_to_markdown(resolved))
else:
out.append(str(resolved))
except Exception:
out.append(f"[Synced block {synced_id}]")
else:
out.append(c)
return "\n\n".join(filter(None, out))
@@ -678,6 +692,20 @@ def blocks_to_html(blocks: list) -> str:
)
elif t == "table":
parts.append(_table_to_html(b))
elif t == "synced":
synced_id = b.get("synced_id")
if synced_id:
try:
from app.services.synced_blocks import get_synced_block
sb = get_synced_block(synced_id)
if sb and sb.get("content"):
resolved = json.loads(sb["content"])
if isinstance(resolved, list):
parts.append(blocks_to_html(resolved))
else:
parts.append(f"<p>{_text(resolved)}</p>")
except Exception:
parts.append(f"<p>[Synced block {synced_id}]</p>")
else:
parts.append(f"<p>{c}</p>")
return "\n".join(parts)
+24 -1
View File
@@ -304,10 +304,33 @@ class RealtimeManager:
return
async def room_state(self, page_id: int) -> dict:
room = self.room(page_id)
room = self._rooms.get(page_id)
if not room.conns and not room.blocks:
await self.load_room(room)
return {"blocks": room.blocks, "title": room.title, "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.db import get_conn as _get_conn
# Find all pages that reference this synced block
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
for pid in pages:
room = self._rooms.get(pid)
if room and room.conns:
# Re-load the page content from DB to get fresh synced blocks
await self.load_room(room)
await self._broadcast(room, {"t": "synced_update",
"synced_id": synced_id,
"version": room.version})
manager = RealtimeManager()
+204
View File
@@ -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