fix(editor): repair block identity for template pages (duplicate/reordered lines)
FlowDeck CI / lint (push) Successful in 52s
FlowDeck CI / test (push) Successful in 5m46s
FlowDeck CI / docker (push) Successful in 47s

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.
This commit is contained in:
2026-09-14 11:45:44 -04:00
parent 334a937507
commit 4038e9bdad
6 changed files with 242 additions and 13 deletions
+22 -1
View File
@@ -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(
+22 -3
View File
@@ -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
+17 -8
View File
@@ -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) {
+7 -1
View File
@@ -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;