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=?", f"UPDATE {table} SET api_base='' WHERE provider=? AND api_base=?",
(provider, value), (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)) 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 ═══════════ # ═══════════ Board page ═══════════
@router.get("/{owner}/{repo}", response_class=HTMLResponse) @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 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. 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: try:
body = await request.json() body = await request.json()
except Exception: except Exception:
raise HTTPException(400, "Invalid JSON body") from None 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", "") title = body.get("title", "")
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = (user or {}).get("id") 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: with get_conn() as conn:
_ensure_page_editable(conn, page_id, user) _ensure_page_editable(conn, page_id, user)
if title: if title:
@@ -1318,6 +1430,21 @@ async def save_page_blocks(request: Request, page_id: int):
(blocks_json, page_id), (blocks_json, page_id),
) )
_record_version(conn, page_id, uid, title or "", blocks_json) _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() conn.commit()
await fire_event("page.updated", {"page_id": page_id, "title": title or "", await fire_event("page.updated", {"page_id": page_id, "title": title or "",
"content_format": "blocks"}) "content_format": "blocks"})
@@ -1876,6 +2003,6 @@ async def sync_project(owner: str, repo: str):
# Extract AI keywords from each issue # Extract AI keywords from each issue
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", "")) _extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
conn.commit() conn.commit()
return {"status": "ok", "issues_synced": len(issues_only)} return {"status": "ok", "issues_synced": len(issues_only)}
except Exception as e: except Exception as e:
raise HTTPException(500, str(e)) from 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]") out.append(f"[{url}]({url})" if url else "[embed]")
elif t == "table": elif t == "table":
out.append(_table_to_markdown(b)) 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: else:
out.append(c) out.append(c)
return "\n\n".join(filter(None, out)) return "\n\n".join(filter(None, out))
@@ -678,6 +692,20 @@ def blocks_to_html(blocks: list) -> str:
) )
elif t == "table": elif t == "table":
parts.append(_table_to_html(b)) 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: else:
parts.append(f"<p>{c}</p>") parts.append(f"<p>{c}</p>")
return "\n".join(parts) return "\n".join(parts)
+24 -1
View File
@@ -304,10 +304,33 @@ class RealtimeManager:
return return
async def room_state(self, page_id: int) -> dict: 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: if not room.conns and not room.blocks:
await self.load_room(room) await self.load_room(room)
return {"blocks": room.blocks, "title": room.title, "version": room.version} 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() 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]); peers = peers.filter(p => p.id !== m.peer.id).concat([m.peer]);
renderPresence(); renderPresence();
} }
} else if (m.t === 'peer_leave') { } else if (m.t === 'peer_leave') {
peers = peers.filter(p => p.id !== m.id); peers = peers.filter(p => p.id !== m.id);
delete remoteCursors[m.id]; delete remoteCursors[m.id];
renderPresence(); renderPresence();
scheduleDraw(); 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 ── */ /* ── connexion WS + fallback polling ── */
function startPolling() { function startPolling() {
+45 -4
View File
@@ -22,6 +22,7 @@ window.__fdEditorScriptsLoaded = true;
{id:'audio',name:'Audio',icon:'🎵'}, {id:'audio',name:'Audio',icon:'🎵'},
]}, ]},
{name:'DATA',items:[{id:'database',name:'Database / Inline database',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:'✕'}]}]; {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);} 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>`; 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...'); 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=''; 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>`; 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){ applySlash(id){
const idx=SM._i;if(idx<0||idx>=this.blocks.length)return; const idx=SM._i;if(idx<0||idx>=this.blocks.length)return;
if(id==='database'){ this.openDbTemplatePicker(idx); return; } if(id==='database'){ this.openDbTemplatePicker(idx); return; }
if(id.indexOf('ai_')===0){ this.aiSlash(id,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==='duplicate'){this.duplicateBlock(idx);return;}
if(id==='delete'){this.removeBlock(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;} 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);}, _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();}, _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';}, _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();}}, _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};}, // ── 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();}, 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();}}, 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 ── // ── 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!"}]