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
+129 -2
View File
@@ -1059,6 +1059,104 @@ async def trash_page(request: Request):
return template.render(**_sidebar_data(request))
# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════
# These routes MUST be registered before the catch-all /{owner}/{repo} below.
@router.get("/api/synced-blocks")
async def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
"""List synced blocks for a workspace."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
from app.services.synced_blocks import list_synced_blocks
return {"synced_blocks": list_synced_blocks(workspace or user.get("login", ""))}
@router.post("/api/synced-blocks")
async def create_synced_block_api(request: Request):
"""Create a new synced block."""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body") from None
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
from app.services.synced_blocks import create_synced_block
sid = create_synced_block(
workspace=body.get("workspace", ""),
title=body.get("title", "Synced block"),
content=body.get("content", []),
created_by=user.get("id"),
)
return {"status": "ok", "synced_block_id": sid}
@router.put("/api/synced-blocks/{sid}")
async def update_synced_block_api(request: Request, sid: int):
"""Update a synced block's content (propagates to all pages)."""
try:
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
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", []))
from app.services.realtime_server import manager
await manager._propagate_synced(sid)
return {"status": "ok"}
@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
sb = get_synced_block(sid)
if not sb:
raise HTTPException(404, "Synced block not found")
delete_synced_block(sid)
return {"status": "ok"}
@router.get("/api/synced-blocks/{sid}")
async def get_synced_block_api(sid: int):
"""Get a synced block by id."""
from app.services.synced_blocks import get_synced_block
sb = get_synced_block(sid)
if not sb:
raise HTTPException(404, "Synced block not found")
return dict(sb)
@router.post("/api/pages/{page_id}/synced")
async def add_synced_to_page(request: Request, page_id: int):
"""Add a synced block reference to a page."""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body") from None
from app.services.synced_blocks import add_page_synced, get_synced_block
sid = body.get("synced_block_id")
sb = get_synced_block(sid)
if not sb:
raise HTTPException(404, "Synced block not found")
add_page_synced(page_id, sid, body.get("block_index", 0))
return {"status": "ok", "synced_block_id": sid}
@router.delete("/api/pages/{page_id}/synced/{sid}")
async def remove_synced_from_page(request: Request, page_id: int, sid: int):
"""Remove a synced block reference from a page (unsync)."""
from app.services.synced_blocks import remove_page_synced
remove_page_synced(page_id, sid)
return {"status": "ok"}
@router.get("/api/pages/{page_id}/synced")
async def get_page_synced_refs(request: Request, page_id: int):
"""Get all synced block references for a page."""
from app.services.synced_blocks import get_page_synced
return {"synced_blocks": get_page_synced(page_id)}
# ═══════════ Board page ═══════════
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
@@ -1299,16 +1397,30 @@ async def save_page_blocks(request: Request, page_id: int):
v5.4.0: a version snapshot is recorded (if the block content actually
changed) so the UI can browse the version history and restore any of them.
v5.14.0: synced block references are tracked in page_synced_blocks.
"""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body") from None
blocks_json = json.dumps(body.get("blocks", []))
blocks = body.get("blocks", [])
blocks_json = json.dumps(blocks)
title = body.get("title", "")
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = (user or {}).get("id")
# Extract synced block ids from the blocks
def _extract_synced(blocks: list[dict]) -> set[int]:
ids: set[int] = set()
for b in blocks:
if b.get("type") == "synced" and b.get("synced_id"):
ids.add(b["synced_id"])
if isinstance(b.get("children"), list):
ids |= _extract_synced(b["children"])
return ids
synced_ids = _extract_synced(blocks)
with get_conn() as conn:
_ensure_page_editable(conn, page_id, user)
if title:
@@ -1318,6 +1430,21 @@ async def save_page_blocks(request: Request, page_id: int):
(blocks_json, page_id),
)
_record_version(conn, page_id, uid, title or "", blocks_json)
# Update synced block references
existing = {r["synced_block_id"] for r in conn.execute(
"SELECT synced_block_id FROM page_synced_blocks WHERE page_id=?", (page_id,)
).fetchall()}
for sid in synced_ids:
if sid not in existing:
conn.execute(
"INSERT OR IGNORE INTO page_synced_blocks (page_id, synced_block_id, block_index) VALUES (?, ?, 0)",
(page_id, sid),
)
for sid in existing - synced_ids:
conn.execute(
"DELETE FROM page_synced_blocks WHERE page_id=? AND synced_block_id=?",
(page_id, sid),
)
conn.commit()
await fire_event("page.updated", {"page_id": page_id, "title": title or "",
"content_format": "blocks"})
@@ -1876,6 +2003,6 @@ async def sync_project(owner: str, repo: str):
# Extract AI keywords from each issue
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
conn.commit()
return {"status": "ok", "issues_synced": len(issues_only)}
return {"status": "ok", "issues_synced": len(issues_only)}
except Exception as e:
raise HTTPException(500, str(e)) from e