From bf3d1ac0cc3ba21c8be71d07fd84192df9f3c537 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 17 Sep 2026 20:08:09 -0400 Subject: [PATCH] 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 --- VERSION | 2 +- app/migrations.py | 42 ++++ app/routers/board.py | 131 +++++++++++- app/services/export.py | 28 +++ app/services/realtime_server.py | 25 ++- app/services/synced_blocks.py | 204 ++++++++++++++++++ app/templates/_page_editor_realtime.html | 20 +- app/templates/_page_editor_scripts.html | 49 ++++- tests/test_v514_synced_blocks.py | 258 +++++++++++++++++++++++ 9 files changed, 744 insertions(+), 15 deletions(-) create mode 100644 app/services/synced_blocks.py create mode 100644 tests/test_v514_synced_blocks.py diff --git a/VERSION b/VERSION index dd0ad7a..0a70cf0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.12.0 +5.14.0 diff --git a/app/migrations.py b/app/migrations.py index ec245ab..be5dc54 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -588,3 +588,45 @@ def _migration_fix_llm_api_bases(conn: sqlite3.Connection) -> None: f"UPDATE {table} SET api_base='' WHERE provider=? AND api_base=?", (provider, value), ) + + +@register(15, "v5.14.0: synced blocks") +def _migration_synced_blocks(conn: sqlite3.Connection) -> None: + """v5.14.0 — Synced blocks: a block created once, displayed & + edited across multiple pages. + + ``synced_blocks`` — source-of-truth content for synced blocks. + ``page_blocks`` — per-page reference to a synced block + (so each page can independently decide to use/unsync). + """ + conn.execute( + """CREATE TABLE IF NOT EXISTS synced_blocks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL DEFAULT '', + content TEXT NOT NULL DEFAULT '[]', + created_by INTEGER REFERENCES users(id), + workspace TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )""" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_synced_blocks_ws ON synced_blocks(workspace)" + ) + + conn.execute( + """CREATE TABLE IF NOT EXISTS page_synced_blocks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE, + synced_block_id INTEGER NOT NULL REFERENCES synced_blocks(id) ON DELETE CASCADE, + block_index INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(page_id, synced_block_id) + )""" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_psb_page ON page_synced_blocks(page_id)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_psb_synced ON page_synced_blocks(synced_block_id)" + ) diff --git a/app/routers/board.py b/app/routers/board.py index 09bc9ac..27f9c46 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -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 diff --git a/app/services/export.py b/app/services/export.py index 45c35a5..13f0e1f 100644 --- a/app/services/export.py +++ b/app/services/export.py @@ -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"

{_text(resolved)}

") + except Exception: + parts.append(f"

[Synced block {synced_id}]

") else: parts.append(f"

{c}

") return "\n".join(parts) diff --git a/app/services/realtime_server.py b/app/services/realtime_server.py index 0a702c8..b8d06f3 100644 --- a/app/services/realtime_server.py +++ b/app/services/realtime_server.py @@ -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() diff --git a/app/services/synced_blocks.py b/app/services/synced_blocks.py new file mode 100644 index 0000000..a4f5a09 --- /dev/null +++ b/app/services/synced_blocks.py @@ -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 diff --git a/app/templates/_page_editor_realtime.html b/app/templates/_page_editor_realtime.html index 68cb245..e9ef1f2 100644 --- a/app/templates/_page_editor_realtime.html +++ b/app/templates/_page_editor_realtime.html @@ -384,13 +384,19 @@ window.__fdRT = (function () { peers = peers.filter(p => p.id !== m.peer.id).concat([m.peer]); renderPresence(); } - } else if (m.t === 'peer_leave') { - peers = peers.filter(p => p.id !== m.id); - delete remoteCursors[m.id]; - renderPresence(); - scheduleDraw(); - } - } + } else if (m.t === 'peer_leave') { + peers = peers.filter(p => p.id !== m.id); + delete remoteCursors[m.id]; + renderPresence(); + scheduleDraw(); + } else if (m.t === 'synced_update') { + // A synced block was updated — re-sync the whole page + if (m.synced_id) { + needSync = true; + send({ t: 'sync_req' }); + } + } + } /* ── connexion WS + fallback polling ── */ function startPolling() { diff --git a/app/templates/_page_editor_scripts.html b/app/templates/_page_editor_scripts.html index dd8c8f5..03c4f44 100644 --- a/app/templates/_page_editor_scripts.html +++ b/app/templates/_page_editor_scripts.html @@ -22,6 +22,7 @@ window.__fdEditorScriptsLoaded = true; {id:'audio',name:'Audio',icon:'🎵'}, ]}, {name:'DATA',items:[{id:'database',name:'Database / Inline database',icon:'📊'}]}, + {name:'SYNCED',items:[{id:'synced',name:'Synced block',icon:'🔗'}]}, {name:'ACTIONS',items:[{id:'duplicate',name:'Duplicate',icon:'▣'},{id:'turn_into',name:'Turn into…',icon:'↩'},{id:'copy_link',name:'Copy link to block',icon:'🔗'},{id:'delete',name:'Delete block',icon:'✕'}]}]; let _bid=0;function genId(){return 'b'+(++_bid)+'_'+Date.now().toString(36);} @@ -567,6 +568,16 @@ window.__fdEditorScriptsLoaded = true; } return `
🔗
Click to add embed URL
`; } + if(b.type==='synced'){ + var syncedTitle=b._synced_title||'Synced block'; + var syncedContent=b._synced_content||[]; + var sid=b.synced_id||''; + var inner=syncedContent.map(function(ch){return'
'+mdEsc(typeof ch==='object'?ch.content||'':ch)+'
';}).join(''); + return `
+
🔗 Synced — ${esc(syncedTitle)}
+
${inner||'
Loading synced content…
'}
+
`; + } const ph=b.type==='heading_1'?'Heading 1':b.type==='heading_2'?'Heading 2':b.type==='heading_3'?'Heading 3':b.type==='heading_4'?'Heading 4':b.type==='bulleted_list'?'List':b.type==='numbered_list'?'List':b.type==='to_do'?'To-do':b.type==='toggle'?'Toggle list':b.type==='quote'?'Empty quote':b.type==='code'?'Type code...':b.type==='callout'?'Type something...':(b.type==='paragraph'&&!b.content?'Press \'space\' for AI or \'/\' for commands':'Press \'/\' for commands...'); let inner=''; if(b.type==='to_do')inner=`
${mdEsc(b.content)}
`; @@ -1958,8 +1969,16 @@ applyAIBlocks(text){ }, applySlash(id){ const idx=SM._i;if(idx<0||idx>=this.blocks.length)return; - if(id==='database'){ this.openDbTemplatePicker(idx); return; } - if(id.indexOf('ai_')===0){ this.aiSlash(id,idx); return; } + if(id==='database'){ this.openDbTemplatePicker(idx); return; } + if(id==='synced'){ + const block=this.blocks[idx];this.sync();this.pushHistory(); + block.type='synced';block.synced_id=0;block._synced_content=[];block._synced_title=''; + this.render();this.dirty=true;this.autoSave();_rtSync(); + // Open synced block picker + this._pickSyncedBlock(idx); + return; + } + if(id.indexOf('ai_')===0){ this.aiSlash(id,idx); return; } if(id==='duplicate'){this.duplicateBlock(idx);return;} if(id==='delete'){this.removeBlock(idx);return;} if(id==='copy_link'){const blk=this.blocks[idx];if(blk)this.copyBlockLink(blk.id);return;} @@ -2016,8 +2035,30 @@ applyAIBlocks(text){ _addColumn(parentId){const idx=this.getIdx(parentId);if(idx<0)return;const b=this.blocks[idx];this.pushHistory();b.children.push({id:genId(),type:'paragraph',content:''});this.render();this.dirty=true;this.autoSave();_rtSync();setTimeout(()=>{const ne=this.getEl(b.children[b.children.length-1].id);if(ne)ne.focus();},60);}, _removeColumn(parentId,childId){const idx=this.getIdx(parentId);if(idx<0)return;const b=this.blocks[idx];this.pushHistory();b.children=b.children.filter(function(c){return c.id!==childId;});if(b.children.length===0)b.children=[{id:genId(),type:'paragraph',content:''}];this.render();this.dirty=true;this.autoSave();_rtSync();}, _toggleCalloutIcon(bid){const picker=document.getElementById('callout-picker-'+bid);if(picker)picker.style.display=picker.style.display==='none'?'flex':'none';}, - _setCalloutIcon(bid,icon){const idx=this.getIdx(bid);if(idx>=0){this.pushHistory();this.blocks[idx].icon=icon;this.dirty=true;this.autoSave();this.render();_rtSync();}}, - showFmt(idx){const s=window.getSelection();if(!s.rangeCount||s.isCollapsed){this.fmt.open=false;return;}const r=s.getRangeAt(0).getBoundingClientRect();this.fmt.open=true;this.fmt.idx=idx;this.fmt.top=Math.max(r.top-44,0);this.fmt.left=Math.min(r.left+r.width/2-120,window.innerWidth-260);this._sel={range:s.getRangeAt(0).cloneRange(),idx};}, + _setCalloutIcon(bid,icon){const idx=this.getIdx(bid);if(idx>=0){this.pushHistory();this.blocks[idx].icon=icon;this.dirty=true;this.autoSave();this.render();_rtSync();}}, + // ── v5.14.0: Synced blocks picker ── + async _pickSyncedBlock(idx){ + const block=this.blocks[idx];if(!block)return; + try{ + const r=await fetch('/api/synced-blocks');const d=await r.json(); + const items=d.synced_blocks||[]; + if(!items.length){this.showToast('No synced blocks yet. Create one first in Settings → Synced Blocks.');return;} + const names=items.map(function(s){return (s.title||'Untitled')+' (id:'+s.id+')';}); + const choice=window.prompt('Choose a synced block:\n\n'+names.join('\n')); + if(!choice)return; + const match=choice.match(/id:(\d+)/); + if(!match)return; + const sid=parseInt(match[1]); + if(!sid){this.showToast('Invalid synced block','error');return;} + const sr=await fetch('/api/synced-blocks/'+sid);const sd=await sr.json(); + if(!sd||!sd.content){this.showToast('Synced block not found','error');return;} + block.synced_id=sid; + block._synced_content=sd.content||[]; + block._synced_title=sd.title||''; + this.render();this.dirty=true;this.autoSave();_rtSync(); + }catch(e){this.showToast('Failed to load synced blocks','error');} + }, + showFmt(idx){const s=window.getSelection();if(!s.rangeCount||s.isCollapsed){this.fmt.open=false;return;}const r=s.getRangeAt(0).getBoundingClientRect();this.fmt.open=true;this.fmt.idx=idx;this.fmt.top=Math.max(r.top-44,0);this.fmt.left=Math.min(r.left+r.width/2-120,window.innerWidth-260);this._sel={range:s.getRangeAt(0).cloneRange(),idx};}, fmtApply(f){this.fmt.open=false;if(!this._sel)return;const s=window.getSelection();s.removeAllRanges();s.addRange(this._sel.range);document.execCommand(f==='strikeThrough'?'strikeThrough':f);this._sel=null;this.dirty=true;this.autoSave();}, fmtLink(){this.fmt.open=false;const u=prompt('URL:');if(u){document.execCommand('createLink',false,u);this.dirty=true;this.autoSave();}}, // ── v4.9.0: Collaboration — comments & mentions ── diff --git a/tests/test_v514_synced_blocks.py b/tests/test_v514_synced_blocks.py new file mode 100644 index 0000000..4e0681b --- /dev/null +++ b/tests/test_v514_synced_blocks.py @@ -0,0 +1,258 @@ +"""FlowDeck — Tests for v5.14.0 (Synced blocks).""" +from __future__ import annotations + +import json +import secrets + +from app.db import get_conn +from app.services.synced_blocks import ( + add_page_synced, + create_synced_block, + delete_synced_block, + get_page_synced, + get_synced_block, + list_synced_blocks, + remove_page_synced, + update_synced_block, +) + + +# ── Helpers ────────────────────────────────────────────────────── + +def _login(client): + from app.auth.session import SessionManager + + login = f"test_{secrets.token_hex(4)}" + with get_conn() as conn: + conn.execute( + "INSERT INTO users (login, full_name, email, is_admin) " + "VALUES (?, 'Test User', ?, 0)", + (login, f"{login}@test.com",), + ) + uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"] + conn.commit() + session = SessionManager.create_session({"id": uid, "login": login}) + r2 = client.get("/api/csrf-token", cookies={"flowdeck_session": session}) + csrf = r2.json()["csrf_token"] + return session, csrf, uid + + +def _create_page(client, cookie, csrf, title="Test Page", content="[]"): + r = client.post( + f"/board/api/pages?title={title}", + headers={"X-CSRF-Token": csrf}, + cookies={"flowdeck_session": cookie}, + ) + assert r.status_code == 200 + return r.json()["id"] + + +# ── Service tests ──────────────────────────────────────────────── + +def test_create_synced_block(client): + """Creating a synced block returns its id and stores it.""" + session, _, uid = _login(client) + sid = create_synced_block(workspace="test", title="My synced", content=[{"type": "paragraph", "content": "Hello"}], created_by=uid) + assert sid > 0 + sb = get_synced_block(sid) + assert sb is not None + assert sb["title"] == "My synced" + assert json.loads(sb["content"]) == [{"type": "paragraph", "content": "Hello"}] + assert sb["workspace"] == "test" + + +def test_get_synced_block_not_found(client): + """Getting a non-existent synced block returns None.""" + sb = get_synced_block(99999) + assert sb is None + + +def test_update_synced_block(client): + """Updating a synced block changes its content.""" + session, _, uid = _login(client) + sid = create_synced_block(workspace="test", title="Original", content=[{"type": "paragraph", "content": "Old"}], created_by=uid) + update_synced_block(sid, "Updated", [{"type": "heading_1", "content": "New"}]) + sb = get_synced_block(sid) + assert sb["title"] == "Updated" + assert json.loads(sb["content"]) == [{"type": "heading_1", "content": "New"}] + + +def test_delete_synced_block(client): + """Deleting a synced block removes it.""" + session, _, uid = _login(client) + sid = create_synced_block(workspace="test", title="To delete", content=[], created_by=uid) + delete_synced_block(sid) + assert get_synced_block(sid) is None + + +def test_list_synced_blocks(client): + """Listing synced blocks returns all for a workspace.""" + session, _, uid = _login(client) + create_synced_block(workspace="ws1", title="A", content=[], created_by=uid) + create_synced_block(workspace="ws1", title="B", content=[], created_by=uid) + create_synced_block(workspace="ws2", title="C", content=[], created_by=uid) + items = list_synced_blocks("ws1") + assert len(items) == 2 + titles = {i["title"] for i in items} + assert titles == {"A", "B"} + + +# ── Page reference tests ───────────────────────────────────────── + +def test_add_page_synced(client): + """Adding a synced block reference to a page works.""" + session, csrf, uid = _login(client) + sid = create_synced_block(workspace="test", title="Sync1", content=[], created_by=uid) + page_id = _create_page(client, session, csrf, "Page with sync") + add_page_synced(page_id, sid, block_index=0) + refs = get_page_synced(page_id) + assert len(refs) == 1 + assert refs[0]["synced_block_id"] == sid + + +def test_remove_page_synced(client): + """Removing a synced block reference from a page works.""" + session, csrf, uid = _login(client) + sid = create_synced_block(workspace="test", title="Sync1", content=[], created_by=uid) + page_id = _create_page(client, session, csrf, "Page with sync") + add_page_synced(page_id, sid, block_index=0) + remove_page_synced(page_id, sid) + assert len(get_page_synced(page_id)) == 0 + + +# ── API endpoint tests ─────────────────────────────────────────── + +def test_api_list_synced_blocks(client): + """GET /board/api/synced-blocks returns synced blocks.""" + session, _, uid = _login(client) + create_synced_block(workspace="test", title="API Test", content=[], created_by=uid) + r = client.get("/board/api/synced-blocks?workspace=test", cookies={"flowdeck_session": session}) + assert r.status_code == 200 + data = r.json() + assert len(data["synced_blocks"]) >= 1 + assert data["synced_blocks"][0]["title"] == "API Test" + + +def test_api_create_synced_block(client): + """POST /board/api/synced-blocks creates a synced block.""" + session, csrf, uid = _login(client) + r = client.post("/board/api/synced-blocks", json={ + "title": "From API", + "content": [{"type": "paragraph", "content": "Hello from API"}], + "workspace": "test", + }, headers={"X-CSRF-Token": csrf}, cookies={"flowdeck_session": session}) + assert r.status_code == 200 + data = r.json() + assert data["status"] == "ok" + assert data["synced_block_id"] > 0 + sb = get_synced_block(data["synced_block_id"]) + assert sb["title"] == "From API" + + +def test_api_get_synced_block(client): + """GET /board/api/synced-blocks/{sid} returns the block.""" + session, _, uid = _login(client) + sid = create_synced_block(workspace="test", title="GetTest", content=[], created_by=uid) + r = client.get(f"/board/api/synced-blocks/{sid}", cookies={"flowdeck_session": session}) + assert r.status_code == 200 + assert r.json()["title"] == "GetTest" + + +def test_api_get_synced_block_404(client): + """GET /board/api/synced-blocks/{sid} returns 404 for missing block.""" + session, _, uid = _login(client) + r = client.get("/board/api/synced-blocks/99999", cookies={"flowdeck_session": session}) + assert r.status_code == 404 + + +def test_api_update_synced_block(client): + """PUT /board/api/synced-blocks/{sid} updates the block.""" + session, csrf, uid = _login(client) + sid = create_synced_block(workspace="test", title="ToUpdate", content=[], created_by=uid) + r = client.put(f"/board/api/synced-blocks/{sid}", json={ + "title": "Updated", + "content": [{"type": "heading_1", "content": "Updated content"}], + }, headers={"X-CSRF-Token": csrf}, cookies={"flowdeck_session": session}) + assert r.status_code == 200 + sb = get_synced_block(sid) + assert sb["title"] == "Updated" + assert json.loads(sb["content"]) == [{"type": "heading_1", "content": "Updated content"}] + + +def test_api_delete_synced_block(client): + """DELETE /board/api/synced-blocks/{sid} deletes the block.""" + session, csrf, uid = _login(client) + sid = create_synced_block(workspace="test", title="ToDelete", content=[], created_by=uid) + r = client.delete(f"/board/api/synced-blocks/{sid}", headers={"X-CSRF-Token": csrf}, cookies={"flowdeck_session": session}) + assert r.status_code == 200 + assert get_synced_block(sid) is None + + +def test_api_add_synced_to_page(client): + """POST /board/api/pages/{id}/synced adds a synced reference.""" + session, csrf, uid = _login(client) + sid = create_synced_block(workspace="test", title="PageSync", content=[], created_by=uid) + page_id = _create_page(client, session, csrf, "Page with sync") + r = client.post(f"/board/api/pages/{page_id}/synced", json={ + "synced_block_id": sid, + "block_index": 0, + }, headers={"X-CSRF-Token": csrf}, cookies={"flowdeck_session": session}) + assert r.status_code == 200 + refs = get_page_synced(page_id) + assert len(refs) == 1 + + +def test_api_remove_synced_from_page(client): + """DELETE /board/api/pages/{id}/synced/{sid} removes a synced reference.""" + session, csrf, uid = _login(client) + sid = create_synced_block(workspace="test", title="PageSync", content=[], created_by=uid) + page_id = _create_page(client, session, csrf, "Page with sync") + add_page_synced(page_id, sid, block_index=0) + r = client.delete(f"/board/api/pages/{page_id}/synced/{sid}", headers={"X-CSRF-Token": csrf}, cookies={"flowdeck_session": session}) + assert r.status_code == 200 + assert len(get_page_synced(page_id)) == 0 + + +def test_api_get_page_synced_refs(client): + """GET /board/api/pages/{id}/synced returns synced refs for a page.""" + session, csrf, uid = _login(client) + sid = create_synced_block(workspace="test", title="RefTest", content=[], created_by=uid) + page_id = _create_page(client, session, csrf, "Page with sync") + add_page_synced(page_id, sid, block_index=0) + r = client.get(f"/board/api/pages/{page_id}/synced", cookies={"flowdeck_session": session}) + assert r.status_code == 200 + data = r.json() + assert len(data["synced_blocks"]) == 1 + assert data["synced_blocks"][0]["synced_block_id"] == sid + + +def test_save_blocks_with_synced(client): + """POST /board/api/pages/{id}/blocks with synced blocks updates page_synced_blocks.""" + session, csrf, uid = _login(client) + sid = create_synced_block(workspace="test", title="BlockSync", content=[], created_by=uid) + page_id = _create_page(client, session, csrf, "Page with synced blocks") + r = client.post(f"/board/api/pages/{page_id}/blocks", json={ + "blocks": [ + {"type": "paragraph", "content": "Hello", "id": "b1"}, + {"type": "synced", "synced_id": sid, "id": "b2"}, + ], + "title": "Updated", + }, headers={"X-CSRF-Token": csrf}, cookies={"flowdeck_session": session}) + assert r.status_code == 200 + # Verify the reference exists + refs = get_page_synced(page_id) + assert any(r["synced_block_id"] == sid for r in refs) + + +# ── Resolve synced blocks tests ────────────────────────────────── + +def test_resolve_synced_block(client): + """resolve_synced_block replaces synced block references with content.""" + from app.services.synced_blocks import resolve_synced_block + session, _, uid = _login(client) + sid = create_synced_block(workspace="test", title="ResolveTest", content=[{"type": "paragraph", "content": "Resolved!"}], created_by=uid) + blocks = [{"type": "synced", "synced_id": sid, "id": "b1"}] + resolved = resolve_synced_block(blocks) + assert len(resolved) == 1 + assert resolved[0].get("_synced_id") == sid + assert resolved[0].get("_synced_content") == [{"type": "paragraph", "content": "Resolved!"}] \ No newline at end of file