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
+1 -1
View File
@@ -1 +1 @@
5.12.0
5.14.0
+42
View File
@@ -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)"
)
+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
+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
+13 -7
View File
@@ -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() {
+45 -4
View File
@@ -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 `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="text-align:center;padding:60px 20px;background:var(--bg-secondary);border:1px dashed var(--border);border-radius:8px;cursor:pointer;" onclick="E.setEmbedUrl('${b.id}')"><div style="font-size:48px;margin-bottom:12px;">🔗</div><div style="font-size:14px;color:var(--text-dim);">Click to add embed URL</div></div></div>`;
}
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'<div class="block-content" style="padding-left:16px;border-left:2px solid var(--accent);margin:4px 0;">'+mdEsc(typeof ch==='object'?ch.content||'':ch)+'</div>';}).join('');
return `<div class="block-wrapper synced-block-wrapper" data-id="${b.id}" style="border-left:3px solid var(--accent);background:var(--synced-bg,var(--bg-secondary))">
<div class="synced-badge" style="font-size:11px;color:var(--accent);padding:4px 8px;font-weight:600;">🔗 Synced — ${esc(syncedTitle)}</div>
<div class="synced-content">${inner||'<div style="color:var(--text-dim);padding:8px;">Loading synced content…</div>'}</div>
</div>`;
}
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=`<input type="checkbox" class="todo-checkbox" ${b.checked?'checked':''} onchange="E._doToggle('${b.id}',this.checked)"><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${mdEsc(b.content)}</div>`;
@@ -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 ──
+258
View File
@@ -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!"}]