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 <[email protected]>
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 ──
|
||||||
|
|||||||
@@ -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!"}]
|
||||||
Reference in New Issue
Block a user