From 4038e9bdadb2ff05500655ae2b351c295d1f3092 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 14 Sep 2026 11:45:44 -0400 Subject: [PATCH] fix(editor): repair block identity for template pages (duplicate/reordered lines) Template-created pages (weekly report, project doc, meeting notes, to-do list, ...) were persisted without block ids. The editor assigned ids client-side, but the realtime room loaded the raw id-less content and sent it back on 'sync'; applySync then merged id-less server blocks with local blocks, producing data-bid='undefined' collisions and duplicated/shuffled lines as soon as the user edited. Editing an empty page was unaffected because the server state was empty. Fixes: - board.use_page_template: materialize unique block ids (recursively) when instantiating built-in or user templates. - realtime_server: unique block_id() (uuid) + ensure_block_ids() on room load and on insert ops. - editor: recursive ensureBlockIds() in init; gtTok() now restores [[fddate:...]] tokens (date chips survived as labels before). - realtime client: applySync() normalizes ids, no longer appends unknown local blocks (duplication), and keeps local content when server is empty. Tests: pytest (templates + realtime) and Playwright e2e covering to-do list, weekly report date chip, Enter ordering and legacy id-less repair. --- app/routers/board.py | 23 +++- app/services/realtime_server.py | 25 +++- app/templates/_page_editor_realtime.html | 25 ++-- app/templates/_page_editor_scripts.html | 8 +- e2e/editor_template.spec.js | 157 +++++++++++++++++++++++ tests/test_v511_v512_wiki_templates.py | 17 +++ 6 files changed, 242 insertions(+), 13 deletions(-) create mode 100644 e2e/editor_template.spec.js diff --git a/app/routers/board.py b/app/routers/board.py index cadce6c..97d0e46 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -42,6 +42,25 @@ def _ensure_page_editable(conn, page_id: int, user: dict | None) -> None: raise HTTPException(423, "Page is locked — only the owner of the lock or an admin can edit") +def _ensure_block_ids(blocks) -> None: + """Assign unique ids to blocks missing one, recursively. + + Built-in page templates ship without ids (the editor used to assign them + client-side only). Without persisted ids, the realtime layer and the editor + disagree on block identity, which duplicated lines / shuffled blocks when + editing a template-created page. We now materialize ids at creation time. + """ + import uuid + if not isinstance(blocks, list): + return + for b in blocks: + if isinstance(b, dict): + if not b.get("id"): + b["id"] = "b" + uuid.uuid4().hex[:12] + if isinstance(b.get("children"), list): + _ensure_block_ids(b["children"]) + + @router.get("/api/wiki/pages") async def wiki_page_search(request: Request, q: str = Query(default="")): """v5.11.0: page picker for [[ wiki-links. Fuzzy-ish title search across @@ -274,9 +293,11 @@ async def use_page_template(request: Request, template_id: int): if body_ws: ws_key = body_ws try: - json.loads(blocks_json) + parsed_blocks = json.loads(blocks_json) except (json.JSONDecodeError, TypeError): raise HTTPException(500, "Template content corrupted") from None + _ensure_block_ids(parsed_blocks) + blocks_json = json.dumps(parsed_blocks) with get_conn() as conn: if ws_id is not None: next_order = conn.execute( diff --git a/app/services/realtime_server.py b/app/services/realtime_server.py index a8ed8be..0a702c8 100644 --- a/app/services/realtime_server.py +++ b/app/services/realtime_server.py @@ -25,8 +25,26 @@ def color_for(uid: int) -> str: def block_id() -> str: - import time - return f"b{int(time.time()*1000)}" + import uuid + return "b" + uuid.uuid4().hex[:12] + + +def ensure_block_ids(blocks: list[dict]) -> list[dict]: + """Assign unique ids to blocks missing one, recursively. + + Template-created pages may have been persisted without block ids; without + them the room state is not addressable by ops and the editor ends up with + ``data-bid="undefined"`` blocks (duplicated / reordered lines). + """ + if not isinstance(blocks, list): + return blocks + for b in blocks: + if isinstance(b, dict): + if not b.get("id"): + b["id"] = block_id() + if isinstance(b.get("children"), list): + ensure_block_ids(b["children"]) + return blocks def apply_op(blocks: list[dict], op: dict) -> list[dict]: @@ -37,6 +55,7 @@ def apply_op(blocks: list[dict], op: dict) -> list[dict]: if not blk.get("id"): blk = dict(blk) blk["id"] = block_id() + ensure_block_ids([blk]) idx = op.get("index") if not isinstance(idx, int): idx = len(blocks) @@ -126,7 +145,7 @@ class RealtimeManager: room.title = row["title"] or "" if (row["content_format"] or "") == "blocks" and row["content"]: try: - room.blocks = json.loads(row["content"]) + room.blocks = ensure_block_ids(json.loads(row["content"])) except Exception: room.blocks = [] return True diff --git a/app/templates/_page_editor_realtime.html b/app/templates/_page_editor_realtime.html index ec9999e..68cb245 100644 --- a/app/templates/_page_editor_realtime.html +++ b/app/templates/_page_editor_realtime.html @@ -74,6 +74,7 @@ window.__fdRT = (function () { const blk = op.block || {}; const id = blk.id || ('rb' + Date.now().toString(36)); blk.id = id; + if (typeof ensureBlockIds === 'function') ensureBlockIds([blk]); let idx = op.index != null ? op.index : blocks.length; idx = Math.max(0, Math.min(idx, blocks.length)); return blocks.slice(0, idx).concat([blk]).concat(blocks.slice(idx)); @@ -268,8 +269,13 @@ window.__fdRT = (function () { /* ── application des changements distants ── */ function applySync(blocks, title) { if (!E || !E.blocks) return; + // v5.13.1: guarantee ids before comparing/merging. Server rooms may still + // carry legacy id-less blocks; without this they collide on + // data-bid="undefined" and the merge below duplicated every line. + blocks = (blocks || []).slice(); + if (typeof ensureBlockIds === 'function') ensureBlockIds(blocks); const curIds = JSON.stringify((E.blocks || []).map(b => b.id)); - const srvIds = JSON.stringify((blocks || []).map(b => b.id)); + const srvIds = JSON.stringify(blocks.map(b => b.id)); if (curIds === srvIds) { base = clone(E.blocks); E.dirty = false; @@ -280,14 +286,17 @@ window.__fdRT = (function () { (E.blocks || []).forEach(b => { curById[b.id] = b; }); let out; try { - out = (blocks || []).map(b => { - const cb = curById[b.id]; - if (cb && b.id === fid) return cb; // garde la frappe locale en cours - return b; - }); + if (!blocks.length) { + // serveur vide : ne pas effacer le contenu local (page neuve). + out = (E.blocks || []).slice(); + } else { + out = blocks.map(b => { + const cb = curById[b.id]; + if (cb && b.id === fid) return cb; // garde la frappe locale en cours + return b; + }); + } } catch (e) { return; } - const seen = {}; (blocks || []).forEach(b => { seen[b.id] = 1; }); - (E.blocks || []).forEach(b => { if (!seen[b.id]) out.push(b); }); E.blocks = out; const tEl = document.getElementById('_titleEl'); if (title && title !== E.pageTitle && document.activeElement !== tEl) { diff --git a/app/templates/_page_editor_scripts.html b/app/templates/_page_editor_scripts.html index b9e4266..6289e5e 100644 --- a/app/templates/_page_editor_scripts.html +++ b/app/templates/_page_editor_scripts.html @@ -25,6 +25,11 @@ window.__fdEditorScriptsLoaded = true; {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);} + // v5.13.1: guarantee every block (and nested child) has a stable id. + // Template blocks may be persisted without ids; leaving them undefined + // makes `data-bid="undefined"` collide and corrupts editing. + function ensureBlockIds(list){if(!Array.isArray(list))return list;list.forEach(function(b){if(b&&typeof b==='object'){if(!b.id)b.id=genId();if(Array.isArray(b.children))ensureBlockIds(b.children);}});return list;} + window.ensureBlockIds=ensureBlockIds; function cp(e){const s=window.getSelection();if(!s.rangeCount)return 0;const r=s.getRangeAt(0).cloneRange();r.selectNodeContents(e);r.setEnd(s.getRangeAt(0).endContainer,s.getRangeAt(0).endOffset);return r.toString().length;} function gt(e){return (e&&e.innerText!==undefined)?e.innerText:(e?e.textContent:'');} // v5.11.0: read a contenteditable block back to plain text, converting @@ -39,6 +44,7 @@ window.__fdEditorScriptsLoaded = true; if(n.nodeType===3){out+=n.nodeValue;continue;} if(n.nodeType===1){ if(n.classList&&n.classList.contains('fd-wiki-chip')){out+=n.getAttribute('data-token')||'';continue;} + if(n.classList&&n.classList.contains('fd-wiki-date')){out+=n.getAttribute('data-token')||'';continue;} if(n.tagName==='BR'){out+='\n';continue;} walk(n); } @@ -1049,7 +1055,7 @@ window.__fdEditorScriptsLoaded = true; const dataEl=document.getElementById('page-data'); if(dataEl){try{const data=JSON.parse(dataEl.textContent);this.pid=data.id;this.pageTitle=data.title||'';this.favorited=data.favorited||false;const fmt=data.content_format||'blocks';this.contentFormat=fmt;const raw=data.content||''; if(fmt==='file'){this.fileData=data;this.fileUrl=data.file_url||'';this.loadFileContent(data);} - else if(fmt==='blocks'&&raw){try{this.blocks=JSON.parse(raw);this.blocks.forEach(b=>{if(!b.id)b.id=genId()});}catch(e){this.blocks=[];}} + else if(fmt==='blocks'&&raw){try{this.blocks=JSON.parse(raw);ensureBlockIds(this.blocks);}catch(e){this.blocks=[];}} else if(raw&&raw.trim())this.blocks=this.md2b(raw); // v5.12.0: page lock + layout options this.isLocked=!!data.is_locked;this.lockedBy=data.locked_by||null; diff --git a/e2e/editor_template.spec.js b/e2e/editor_template.spec.js new file mode 100644 index 0000000..81a74c1 --- /dev/null +++ b/e2e/editor_template.spec.js @@ -0,0 +1,157 @@ +const { test, expect } = require('@playwright/test'); + +const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080'; +const USER = process.env.FD_USER || 'e2e@flowdeck.local'; +const PASS = process.env.FD_PASS || 'e2e-secret-123'; + +async function login(page) { + await page.goto(`${FD_BASE}/auth/login?provider=local`); + await page.fill('#email', USER); + await page.fill('#password', PASS); + await page.click('.btn-primary'); + await page.waitForURL('**/workspaces', { timeout: 15000 }); +} + +async function setupWorkspace(page, name) { + return await page.evaluate(async (n) => { + const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || ''; + const r = await fetch('/api/workspaces', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }, + body: JSON.stringify({ name: n }), + }); + const ws = await r.json(); + await fetch(`/api/workspaces/${ws.id}/select`, { method: 'POST' }); + return ws; + }, name); +} + +async function createFromTemplate(page, key, wsId) { + return await page.evaluate(async ({ k, id }) => { + const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || ''; + const r = await fetch('/board/api/page-templates/0/use', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }, + body: JSON.stringify({ key: k, workspace_id: id }), + }); + return await r.json(); + }, { k: key, id: wsId }); +} + +test.describe('Template page editor', () => { + test('to-do list: block ids remain valid after realtime sync', async ({ page }) => { + await login(page); + const ws = await setupWorkspace(page, 'Edit Repro WS ' + Date.now()); + const tpl = await createFromTemplate(page, 'todo_list', ws.id); + console.log('created page', tpl); + + await page.goto(`${FD_BASE}/pages/${tpl.id}`); + await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 }); + await page.waitForTimeout(3000); // let WS sync land + + const bids = await page.$$eval('#_blocksCt [data-bid]', (els) => + els.map((e) => e.getAttribute('data-bid')) + ); + console.log('BIDS:', JSON.stringify(bids)); + expect(bids.length).toBeGreaterThan(0); + expect(bids).not.toContain('undefined'); + expect(new Set(bids).size).toBe(bids.length); + }); + + test('weekly report: date chip survives editing', async ({ page }) => { + await login(page); + const ws = await setupWorkspace(page, 'Edit Repro WS2 ' + Date.now()); + const tpl = await createFromTemplate(page, 'weekly_report', ws.id); + + await page.goto(`${FD_BASE}/pages/${tpl.id}`); + await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 }); + await page.waitForTimeout(3000); + + // Type in the first heading then read back the saved content token + const h1 = page.locator('#_blocksCt .block-content').first(); + await h1.click(); + await page.keyboard.press('End'); + await page.keyboard.type(' (edited)'); + await page.waitForTimeout(2500); // autosave + + const stored = await page.evaluate(async (pid) => { + const r = await fetch('/board/api/pages/' + pid); + return (await r.json()).content; + }, tpl.id); + console.log('STORED:', stored); + expect(stored).toContain('fddate'); + }); + + test('to-do list: typing + Enter keeps order and does not duplicate', async ({ page }) => { + await login(page); + const ws = await setupWorkspace(page, 'Edit Repro WS3 ' + Date.now()); + const tpl = await createFromTemplate(page, 'todo_list', ws.id); + + await page.goto(`${FD_BASE}/pages/${tpl.id}`); + await page.waitForSelector('#_blocksCt .block-todo [contenteditable][data-bid]', { timeout: 15000 }); + await page.waitForTimeout(2500); // let WS sync land + + const first = page.locator('#_blocksCt .block-todo [contenteditable][data-bid]').first(); + await first.click(); + await page.keyboard.type('Task A'); + await page.keyboard.press('Enter'); + await page.waitForTimeout(250); // editor refocuses the new block after render + await page.keyboard.type('Task B'); + await page.waitForTimeout(2500); // autosave + + const stored = await page.evaluate(async (pid) => { + const r = await fetch('/board/api/pages/' + pid); + return JSON.parse((await r.json()).content); + }, tpl.id); + + const texts = stored.map((b) => b.content); + console.log('TODO STORED:', JSON.stringify(texts)); + // heading + 3 to_do (template) + 1 inserted by Enter + expect(texts).toEqual(['To-do', 'Task A', 'Task B', '', '']); + const ids = stored.map((b) => b.id); + expect(new Set(ids).size).toBe(ids.length); + expect(ids).not.toContain(undefined); + }); + + test('legacy id-less page is repaired on load (no undefined ids / duplicates)', async ({ page }) => { + await login(page); + const ws = await setupWorkspace(page, 'Edit Repro Legacy ' + Date.now()); + + // create an empty page, then write id-less blocks directly to simulate a + // page created before block ids were persisted (old templates). + const pid = await page.evaluate(async (wsId) => { + const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || ''; + const r = await fetch('/api/local-workspace/items', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }, + body: JSON.stringify({ name: 'Legacy', type: 'page', workspace_id: wsId }), + }); + const d = await r.json(); + await fetch('/board/api/pages/' + d.id + '/blocks', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }, + body: JSON.stringify({ + title: 'Legacy', + blocks: [ + { type: 'heading_1', content: 'To-do' }, + { type: 'to_do', content: '', checked: false }, + { type: 'to_do', content: '', checked: false }, + ], + }), + }); + return d.id; + }, ws.id); + + await page.goto(`${FD_BASE}/pages/${pid}`); + await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 }); + await page.waitForTimeout(3000); // let realtime sync land + + const bids = await page.$$eval('#_blocksCt [data-bid]', (els) => + els.map((e) => e.getAttribute('data-bid')) + ); + console.log('LEGACY BIDS:', JSON.stringify(bids)); + expect(bids.length).toBe(3); + expect(bids).not.toContain('undefined'); + expect(new Set(bids).size).toBe(bids.length); + }); +}); diff --git a/tests/test_v511_v512_wiki_templates.py b/tests/test_v511_v512_wiki_templates.py index b168425..c8f6ab7 100644 --- a/tests/test_v511_v512_wiki_templates.py +++ b/tests/test_v511_v512_wiki_templates.py @@ -283,6 +283,23 @@ def test_use_builtin_template_creates_page(client): cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c}).status_code == 404 +def test_use_builtin_template_assigns_block_ids(client): + """Template blocks must be persisted with unique ids (v5.13.1). + + Without ids the realtime room and the editor disagree on block identity, + which duplicated/shuffled lines when editing a template page. + """ + s, c, uid = _login(client) + r = client.post("/board/api/page-templates/0/use", json={"key": "weekly_report"}, + cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c}) + assert r.status_code == 200 + page = client.get(f"/board/api/pages/{r.json()['id']}").json() + blocks = json.loads(page["content"]) + ids = [b.get("id") for b in blocks] + assert all(ids), f"missing ids: {ids}" + assert len(set(ids)) == len(ids), f"duplicate ids: {ids}" + + def test_use_template_persists_workspace_id(client): s, c, uid = _login(client) # Create a local workspace owned by this user