From b3c90efa7349a3701a4f9099e97a3c87aa110ef1 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 8 Sep 2026 09:33:45 -0400 Subject: [PATCH] v5.4.1 Partage : correctif permissions + autocomplete membres (inclut v5.4.0 Interactions de bloc) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix "Invalid permission" : le client envoyait editor/commenter/viewer alors que l'API attend view/comment/edit (invitePermission -> 'edit', menu participants aligné) - PUT /api/pages/{page_id}/share/{share_id} : mise à jour de permission persistée - Autocomplete des membres existants dans le champ d'invitation (search users + debounce, navigation clavier, envoi user_id pour lier le partage au compte) - Upsert anti-doublon dans le partage + trim email backend - 12 tests tests/test_sharing.py ; suite 319 verte (+3 PDF pre-existants) Sans oublier v5.4.0 (non publie jusque-la) : - Undo/Redo (Ctrl+Z/Ctrl+Shift+Z/Ctrl+Y), duplicate (Ctrl+D), menu de bloc (turn into, couleurs, lien #fdblk-, move to, delete), drag&drop multi-selection, slash "Actions", en-tetes de tableau (has_header/first_col_header) + exports, sync realtime immédiat apres mutation ; 9 tests test_block_interactions.py --- CHANGELOG.md | 58 ++++ ROADMAP.md | 39 +-- VERSION | 2 +- app/main.py | 4 +- app/routers/sharing.py | 65 ++++- app/services/export.py | 56 ++-- app/templates/_page_editor_content.html | 84 ++++-- app/templates/_page_editor_realtime.html | 9 +- app/templates/_page_editor_scripts.html | 353 +++++++++++++++++++---- static/css/app.css | 100 +++++++ tests/test_block_interactions.py | 202 +++++++++++++ tests/test_sharing.py | 202 +++++++++++++ 12 files changed, 1055 insertions(+), 119 deletions(-) create mode 100644 tests/test_block_interactions.py create mode 100644 tests/test_sharing.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 31c2215..31d5649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,63 @@ # Changelog - FlowDeck +## v5.4.1 (2026-09-08) — Partage de page : correctif permissions + autocomplete membres + +> Le partage à un membre existant échouait avec « Invalid permission. Use view, +> comment, or edit. » — le client envoyait `editor`/`commenter`/`viewer` alors +> que l'API n'accepte que `view`/`comment`/`edit`. Corrigé, et enrichi. + +- **Correctif permissions** — défaut du champ d'invitation passé à `edit` ; + valeurs, libellés et surbrillance du menu de permission des participants + alignés sur le contrat API (`view`/`comment`/`edit`). +- **Mise à jour de permission persistée** — nouvel endpoint + `PUT /api/pages/{page_id}/share/{share_id}` ; le menu de permission d'un + participant enregistre désormais le changement (avant : visuel uniquement). +- **Autocomplete des membres existants** — en tapant dans le champ d'invitation, + recherche dans `/api/notifications/users/search?q=` (debounce 150 ms) avec + liste de suggestions (avatar couleur, login, nom complet), navigation + clavier ↑/↓/Entrée/Échap ; soi-même et membres déjà partagés exclus. +- **Partage lié au compte** — la sélection d'un membre dans les suggestions + envoie `user_id`, le partage est donc lié au compte (pas seulement à un + email) ; un email peut toujours être saisi librement. +- **Upsert anti-doublon** — partager un membre déjà présent met à jour sa + permission au lieu de créer une ligne dupliquée ; trim de l'email côté serveur. +- **Tests** (tests/test_sharing.py) : partage par user_id/email, permission + invalide rejetée, upsert, PUT permission, suppression avec unset is_shared, + erreurs d'auth. Suite complète 319 verte (+3 PDF pré-existants). + +## v5.4.0 (2026-09-08) — Interactions de bloc + +> Manipulation riche des blocs façon Notion : undo/redo, duplication, menu de +> bloc ⋮ (turn into, couleurs, lien vers le bloc, déplacer, supprimer), +> glisser-déposer multi-sélection et en-têtes de tableau configurables. + +- **Undo/Redo** : pile d'historique (100 entrées) alimentée à chaque mutation + (frappe Entrée, retour arrière, slash, tableau, toggle, colonnes…), rappel + `Ctrl+Z` / `Ctrl+Shift+Z` ou `Ctrl+Y`, restauration avec re-focus du bloc. +- **Duplication de bloc** : `Ctrl+D`, menu bloc ou commande slash « Duplicate » ; + copie profonde (enfants columns/toggle inclus) insérée juste en dessous ; + en multi-sélection, le groupe entier est dupliqué. +- **Menu de bloc ⋮** sur la poignée (clic ou clic droit) : « Turn into » + (sous-menu conservant le contenu), Duplicate, Copy link to block + (`#fdblk-`, scrolls/focus au chargement), Move to… (dialogue de + recherche des pages de l'arbre, déplacement du bloc via l'API), Delete, + couleur de texte et de fond appliquées à un bloc ou à la sélection. +- **Glisser-déposer multi-sélection** : `Shift+clic` sur la poignée ajoute un + bloc à la sélection ; déplacer un bloc sélectionné déplace tout le groupe + (reconstruction de l'ordre depuis le DOM via `data-id`). +- **Commandes slash étendues** : groupe « Actions » avec Duplicate, Turn + into…, Copy link to block et Delete block. +- **En-têtes de tableau** : toggles « Header row » / « First col » dans la + barre du tableau ; `has_header` (toujours actif par défaut) et nouveaux + `first_col_header` persistés et pris en compte dans les exports Markdown/HTML + (app/services/export.py) ainsi que que dans `blocksToMarkdown` client. +- **Intégration realtime** : méthode `syncNow`/`forceEmit` sur `window.__fdRT` + appelée après chaque mutation programmatique pour pousser immédiatement les + ops (undo, duplicate, drag, turn into, couleurs, déplacement, toggles). +- **Tests** (tests/test_block_interactions.py) : persistance des blocs avec + `has_header`/`first_col_header`/`style`, exports table Markdown/HTML, + endpoint « Move to » (get page + POST blocks sur la cible). + ## v5.3.0 (2026-09-08) — Realtime : édition collaborative en direct > Deux utilisateurs peuvent maintenant éditer une même page **en même temps** diff --git a/ROADMAP.md b/ROADMAP.md index f794feb..e86b3ad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -479,6 +479,18 @@ app/ - [x] **Templates de database prédéfinis** — 6 templates seedés (CRM, Project tracker, Task list, Content calendar, Meeting notes, Reading list) + galerie au clic « Database » (Get Started) et `/database` ; `POST /db/api` & `/db/inline/api` acceptent `template` et matérialisent les propriétés - [x] **Validation des propriétés** (required, unique, min/max) — côté serveur (`validate_property_rule`, 400 + messages) + UI (modale propriété + erreurs de cellule) +### v5.10.0 — Éditeur : interactions de bloc ✅ (2026-09-08) +> **Objectif** : parité de manipulation des blocs avec Notion — les blocs étaient éditables +> mais *statiques* (impossible de les déplacer, dupliquer ou annuler une action). **COMPLETED**. + +- [x] **Drag & drop des blocs** — poignée ⋮⋮ au survol de chaque bloc, drag vertical pour réordonner, indicateur de drop (ligne bleue) ; **multi-sélection** (Shift+clic sur poignée → sélection groupée déplacée d'un seul geste, ordre reconstruit depuis le DOM via `data-id`) +- [x] **Menu contextuel de bloc** — clic (ou clic droit) sur ⋮⋮ : Turn into (sous-menu conservant le contenu), Duplicate, Copy link to block (`#fdblk-` avec re-focus), Move to (recherche de pages + API), Delete, couleurs texte/fond appliquées au bloc ou à la sélection +- [x] **Undo / Redo** — `Ctrl+Z` / `Ctrl+Shift+Z`/`Ctrl+Y`, pile de 100 opérations en mémoire du tab, re-focus du bloc restauré, déclencheur sur toutes les mutations (entrée, retour arrière, slash, tableaux, colonnes, toggles…) +- [x] **Duplicate block** — `Ctrl+D`, menu bloc ou slash command « Duplicate » ; copie profonde (enfants columns/toggle) en multi-sélection +- [x] **Slash command étendue** — groupe « Actions » : Turn into, Duplicate, Copy link to block, Delete block +- [x] **En-têtes de tableau** — toggles « Header row » / « First col » dans la barre du tableau (`has_header` défaut actif + nouveau `first_col_header`), persistés + rendu `` en preview et exports Markdown/HTML +- [x] **Intégration realtime** — `syncNow()` poussé après chaque mutation programmatique ; **9 tests** `tests/test_block_interactions.py` ; suite complète 307 verte (+3 PDF pré-existants) + ### v5.13.0 — Collaboration temps réel ✅ (2026-09-08) > **Objectif** : édition collaborative en direct (parité Notion en équipe). **COMPLETED**. > Chantier n°1 de la parité Notion ; socle pour v5.14.0 (synced blocks). @@ -544,16 +556,7 @@ app/ --- -## v5.10.0 — Éditeur : interactions de bloc ⬜ (non commencé) -> **Objectif** : parité de manipulation des blocs avec Notion. Actuellement les blocs sont -> éditables mais *statiques* : impossible de les déplacer, dupliquer ou annuler une action. - -- [ ] **Drag & drop des blocs** — poignée ⋮⋮ au survol de chaque bloc, drag vertical pour réordonner, indicateur de drop (ligne bleue), support multi-sélection de blocs (Shift+clic → déplacement groupé) -- [ ] **Menu contextuel de bloc** — clic sur ⋮⋮ : Turn into (changer le type), Duplicate, Copy link to block, Move to, Delete, couleur de texte/fond -- [ ] **Undo / Redo** — Ctrl+Z / Ctrl+Shift+Z ou Ctrl+Y sur l'état des blocs (pile d'opérations inversibles par page, conservée en mémoire du tab) -- [ ] **Duplicate block** — Ctrl+D sur le bloc courant (copie insérée juste en dessous) -- [ ] **Slash command étendue** — la commande `/` propose aussi : Turn into, Duplicate, et toutes les actions du menu bloc -- [ ] **En-têtes de tableau (bloc table v4.8.0)** — toggles « First row as header » / « First column as header » dans la config du bloc table, rendu `` correspondant en preview et exports +## v5.10.0 — Éditeur : interactions de bloc ✅ (livré — voir section Completed) ## v5.11.0 — Wiki-links & mentions de page ⬜ (non commencé) > **Objectif** : le graphe de connaissances Notion. Complète les backlinks de v5.4.0 @@ -614,7 +617,7 @@ app/ 2. ~~**v5.0.0 → Command palette + FTS5**~~ ✅ livré (palette Ctrl+K + `GET /api/search`) 3. ~~**v5.3.0 → Inline databases + templates + validation**~~ ✅ livré (slash `/database`, 6 templates, validation propriétés) 1. ~~**v5.13.0 → Realtime (WS + présence)**~~ ✅ livré (`app/services/realtime_server.py` + `WS /ws/pages/{id}`, présence, curseurs live, merge LWW, 12 tests) -2. **v5.10.0 → Interactions de bloc** (drag&drop, undo/redo, duplicate) — gros impact UX, effort modéré — prochaine priorité +2. ~~**v5.10.0 → Interactions de bloc**~~ ✅ livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, 9 tests) --- ## Résumé des phases @@ -626,11 +629,11 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard + UI Notion + Tags + Admin + Sharing COMPLETED + GitHub OAuth + Library -v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.3 ✅ v5.4–5.12 ⬜ v5.13 ✅ · v5.14 ⬜ v6.0 ⬜ -Quality DB views, Agent IA Palette → Éditeur bloc Realtime + Pro + Agent -& Tests Templates & COMPLETED Automations (drag&drop, Synced blocks - Collaboration Realtime, undo/redo) + - DB avancée, Wiki-links, - Calendrier, AI Templates & lock +v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.3 ✅ v5.13 ✅ · v5.10 ✅ v5.14 ⬜ v6.0 ⬜ +Quality DB views, Agent IA Palette → Realtime + Synced Pro + Agent +& Tests Templates & COMPLETED Automations Interactions blocks + Collaboration Realtime, de bloc (undo/ + DB avancée, redo, drag&drop, + Calendrier, AI duplicate) -*Dernière mise à jour: 2026-09-08 — v5.13.0 Realtime livré (WebSocket gateway, présence, curseurs live, merge LWW + fallback polling, 12 tests, suite 298 verte) ; prochaine priorité v5.10.0 Interactions de bloc ; reste v5.4 → v6.0* +*Dernière mise à jour: 2026-09-08 — v5.13.0 Realtime livré (WebSocket gateway, présence, curseurs live, merge LWW + fallback polling, 12 tests) puis v5.10.0 Interactions de bloc livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, sync realtime, 9 tests, suite 307 verte) ; reste v5.4 → v6.0* diff --git a/VERSION b/VERSION index e230c83..04edabd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.3.0 \ No newline at end of file +5.4.1 \ No newline at end of file diff --git a/app/main.py b/app/main.py index 718217e..4c5f98c 100644 --- a/app/main.py +++ b/app/main.py @@ -53,7 +53,7 @@ async def lifespan(_app: FastAPI): from app.services.automations import automation_scheduler automation_task = asyncio.create_task(automation_scheduler()) - logger.info("FlowDeck v5.3.0 started on port %d", settings.app_port) + logger.info("FlowDeck v5.4.1 started on port %d", settings.app_port) try: yield finally: @@ -71,7 +71,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="5.3.0", + version="5.4.1", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, diff --git a/app/routers/sharing.py b/app/routers/sharing.py index c545a18..9cf3a04 100644 --- a/app/routers/sharing.py +++ b/app/routers/sharing.py @@ -61,16 +61,41 @@ async def share_page(page_id: int, request: Request): if not target: raise HTTPException(404, "Target user not found") - cur = conn.execute( - """INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by) - VALUES (?, ?, ?, ?, ?)""", - (page_id, target_user_id, email, permission, user["id"]), - ) + # Upsert to avoid duplicates: update the existing permission if the same + # target (user or email) is already shared on this page. + target_row = None + if target_user_id: + target_row = conn.execute( + "SELECT id FROM page_shares WHERE page_id=? AND shared_with_user_id=?", + (page_id, target_user_id), + ).fetchone() + elif email: + target_row = conn.execute( + """SELECT id FROM page_shares + WHERE page_id=? AND shared_with_email=? AND shared_with_user_id IS NULL""", + (page_id, email.strip()), + ).fetchone() + + if target_row: + conn.execute( + "UPDATE page_shares SET permission=?, created_by=? WHERE id=?", + (permission, user["id"], target_row["id"]), + ) + share_id = target_row["id"] + else: + if not email: + email = "" + cur = conn.execute( + """INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by) + VALUES (?, ?, ?, ?, ?)""", + (page_id, target_user_id, email.strip(), permission, user["id"]), + ) + share_id = cur.lastrowid conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (page_id,)) conn.commit() return { - "id": cur.lastrowid, + "id": share_id, "page_id": page_id, "shared_with_user_id": target_user_id, "shared_with_email": email, @@ -79,6 +104,34 @@ async def share_page(page_id: int, request: Request): } +@router.put("/pages/{page_id}/share/{share_id}", description="Update a share's permission.") +async def update_share_permission(page_id: int, share_id: int, request: Request): + """Change the permission level of an existing share entry.""" + _require_auth(request) + + body = await request.json() if request.headers.get("content-type") else {} + permission = body.get("permission", "") + + if permission not in ("view", "comment", "edit"): + raise HTTPException(400, "Invalid permission. Use view, comment, or edit.") + + with get_conn() as conn: + row = conn.execute( + "SELECT id FROM page_shares WHERE id=? AND page_id=?", + (share_id, page_id), + ).fetchone() + if not row: + raise HTTPException(404, "Share entry not found") + + conn.execute( + "UPDATE page_shares SET permission=? WHERE id=?", + (permission, share_id), + ) + conn.commit() + + return {"status": "updated", "share_id": share_id, "permission": permission} + + @router.delete("/pages/{page_id}/share/{share_id}") async def remove_share(page_id: int, share_id: int, request: Request): """Remove a share invitation.""" diff --git a/app/services/export.py b/app/services/export.py index ad5f4b2..8ff699c 100644 --- a/app/services/export.py +++ b/app/services/export.py @@ -264,24 +264,28 @@ def _table_to_markdown(b: dict) -> str: align = b.get("align") or [] width = max(len(r) for r in rows) align = (align + ["left"] * width)[:width] + has_header = b.get("has_header", True) out: list[str] = [] def rowline(r): cells = list(r) + [""] * (width - len(r)) return "| " + " | ".join(cells) + " |" - for ri, r in enumerate(rows): - out.append(rowline(r)) - if b.get("has_header") and ri == 0: - seps = [] - for a in align: - if a == "center": - seps.append(":---:") - elif a == "right": - seps.append("---:") - else: - seps.append(":---") - out.append("| " + " | ".join(seps) + " |") + start = 0 + if has_header: + out.append(rowline(rows[0])) + seps = [] + for a in align: + if a == "center": + seps.append(":---:") + elif a == "right": + seps.append("---:") + else: + seps.append(":---") + out.append("| " + " | ".join(seps) + " |") + start = 1 + for ri in range(start, len(rows)): + out.append(rowline(rows[ri])) return "\n".join(out) @@ -297,16 +301,30 @@ def _table_to_html(b: dict) -> str: style = f' style="text-align:{a};"' if a and a != "left" else "" return f"<{tag}{style}>{_text(text)}" - def row_html(r, tag_default): - cells = list(r) + [""] * (width - len(r)) - return "" + "".join(cell_html(tag_default, c, align[ci]) for ci, c in enumerate(cells)) + "" - - header_rows = 1 if b.get("has_header") else 0 + has_header = b.get("has_header", True) + first_col = b.get("first_col_header", False) + header_rows = 1 if has_header else 0 head = "" if header_rows: - head = "" + "".join(row_html(r, "th") for r in rows[:header_rows]) + "" + head_rows = [] + hr = rows[0] + cells = list(hr) + [""] * (width - len(hr)) + head_cells = [] + for ci, c in enumerate(cells): + tag = "th" if first_col and ci == 0 else "th" + head_cells.append(cell_html(tag, c, align[ci])) + head_rows.append("" + "".join(head_cells) + "") + head = "" + "".join(head_rows) + "" tbody_rows = rows[header_rows:] - body = "" + "".join(row_html(r, "td") for r in tbody_rows) + "" + body_rows = [] + for r in tbody_rows: + cells = list(r) + [""] * (width - len(r)) + row_cells = [] + for ci, c in enumerate(cells): + tag = "th" if first_col and ci == 0 else "td" + row_cells.append(cell_html(tag, c, align[ci])) + body_rows.append("" + "".join(row_cells) + "") + body = "" + "".join(body_rows) + "" return f'{head}{body}
' diff --git a/app/templates/_page_editor_content.html b/app/templates/_page_editor_content.html index 30a9fbe..1c57ccd 100644 --- a/app/templates/_page_editor_content.html +++ b/app/templates/_page_editor_content.html @@ -33,17 +33,65 @@
-
- - +
+
+ + +
+ +
+ +
No matching users
+
@@ -78,7 +126,7 @@ @click="a.permOpen = !a.permOpen" > ▾ @@ -89,8 +137,8 @@ >
Can edit
Can comment
Can view \ No newline at end of file diff --git a/app/templates/_page_editor_scripts.html b/app/templates/_page_editor_scripts.html index 53ebcbc..809225c 100644 --- a/app/templates/_page_editor_scripts.html +++ b/app/templates/_page_editor_scripts.html @@ -11,7 +11,8 @@ {id:'columns',name:'Column list',icon:'▮'},{id:'divider',name:'Divider',icon:'—'},{id:'code',name:'Code block',icon:'<>'}, {id:'table',name:'Table',icon:'⊞'},{id:'button',name:'Button / Automation',icon:'⚡'}, ]},{name:'MEDIA',items:[{id:'image',name:'Image',icon:'🖼️'}]}, - {name:'DATA',items:[{id:'database',name:'Database / Inline database',icon:'📊'}]}]; + {name:'DATA',items:[{id:'database',name:'Database / Inline database',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);} 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;} @@ -65,8 +66,8 @@ var w=0;rows.forEach(function(r){if(r&&r.length>w)w=r.length;});if(w<1)w=2; var widths=(b&&Array.isArray(b.colsW))?b.colsW:[]; var bid=b.id;var h=''; - h+='
'; - h+='
'; + var hasHeader=(b.has_header!==false);var firstCol=(!!b.first_col_header); + h+='
'; h+='
'; for(var c=0;c'+_tblGrip()+'
'; @@ -74,22 +75,21 @@ h+='
'; h+=''; for(var ri=0;ri0&&firstCol);var tag='td'; + if(isHead){tag='th';} h+=''; for(var ci=0;ci0)?(' style="width:'+widths[ci]+'px"'):''; - h+='<'+tag+' class="ftable-cell'+(isHead?' head':'')+'" contenteditable="true" data-r="'+ri+'" data-c="'+ci+'"'+wpx+' spellcheck="false">'+esc(v); - if(isHead)h+=''; + h+='<'+tag+' class="ftable-cell'+(rcH?' head':'')+'" contenteditable="true" data-r="'+ri+'" data-c="'+ci+'"'+wpx+' spellcheck="false">'+esc(v); + if(ri===0&&hasHeader)h+=''; h+=''; } h+=''; } h+='
'; - h+='
'; - h+='
'; - h+='
'; - h+='
'; + h+='
'; h+='
'; return h; } @@ -123,6 +123,7 @@ var bid=cur.bid,index=cur.index,b=_tblBlockById(bid),e=window.E; if(!b||!e)return; e.sync(); + if(action!=='tbl-del')e.pushHistory(); if(!(b.rows&&b.rows.length))b.rows=_tblNew(2,2); var focusR=1,focusC=0; if(action==='col-insert-left'){_tblAddCol(b,index);focusC=index;} @@ -173,6 +174,7 @@ else if(act==='colplus'){var b=_tblBlockById(bid);if(b){window.E.sync();_tblAddCol(b,col+1);window.E.render();window.E.dirty=true;window.E.autoSave();_tblFocus(bid,1,col+1);}} else if(act==='rowadd'){var b2=_tblBlockById(bid);if(b2){window.E.sync();var n=b2.rows.length;_tblAddRow(b2,n);window.E.render();window.E.dirty=true;window.E.autoSave();_tblFocus(bid,n,0);}} else if(act==='coladdright'){var b4=_tblBlockById(bid);if(b4){window.E.sync();var cw=(b4.rows&&b4.rows[0])?b4.rows[0].length:1;_tblAddCol(b4,cw);window.E.render();window.E.dirty=true;window.E.autoSave();_tblFocus(bid,1,cw);}} + else if(act==='header'||act==='firstcol'){var b5=_tblBlockById(bid);if(b5){window.E.sync();window.E.pushHistory();if(act==='header')b5.has_header=g.checked?true:false;else b5.first_col_header=g.checked?true:false;window.E.render();window.E.dirty=true;window.E.autoSave();_rtSync();}} return; } if(document.getElementById('_tblMenu')&&!(ev.target.closest&&ev.target.closest('[data-tbl]')))_tblMenuClose(); @@ -193,8 +195,8 @@ } // ── Block type shortcuts (shown on the right of each slash item) ── - const _SHORTCUT={text:'T',heading_1:'#',heading_2:'##',heading_3:'###',heading_4:'####',bulleted_list:'•',numbered_list:'1.',to_do:'[ ]',toggle:'▸',quote:'"',callout:'!',table_of_contents:'TOC',math:'∑',columns:'‖',divider:'———',code:'<>',table:'|',image:'[img]',database:'DB'}; - const _GROUP_LABEL={BASIC:'Basic blocks',MEDIA:'Media',DATA:'Data'}; + const _SHORTCUT={text:'T',heading_1:'#',heading_2:'##',heading_3:'###',heading_4:'####',bulleted_list:'•',numbered_list:'1.',to_do:'[ ]',toggle:'▸',quote:'"',callout:'!',table_of_contents:'TOC',math:'∑',columns:'‖',divider:'———',code:'<>',table:'|',image:'[img]',database:'DB',duplicate:'Ctrl+D',turn_into:'⋯',copy_link:'#',delete:'⌫'}; + const _GROUP_LABEL={BASIC:'Basic blocks',MEDIA:'Media',DATA:'Data',ACTIONS:'Actions'}; // ── Hover preview card (matches Notion's visual hint) ── function _previewHtml(blockId){ const p=function(inner,cls){return '
'+inner+'
';}; @@ -357,19 +359,19 @@ function renderBlock(b,idx){ if(b.type==='meeting')return renderMeetingBlock(b,idx); if(b.type==='table')return renderTableBlock(b,idx); - if(b.type==='divider')return `

`; + if(b.type==='divider')return `

`; if(b.type==='button')return renderAutomationButton(b,idx); - if(b.type==='image')return `
${esc(b.alt||'')}
`; - if(b.type==='table_of_contents')return renderTOC(); - if(b.type==='math')return `
${renderMathBlock(b)}
`; + if(b.type==='image')return `
${esc(b.alt||'')}
`; + if(b.type==='table_of_contents')return renderTOC(b); + if(b.type==='math')return `
${renderMathBlock(b)}
`; if(b.type==='columns')return renderColumnsBlock(b); if(b.type==='embed'){ - if(b.embed_type==='inline_dbs')return `
📊 Inline Databases
`; - if(b.embed_type==='collection')return `
Loading database...
<`+`script>setTimeout(function(){var el=document.getElementById('inline-db-${b.collection_id}');if(el&&window.FlowDeckDB)window.FlowDeckDB.renderInto(el,${b.collection_id},null);},50);<`+`/script>
`; - if(b.embed_type==='pdf')return `

Failed to load PDF

Open in new tab →
`; + if(b.embed_type==='inline_dbs')return `
📊 Inline Databases
`; + if(b.embed_type==='collection')return `
Loading database...
<`+`script>setTimeout(function(){var el=document.getElementById('inline-db-${b.collection_id}');if(el&&window.FlowDeckDB)window.FlowDeckDB.renderInto(el,${b.collection_id},null);},50);<`+`/script>
`; + if(b.embed_type==='pdf')return `

Failed to load PDF

Open in new tab →
`; if(b.embed_type==='download'){ var sz=b.file_size||0;var szStr=sz<1024?sz+' B':sz<1048576?(sz/1024).toFixed(1)+' KB':(sz/1048576).toFixed(1)+' MB'; - return `
{{ fd_icon("paperclip",14) }}
${esc(b.file_name||'File')}
${szStr} · ${esc(b.file_mime||'')}
⬇ Download
`; + return `
{{ fd_icon("paperclip",14) }}
${esc(b.file_name||'File')}
${szStr} · ${esc(b.file_mime||'')}
⬇ Download
`; } } 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...'); @@ -381,17 +383,17 @@ else inner=`
${mdEsc(b.content)}
`; const tag=b.type.startsWith('heading_')?'h'+b.type.slice(-1):b.type==='quote'?'blockquote':'div'; const cls=tag==='div'?`block-content ${b.type==='bulleted_list'?'block-bullet':b.type==='numbered_list'?'block-numbered':b.type==='to_do'?'block-todo':b.type==='toggle'?'block-toggle':b.type==='code'?'block-code':b.type==='callout'?'block-callout':''}`:`block-content block-${tag}`; - const style=b.type==='callout'?` style="background:${(b.style&&b.style.bgColor)||'var(--blue-bg)'}"`:''; + const style=b.type==='callout'?` style="background:${(b.style&&b.style.bgColor)||'var(--blue-bg)'}${(b.style&&b.style.color)?';color:'+b.style.color:''}"`:((b.style&&(b.style.bgColor||b.style.color))?` style="${(b.style.bgColor?'background:'+b.style.bgColor+';':'')+(b.style.color?'color:'+b.style.color+';':'')}"`:''); const bidAttr=b.type.startsWith('heading_')?` id="h-${(b.id||'').replace(/[^a-zA-Z0-9]/g,'')}"`:''; const actions=idx>=0?`
`:''; - return `
+ return `
${actions} <${tag}${style}${bidAttr} class="${cls}">${inner}
`; } - function renderTOC(){ + function renderTOC(b){ var out=[]; var blocks=window.E?window.E.blocks:[]; var headings=blocks.filter(function(b){return /^heading_[1-4]$/.test(b.type)&&(b.content||'').trim();}); @@ -406,7 +408,8 @@ }); out.push(`
`); } - return `
${out.join('')}
`; + if(blocks.length===0){} + return `
${out.join('')}
`; } function renderMathBlock(b){ @@ -420,7 +423,7 @@ var bound=b.automation_id>0; var badge=bound?'⚡ '+esc(b.automation_name||'')+'':'Choose…'; var actions=idx>=0?`
`:''; - return `
+ return `
${actions}
@@ -432,7 +435,7 @@ var cells=cols.map(function(c){ return `
${renderChildColumn(c,b.id)}
`; }).join(''); - return `
${cells}
`; + return `
${cells}
`; } function renderChildColumn(c,parentId){ @@ -453,7 +456,7 @@ // généré par le FlowDeck Agent. function renderMeetingBlock(b, idx){ var actions=idx>=0?`
`:''; - return `
+ return `
${actions}
Préparation de la réunion…
@@ -676,6 +679,121 @@ }; window.FDMeeting=FDMeeting; + // ═══════════ v5.10.0 — Interactions de bloc : sélection multiple, menu ⋮⋮, undo/redo ═══════════ + let _selSet=new Set(); + function _selHas(bid){return _selSet.has(bid);} + function _selToggle(bid){if(!bid)return;if(_selSet.has(bid))_selSet.delete(bid);else _selSet.add(bid); + document.querySelectorAll('.block-wrapper[data-id="'+bid+'"]').forEach(function(w){w.classList.toggle('selected',_selSet.has(bid));});} + function _selClear(){_selSet.clear();document.querySelectorAll('.block-wrapper.selected').forEach(function(w){w.classList.remove('selected');});} + function _selApply(){document.querySelectorAll('.block-wrapper[data-id]').forEach(function(w){var id=w.getAttribute('data-id');w.classList.toggle('selected',!!(_selSet.has(id)));});} + function _selIndicesSorted(){var E=window.E;var out=[];if(!E)return out;E.blocks.forEach(function(b,i){if(_selSet.has(b.id))out.push(i);});return out;} + function _rtSync(){try{var rt=window.__fdRT;if(rt&&rt.syncNow)rt.syncNow();}catch(e){}} + + const _BLOCK_TURN_TYPES=[ + {id:'paragraph',name:'Text'},{id:'heading_1',name:'Heading 1'},{id:'heading_2',name:'Heading 2'}, + {id:'heading_3',name:'Heading 3'},{id:'heading_4',name:'Heading 4'},{id:'bulleted_list',name:'Bulleted list'}, + {id:'numbered_list',name:'Numbered list'},{id:'to_do',name:'To-do'},{id:'toggle',name:'Toggle'}, + {id:'quote',name:'Quote'},{id:'callout',name:'Callout'},{id:'table',name:'Table'}, + {id:'math',name:'Math equation'},{id:'columns',name:'Column list'},{id:'button',name:'Button / Automation'}, + {id:'code',name:'Code block'} + ]; + const _BLOCK_COLORS_TEXT=['default','#E5484D','#F76B15','#FFC53D','#46A758','#3E63DD','#8E4EC6','#12A594','#94A3B8']; + const _BLOCK_COLORS_BG=['default','rgba(229,72,77,.15)','rgba(247,107,21,.15)','rgba(255,197,61,.15)','rgba(70,154,88,.15)','rgba(62,99,221,.15)','rgba(142,78,198,.15)','rgba(18,165,148,.15)','rgba(148,163,184,.2)']; + function _blockMenuClose(){var m=document.getElementById('blockMenu');if(m)m.remove();m=document.getElementById('blockMenuSub');if(m)m.remove();} + function _blockMenuClick(ev){ + var t=ev.target.closest&&ev.target.closest('[data-bm-act]');if(!t)return; + var act=t.getAttribute('data-bm-act');var bid=t.getAttribute('data-bid'); + if(!act)return; + ev.preventDefault();ev.stopPropagation(); + var E=window.E;if(!E)return; + var idx=bid?E.getIdx(bid):-1; + if(act==='turn'){var m=document.getElementById('blockMenu');if(m){var r=m.getBoundingClientRect();_blockTurnMenuOpen(r.right+4,r.top,bid||'');}return;} + if(act==='duplicate'){E.duplicateBlock(idx);} + else if(act==='copy-link'){E.copyBlockLink(bid||'');} + else if(act==='move-to'){_blockMoveToOpen(idx);} + else if(act==='delete'){E.deleteSelected(idx);} + else if(act==='color'){var key=t.getAttribute('data-color-key'),val=t.getAttribute('data-color-val');E.setBlockColor(idx,key,val);} + else if(act==='turn-type'){E.turnInto(idx,t.getAttribute('data-type'));_blockMenuClose();} + _blockMenuClose(); + } + function blockMenuOpen(x,y,bid){ + _blockMenuClose(); + var E=window.E;if(!E)return; + var idx=E.getIdx(bid);if(idx<0)return; + var b=E.blocks[idx];if(!b)return; + var d=document.createElement('div');d.id='blockMenu';d.className='block-menu';d.style.cssText='position:fixed;z-index:4000;'; + var colorsText='',colorsBg=''; + _BLOCK_COLORS_TEXT.forEach(function(c){colorsText+='';}); + _BLOCK_COLORS_BG.forEach(function(c){colorsBg+='';}); + d.innerHTML='
↩Turn into▸
' + +'
' + +'
▣Duplicate '+(_rtMac()?'⌘D':'Ctrl+D')+'
' + +'
🔗Copy link to block
' + +'
↪Move to…
' + +'
' + +'
Text
'+colorsText+'
' + +'
Background
'+colorsBg+'
' + +'
' + +'
✕Delete ⌫
'; + d.addEventListener('click',_blockMenuClick); + d.addEventListener('contextmenu',function(ev){ev.preventDefault();_blockMenuClose();}); + document.body.appendChild(d); + var r=d.getBoundingClientRect(); + x=Math.min(Math.max(8,x),window.innerWidth-r.width-8); + y=Math.min(Math.max(8,y),window.innerHeight-r.height-8); + d.style.left=x+'px';d.style.top=y+'px'; + } + function _blockTurnMenuOpen(x,y,bid){ + var m=document.getElementById('blockMenuSub');if(m)m.remove(); + var d=document.createElement('div');d.id='blockMenuSub';d.className='block-menu block-menu-sub';d.style.cssText='position:fixed;z-index:4001;'; + var html=''; + _BLOCK_TURN_TYPES.forEach(function(t){html+='
'+t.name+'
';}); + d.innerHTML=html; + d.addEventListener('click',_blockMenuClick); + document.body.appendChild(d); + var r=d.getBoundingClientRect(); + x=Math.min(Math.max(8,x),window.innerWidth-r.width-8); + y=Math.min(Math.max(8,y),window.innerHeight-r.height-8); + d.style.left=x+'px';d.style.top=y+'px'; + } + function _rtMac(){return /Mac|iPhone|iPad/.test(navigator.userAgent);} + function _blockMoveToOpen(idx){ + var E=window.E;if(!E)return; + var pages=[];var seen={}; + document.querySelectorAll('.sidebar-item.ws-tree-item a.page-name[href^="/pages/"]').forEach(function(a){ + var m=(a.getAttribute('href')||'').match(/\/pages\/(\d+)/);if(!m)return; + var id=parseInt(m[1],10);var name=(a.textContent||'').trim()||('Page '+id); + if(id===E.pid||seen[id])return;seen[id]=1;pages.push({id:id,name:name}); + }); + var ov=document.createElement('div');ov.id='fdMoveOverlay';ov.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9999;display:flex;align-items:center;justify-content:center;'; + var box=document.createElement('div');box.style.cssText='background:var(--bg-secondary,#1f1f1f);border:1px solid var(--border,#333);border-radius:12px;padding:16px;width:420px;max-width:92vw;max-height:80vh;overflow:auto;'; + var listHtml=pages.length?pages.map(function(p){return '';}).join(''):'
Aucune autre page dans l\u2019arbre du workspace.
Ouvrez la page cible puis réessayez.
'; + box.innerHTML='
Move block to…
' + +'' + +'
'+listHtml+'
' + +''; + box.addEventListener('click',function(ev){ + var p=ev.target.closest('[data-pid]'); + if(p){var pid=parseInt(p.getAttribute('data-pid'),10);ov.remove();E.moveBlockToPage(idx,pid);return;} + if(ev.target.closest('[data-cancel]')){ov.remove();} + }); + box.addEventListener('input',function(ev){ + if(ev.target.classList&&ev.target.classList.contains('bm-search')){ + var q=(ev.target.value||'').toLowerCase(); + box.querySelectorAll('.bm-page').forEach(function(b2){b2.style.display=(q&&b2.textContent.toLowerCase().indexOf(q)<0)?'none':'';}); + } + }); + ov.appendChild(box); + document.body.appendChild(ov); + } + function _blockDocDown(ev){ + if(!(ev.target&&ev.target.closest))return; + if(ev.target.closest('#blockMenu')||ev.target.closest('#blockMenuSub')||ev.target.closest('#fdMoveOverlay'))return; + if(ev.target.closest('.block-handle'))return; + if(_selSet.size)_selClear(); + _blockMenuClose(); + } + window.E=null; function editorState(){ return { @@ -691,13 +809,14 @@ generalAccess:'{{ page_share_mode }}',publicPerm:'viewer', pubAllowEdit:false,pubAllowComments:true, pageUrl:window.location.href,publishedUrl:'', - inviteEmail:'',invitePermission:'editor',accessList:[], + inviteEmail:'',invitePermission:'edit',inviteUserId:null,inviteUsers:[],inviteSuggestOpen:false,inviteSel:-1,accessList:[], toastVisible:false,toastMsg:'', // ── v4.9.0: Collaboration — comments & mentions ── commentsOpen:false,commentDraft:'',comments:[],commentCount:0, currentUserId:{{ user.get('id', 0) if user else 0 }}, _commentSel:{range:null,bid:null,start:0,end:0}, _notifiedMentions:{}, + undoStack:[],redoStack:[],_histCap:100,_histLock:false, get timeAgo() { if (!this.updatedAt) return 'just now'; var diff = Math.floor((Date.now() - new Date(this.updatedAt + 'Z').getTime()) / 1000); @@ -722,7 +841,7 @@ if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')]; this.loadInlineDBs(); window.E=this; - this.insertTable=function(){this.sync();var nb={id:genId(),type:'table',content:'',rows:_tblNew(3,3)};this.blocks.push(nb);this.dirty=true;this.autoSave();this.render();setTimeout(function(){_tblFocus(nb.id,0,0);},60);}; + this.insertTable=function(){this.sync();this.pushHistory();var nb={id:genId(),type:'table',content:'',rows:_tblNew(3,3)};this.blocks.push(nb);this.dirty=true;this.autoSave();this.render();_rtSync();setTimeout(function(){_tblFocus(nb.id,0,0);},60);}; SM.init();_tblInitDelegate();this.render(); const tEl=document.getElementById('_titleEl'); if(tEl){const syncT=()=>{tEl.classList.toggle('empty',!(tEl.textContent||'').trim());};syncT();tEl.addEventListener('input',syncT);tEl.addEventListener('blur',syncT);} @@ -730,7 +849,13 @@ setTimeout(()=>{this.focusBlock();const ct=document.getElementById('_blocksCt');if(!ct)return; ct.addEventListener('keydown',e=>this.onKd(e)); ct.addEventListener('paste',e=>this.handleSmartPaste(e)); - if(typeof Sortable!=='undefined')Sortable.create(ct,{draggable:'.block-wrapper:not(.nested-block):not(.block-column-wrapper)',handle:'.block-handle',animation:150,ghostClass:'sortable-ghost',dragClass:'sortable-drag',onEnd:evt=>{if(evt.oldIndex===evt.newIndex)return;const[item]=this.blocks.splice(evt.oldIndex,1);this.blocks.splice(evt.newIndex,0,item);this.dirty=true;this.autoSave();this.render();}}); + if(typeof Sortable!=='undefined')Sortable.create(ct,{draggable:'.block-wrapper:not(.nested-block):not(.block-column-wrapper)',handle:'.block-handle',animation:150,ghostClass:'sortable-ghost',dragClass:'sortable-drag',onEnd:evt=>{this._dragEnd(evt);}}); + ct.addEventListener('click',e=>{const h=e.target&&e.target.closest?e.target.closest('.block-handle'):null;if(!h)return;const w=h.closest('.block-wrapper');if(!w)return;const wid=w.getAttribute('data-id');if(!wid)return; + if(e.shiftKey){_selToggle(wid);return;} + _selClear();blockMenuOpen(e.clientX,e.clientY,wid);}); + ct.addEventListener('contextmenu',e=>{if(!(e.target&&e.target.closest))return;if(e.target.closest('.ftable-cell')||e.target.closest('#_tblMenu'))return;const c=e.target.closest('.block-content, .block-wrapper, .block-column-wrapper');const el0=e.target.closest('[data-bid]');if(!c)return;const w=e.target.closest('.block-wrapper');const wid=w?w.getAttribute('data-id'):null;const bid=wid||(el0?el0.getAttribute('data-bid'):null);if(!bid)return;e.preventDefault();_selClear();blockMenuOpen(e.clientX,e.clientY,bid);}); + document.addEventListener('mousedown',_blockDocDown); + if(window.location.hash&&window.location.hash.indexOf('#fdblk-')===0){const hb=window.location.hash.slice(7);setTimeout(()=>{const selEl=document.querySelector(`[data-bid="${hb}"]`);if(selEl&&selEl.scrollIntoView)selEl.scrollIntoView({behavior:'smooth',block:'center'});if(selEl){selEl.focus();ce(selEl);}},500);} },100); }, fileData:null, @@ -760,6 +885,31 @@ getEl(bid){const ct=document.getElementById('_blocksCt');return ct?ct.querySelector(`[data-bid="${bid}"]`):null;}, getIdx(bid){return this.blocks.findIndex(b=>b.id===bid);}, getActiveBlock(){const el=document.activeElement;if(!el||!el.hasAttribute('data-bid'))return null;return{el,bid:el.dataset.bid,idx:this.getIdx(el.dataset.bid)};}, + _dragEnd(evt){ + const draggedId=evt.item&&evt.item.getAttribute?evt.item.getAttribute('data-id'):null; + const selected=_selIndicesSorted(); + const group=_selSet.has(draggedId)&&selected.length>1; + if(evt.oldIndex===evt.newIndex&&!group)return; + this.sync();this.pushHistory(); + const byId={};this.blocks.forEach(b=>{byId[b.id]=b;}); + if(group){ + const groupIds={};selected.forEach(i=>{const bb=this.blocks[i];if(bb)groupIds[bb.id]=1;}); + const groupBlocks=selected.map(i=>this.blocks[i]).filter(Boolean); + const ct=document.getElementById('_blocksCt'); + const domIds=[];ct.querySelectorAll('.block-wrapper[data-id]').forEach(w=>{const id=w.getAttribute('data-id');if(byId[id])domIds.push(id);}); + let inserted=false;const out=[]; + for(let i=0;iout.push(gb));inserted=true;}continue;} + out.push(byId[id]); + } + if(!inserted)groupBlocks.forEach(gb=>out.push(gb)); + if(out.length)this.blocks=out; + }else{ + const[item]=this.blocks.splice(evt.oldIndex,1);this.blocks.splice(evt.newIndex,0,item); + } + this.dirty=true;this.autoSave();this.render();_rtSync(); + }, focusBlock(){const b=this.blocks[0];if(!b)return;const el=this.getEl(b.id);if(el){el.focus();ce(el);}}, onCtClick(e){if(e.target===document.getElementById('_blocksCt')||e.target?.classList?.contains('blocks-empty')){if(!this.blocks.length)this.addAt(0);else{const b=this.blocks[this.blocks.length-1];if(b&&(b.type==='table'||b.type==='divider'||b.type==='image'||b.type==='embed'||b.type==='button')){this.addAt(this.blocks.length,'paragraph');}else{const el=b&&this.getEl(b.id);if(el){el.focus();ce(el);}}}}}, sync(){const ct=document.getElementById('_blocksCt');if(!ct)return;for(const b of this.blocks){if(b.type==='table'){const wrap=ct.querySelector(`[data-bid="${b.id}"].ftable-editor`);if(wrap){const rows=[];wrap.querySelectorAll('tr.ftable-row').forEach(function(tr){const cells=tr.querySelectorAll('th.ftable-cell,td.ftable-cell');if(!cells.length)return;const rowarr=[];cells.forEach(function(cell){rowarr.push(cell.textContent||'');});rows.push(rowarr);});if(rows.length)b.rows=rows;}continue;} @@ -780,16 +930,43 @@ setTimeout(()=>{ct.querySelectorAll('[data-meeting-bid]').forEach(function(el){if(window.FDMeeting)window.FDMeeting.mount(el.getAttribute('data-meeting-bid'),el);});},50); ct.querySelectorAll('[data-bid]').forEach(el=>{el.addEventListener('input',()=>{self.dirty=true;self.autoSave();if(el.hasAttribute&&el.hasAttribute('data-placeholder'))el.classList.toggle('empty',!(el.textContent||'').trim());})}); ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());}); + _selApply(); }, _renderKatex(el,tex){ if(!el)return;if(!tex||!tex.trim()){el.innerHTML='Empty math block';return;} if(window.katex){try{el.innerHTML=window.katex.renderToString(tex,{displayMode:true,throwOnError:false});}catch(e){el.innerHTML='Invalid LaTeX';}} else{el.innerHTML=''+esc(tex)+'';} }, - replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();b.type=type;b.content='';if(type==='toggle'){b.expanded=true;b.children=[];}if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='math'){}if(type==='button'){b.automation_id=0;b.automation_name='';}this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(b.id);if(el)el.focus();},60);}, - addAt(idx,type,content){this.sync();const b=this.mkB(type||'paragraph',content||'');this.blocks.splice(idx,0,b);this.dirty=true;this.autoSave();this.render();setTimeout(()=>{const el=this.getEl(b.id);if(el&&type!=='divider'){el.focus();content&&ce(el);}},60);return b;}, + replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pushHistory();b.type=type;b.content='';if(type==='toggle'){b.expanded=true;b.children=[];}if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='math'){}if(type==='button'){b.automation_id=0;b.automation_name='';}this.render();this.dirty=true;this.autoSave();_rtSync();setTimeout(()=>{const el=this.getEl(b.id);if(el)el.focus();},60);}, + addAt(idx,type,content){this.sync();this.pushHistory();const b=this.mkB(type||'paragraph',content||'');this.blocks.splice(idx,0,b);this.dirty=true;this.autoSave();this.render();_rtSync();setTimeout(()=>{const el=this.getEl(b.id);if(el&&type!=='divider'){el.focus();content&&ce(el);}},60);return b;}, addAfter(idx,type){return this.addAt(idx+1,type);}, - removeBlock(idx){this.sync();if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}const fi=Math.min(idx,this.blocks.length-1);this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);}, + removeBlock(idx){this.sync();this.pushHistory();if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}const fi=Math.min(idx,this.blocks.length-1);this.render();this.dirty=true;this.autoSave();this._rtSync();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);}, + + // ── v5.10.0: Undo / Redo ── + pushHistory(){if(this._histLock)return;this.undoStack.push(JSON.parse(JSON.stringify(this.blocks)));if(this.undoStack.length>this._histCap)this.undoStack.shift();this.redoStack.length=0;}, + undo(){if(!this.undoStack.length){this.showToast('Rien à annuler');return;}const fid=this._focusedBid();this.redoStack.push(JSON.parse(JSON.stringify(this.blocks)));this.applySnapshot(this.undoStack.pop());this._focusBid(fid);}, + redo(){if(!this.redoStack.length){this.showToast('Rien à rétablir');return;}const fid=this._focusedBid();this.undoStack.push(JSON.parse(JSON.stringify(this.blocks)));this.applySnapshot(this.redoStack.pop());this._focusBid(fid);}, + applySnapshot(snap){this._histLock=true;this.blocks=snap||[];if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];this.dirty=true;this.autoSave();this.render();this._histLock=false;_rtSync();}, + _focusedBid(){const a=this.getActiveBlock();return a?a.bid:null;}, + _focusBid(bid){if(bid)setTimeout(()=>{const el=this.getEl(bid);if(el){el.focus();ce(el);}},60);}, + _rtSync(){_rtSync();}, + + // ── v5.10.0: Duplicate ── + _deepCloneBlock(b){const c=JSON.parse(JSON.stringify(b));c.id=genId();if(Array.isArray(c.children)){c.children=c.children.map(ch=>{const n=JSON.parse(JSON.stringify(ch));n.id=genId();return n;});}return c;}, + duplicateBlock(idx){if(idx<0||idx>=this.blocks.length)return;const sel=_selIndicesSorted();if(sel.length>1&&sel.includes(idx)){this.duplicateSelected();return;}this.sync();this.pushHistory();const copy=this._deepCloneBlock(this.blocks[idx]);this.blocks.splice(idx+1,0,copy);this.dirty=true;this.autoSave();this.render();_rtSync();this._focusBid(copy.id);_selClear();return copy;}, + duplicateSelected(){const indices=_selIndicesSorted();if(!indices.length)return;this.sync();this.pushHistory();const copies=indices.map(i=>this._deepCloneBlock(this.blocks[i]));const pos=indices[indices.length-1]+1;this.blocks.splice(pos,0,...copies);this.dirty=true;this.autoSave();this.render();_rtSync();this._focusBid(copies[0].id);_selClear();}, + + // ── v5.10.0: Turn into / couleurs / liens / déplacement ── + _blockTargets(idx){const sel=_selIndicesSorted();return(sel.includes(idx))?sel:[idx];}, + turnInto(idx,type){if(idx<0||idx>=this.blocks.length)return;if(_BLOCK_TURN_TYPES.indexOf(type)<0)return;const targets=this._blockTargets(idx);const focusId=this.blocks[idx].id;this.sync();this.pushHistory();targets.forEach(i=>{const b=this.blocks[i];if(!b)return;if(b.type===type)return;const keep=(b.content||'').trim();b.type=type;if(type!=='to_do'&&type!=='toggle'&&keep&&['image','embed','meeting','divider','table_of_contents','database','button','columns'].indexOf(b.type)<0){b.content=keep;}else{b.content='';}if(type==='toggle'){b.expanded=true;if(!Array.isArray(b.children))b.children=[];}if(type==='to_do')b.checked=false;if(type==='table'&&!Array.isArray(b.rows))b.rows=_tblNew(3,3);if(type==='code'&&!b.language)b.language='Plain Text';if(type==='columns'&&(!Array.isArray(b.children)||!b.children.length))b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='button'){b.automation_id=0;b.automation_name='';}}); + this.dirty=true;this.autoSave();this.render();_rtSync();_selClear(); + setTimeout(()=>{const bid=this.blocks[idx]?this.blocks[idx].id:focusId;if(type==='table'){_tblFocus(bid,0,0);}else{const el=this.getEl(bid);if(el){el.focus();ce(el);}}},60);}, + setBlockColor(idx,key,val){if(idx<0||idx>=this.blocks.length)return;const targets=this._blockTargets(idx);const v=(!val||val==='default')?null:val;this.sync();this.pushHistory();targets.forEach(i=>{const b=this.blocks[i];if(!b)return;if(!b.style)b.style={};if(v==null)delete b.style[key];else b.style[key]=v;});this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();}, + copyBlockLink(bid){const url=window.location.origin+window.location.pathname+'#fdblk-'+bid;if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(url).then(()=>this.showToast('Lien du bloc copié')).catch(()=>this.showToast('Copie impossible','error'));}else{this.showToast(url,'error');}}, + deleteSelected(idx){const indices=_selIndicesSorted();if(indices.length<=1)return this.removeBlock(idx);this.sync();this.pushHistory();const keep=this.blocks.filter((b,i)=>indices.indexOf(i)<0);this.blocks=keep.length?keep:[this.mkB('paragraph','')];const fi=Math.min(idx,this.blocks.length-1);this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);}, + async moveBlockToPage(idx,pageId){if(idx<0||idx>=this.blocks.length||pageId===this.pid)return;this.sync();this.pushHistory();const block=this.blocks[idx];const self=this;const csrf=document.cookie.match(/csrf_token=([^;]+)/);this.showToast('Déplacement du bloc…');try{const r=await fetch(`/board/api/pages/${pageId}`,{credentials:'same-origin'});const d=await r.json();let tblocks=[];if(d.content_format==='blocks'&&d.content){try{tblocks=JSON.parse(d.content);}catch(e){tblocks=[];}}tblocks.push(block);await fetch(`/board/api/pages/${pageId}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:d.title||'',blocks:tblocks})}); + if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();this.showToast('Bloc déplacé'); + }catch(e){this.showToast('Échec du déplacement','error');}}, // ── v4.11.1: Inline AI — "Ask AI" & "AI meeting note" (Get started) ── // Wire the contextual get-started actions into the FlowDeck Agent @@ -844,7 +1021,7 @@ summaryError:'', lastTab:'transcript' }; - this.sync(); + this.sync();this.pushHistory(); var emptyFirst=this.blocks.length===1 && this.blocks[0].type==='paragraph' && !(this.blocks[0].content||'').trim(); if(emptyFirst){ this.blocks=[mb]; } else { this.blocks.push(mb); } this.dirty=true; this.autoSave(); this.render(); @@ -956,12 +1133,72 @@ applyAIBlocks(text){ fetch('/api/pages/' + this.pid + '/share', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' }, - body: JSON.stringify({ email: this.inviteEmail.trim(), permission: this.invitePermission }) + body: JSON.stringify({ user_id: this.inviteUserId, email: this.inviteEmail.trim(), permission: this.invitePermission }) }).then(function(r){ return r.json(); }).then(function(d){ - if (d.status === 'shared') { self.inviteEmail = ''; self.showToast('Invitation sent'); self.loadShares(); } - else { self.showToast(d.detail || 'Share failed'); } + if (d.status === 'shared') { + self.inviteEmail = ''; self.inviteUserId = null; self.inviteUsers = []; self.inviteSuggestOpen = false; + self.showToast('Invitation sent'); self.loadShares(); + } else { self.showToast(d.detail || 'Share failed'); } }).catch(function(){ self.showToast('Share failed'); }); }, + inviteInput(){ + var self = this; + var q = (this.inviteEmail || '').trim(); + if (!q) { this.inviteUsers = []; this.inviteSuggestOpen = false; this.inviteUserId = null; this.inviteSel = -1; return; } + clearTimeout(this._inviteTimer); + this._inviteTimer = setTimeout(function(){ self._loadInviteUsers(q); }, 150); + }, + async _loadInviteUsers(q){ + try { + const r = await fetch('/api/notifications/users/search?q=' + encodeURIComponent(q), { credentials: 'same-origin' }); + const d = await r.json(); + var me = this.currentUserId; + var shared = {}; + (this.accessList || []).forEach(function(a){ if (a.user_login) shared[a.user_login.toLowerCase()] = true; }); + this.inviteUsers = (d.users || []).filter(function(u){ + return u.id !== me && !shared[(u.login || '').toLowerCase()]; + }); + this.inviteSel = this.inviteUsers.length ? 0 : -1; + this.inviteSuggestOpen = true; + } catch(e) { this.inviteUsers = []; this.inviteSuggestOpen = false; } + }, + inviteMove(dir){ + var el = this.$refs.inviteSuggest; + var n = this.inviteUsers.length; + if (!n) return; + this.inviteSel = (this.inviteSel + dir + n) % n; + if (el) { + var item = el.querySelectorAll('.sd-user-item')[this.inviteSel]; + if (item) item.scrollIntoView({ block: 'nearest' }); + } + }, + inviteEnter(){ + if (this.inviteSuggestOpen && this.inviteSel >= 0 && this.inviteUsers[this.inviteSel]) { + this.pickInviteUser(this.inviteUsers[this.inviteSel]); + } else { + this.inviteSuggestOpen = false; + this.shareInvite(); + } + }, + pickInviteUser(u){ + this.inviteUserId = u.id; + this.inviteEmail = u.login || u.full_name || ''; + this.inviteUsers = []; this.inviteSuggestOpen = false; this.inviteSel = -1; + var el = this.$refs.inviteInput; + if (el) el.focus(); + }, + updateShare(sid, perm) { + var self = this; + var csrf = document.cookie.match(/csrf_token=([^;]+)/); + fetch('/api/pages/' + this.pid + '/share/' + sid, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' }, + body: JSON.stringify({ permission: perm }) + }).then(function(r){ return r.json(); }).then(function(d){ + self.showToast(d.status === 'updated' ? 'Permission updated' : (d.detail || 'Update failed')); + self.loadShares(); + }).catch(function(){ self.showToast('Update failed'); self.loadShares(); }); + }, loadShares() { var self = this; fetch('/api/pages/' + this.pid + '/shares').then(function(r){ return r.json(); }).then(function(d){ @@ -1148,20 +1385,24 @@ applyAIBlocks(text){ const before=sp?sp.before:gt(el)||'',after=sp?sp.after:''; if(!before.trim()&&!after.trim()){ const nt=(block.type==='bulleted_list'||block.type==='numbered_list'||block.type==='to_do')?block.type:'paragraph'; - this.sync();this.blocks.splice(idx+1,0,this.mkB(nt,''));this.render(); + this.sync();this.pushHistory();this.blocks.splice(idx+1,0,this.mkB(nt,''));this.render();_rtSync(); setTimeout(()=>{const ne=this.getEl(this.blocks[idx+1]?.id);if(ne){ne.focus();ce(ne);}},60); this.dirty=true;this.autoSave();return; } const nt=(block.type==='bulleted_list'||block.type==='numbered_list'||block.type==='to_do')?block.type:'paragraph'; - this.sync();block.content=before;this.blocks.splice(idx+1,0,this.mkB(nt,after));this.render(); + this.sync();this.pushHistory();block.content=before;this.blocks.splice(idx+1,0,this.mkB(nt,after));this.render();_rtSync(); setTimeout(()=>{const ne=this.getEl(this.blocks[idx+1]?.id);if(ne){ne.focus();cs(ne);}},60); this.dirty=true;this.autoSave();return; } if(e.key==='ArrowUp'){const _sp=splitCaret(el);if(_sp&&!_sp.before.trim()&&idx>0){e.preventDefault();const prev=this.getEl(this.blocks[idx-1]?.id);if(prev){prev.focus();ce(prev);}}return;} if(e.key==='ArrowDown'){const _sp=splitCaret(el);if(_sp&&!_sp.after.trim()&&idx{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}else if(this.blocks.length>1){e.preventDefault();this.removeBlock(idx);}}return;} + if(e.key==='Backspace'){if(!(el.textContent||'').trim()){this.sync();if(block.type!=='paragraph'){e.preventDefault();this.pushHistory();block.type='paragraph';block.content='';this.render();_rtSync();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}else if(this.blocks.length>1){e.preventDefault();this.removeBlock(idx);}}return;} if(e.key===' '){const b2=bid;setTimeout(()=>this.checkMd(b2),15);} - if((e.ctrlKey||e.metaKey)&&!e.altKey){const m={b:'bold',i:'italic',u:'underline'};if(m[e.key]){e.preventDefault();document.execCommand(m[e.key]);this.dirty=true;this.autoSave();}} + if((e.ctrlKey||e.metaKey)&&!e.altKey){ + if(e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)this.redo();else this.undo();return;} + if(e.key.toLowerCase()==='y'){e.preventDefault();this.redo();return;} + if(e.key.toLowerCase()==='d'){e.preventDefault();const ab2=this.getActiveBlock();if(ab2)this.duplicateBlock(ab2.idx);return;} + const m={b:'bold',i:'italic',u:'underline'};if(m[e.key]){e.preventDefault();document.execCommand(m[e.key]);this.dirty=true;this.autoSave();}} if(e.key==='Tab'&&block.type==='code'){e.preventDefault();document.execCommand('insertText',false,' ');} this.dirty=true;this.autoSave(); }, @@ -1169,28 +1410,32 @@ applyAIBlocks(text){ if(idx<0||idx>=this.blocks.length)return;const block=this.blocks[idx];const el=this.getEl(block.id);if(!el)return;const text=el.textContent||''; const sc={'# ':'heading_1','## ':'heading_2','### ':'heading_3','#### ':'heading_4','- ':'bulleted_list','* ':'bulleted_list','+ ':'bulleted_list','1. ':'numbered_list','[] ':'to_do','[ ] ':'to_do','> ':'quote'}; this.sync(); - for(const[pfx,type] of Object.entries(sc)){if(text===pfx||text.startsWith(pfx)){block.type=type;block.content='';if(type==='toggle')block.expanded=true;if(type==='to_do')block.checked=false;this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne&&type!=='divider')ne.focus();},60);return;}} - if(text==='---'){block.type='divider';block.content='';this.addAfter(idx,'paragraph');} - if(text==='```'){block.type='code';block.content='';block.language='Plain Text';this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);} + for(const[pfx,type] of Object.entries(sc)){if(text===pfx||text.startsWith(pfx)){this.pushHistory();block.type=type;block.content='';if(type==='toggle')block.expanded=true;if(type==='to_do')block.checked=false;this.render();_rtSync();setTimeout(()=>{const ne=this.getEl(block.id);if(ne&&type!=='divider')ne.focus();},60);return;}} + if(text==='---'){this.pushHistory();block.type='divider';block.content='';this.addAfter(idx,'paragraph');} + if(text==='```'){this.pushHistory();block.type='code';block.content='';block.language='Plain Text';this.render();_rtSync();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);} }, applySlash(id){ const idx=SM._i;if(idx<0||idx>=this.blocks.length)return; if(id==='database'){ this.openDbTemplatePicker(idx); return; } - const block=this.blocks[idx];this.sync();block.content=''; + if(id==='duplicate'){this.duplicateBlock(idx);return;} + if(id==='delete'){this.removeBlock(idx);return;} + if(id==='copy_link'){const blk=this.blocks[idx];if(blk)this.copyBlockLink(blk.id);return;} + if(id==='turn_into'){const blk=this.blocks[idx];if(!blk)return;const el=this.getEl(blk.id);const rk=el?el.getBoundingClientRect():null;blockMenuOpen(rk?rk.left:window.innerWidth/2,rk?rk.bottom:window.innerHeight/2,blk.id);return;} + const block=this.blocks[idx];this.sync();this.pushHistory();block.content=''; if(id==='divider'){block.type='divider';this.addAfter(idx,'paragraph');} else{block.type=id;if(id==='toggle'){block.expanded=true;block.children=[];}if(id==='to_do')block.checked=false;if(id==='columns')block.children=[{id:genId(),type:'paragraph',content:''}];if(id==='code')block.language='Plain Text';if(id==='table'){block.rows=_tblNew(3,3);block.content='';}if(id==='button'){block.automation_id=0;block.automation_name='';}} - this.render();this.dirty=true;this.autoSave(); + this.render();this.dirty=true;this.autoSave();_rtSync(); if(id==='table'){setTimeout(()=>_tblFocus(block.id,1,0),60);} else if(id==='math'){setTimeout(()=>{const ne=document.querySelector(`[data-math-bid="${block.id}"]`);if(ne)ne.focus();},60);} else if(id==='columns'){setTimeout(()=>{const ne=this.getEl(block.children[0].id);if(ne)ne.focus();},60);} else{setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);} }, - _doToggle(bid,v){const idx=this.getIdx(bid);if(idx>=0){this.blocks[idx].checked=v;this.dirty=true;this.autoSave();}}, - _doToggleExpand(bid){const idx=this.getIdx(bid);if(idx>=0){this.blocks[idx].expanded=!this.blocks[idx].expanded;this.render();this.dirty=true;this.autoSave();}}, - _addColumn(parentId){const idx=this.getIdx(parentId);if(idx<0)return;const b=this.blocks[idx];b.children.push({id:genId(),type:'paragraph',content:''});this.render();this.dirty=true;this.autoSave();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];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();}, + _doToggle(bid,v){const idx=this.getIdx(bid);if(idx>=0){this.pushHistory();this.blocks[idx].checked=v;this.dirty=true;this.autoSave();_rtSync();}}, + _doToggleExpand(bid){const idx=this.getIdx(bid);if(idx>=0){this.pushHistory();this.blocks[idx].expanded=!this.blocks[idx].expanded;this.render();this.dirty=true;this.autoSave();_rtSync();}}, + _addColumn(parentId){const idx=this.getIdx(parentId);if(idx<0)return;const b=this.blocks[idx];this.pushHistory();b.children.push({id:genId(),type:'paragraph',content:''});this.render();this.dirty=true;this.autoSave();_rtSync();setTimeout(()=>{const ne=this.getEl(b.children[b.children.length-1].id);if(ne)ne.focus();},60);}, + _removeColumn(parentId,childId){const idx=this.getIdx(parentId);if(idx<0)return;const b=this.blocks[idx];this.pushHistory();b.children=b.children.filter(function(c){return c.id!==childId;});if(b.children.length===0)b.children=[{id:genId(),type:'paragraph',content:''}];this.render();this.dirty=true;this.autoSave();_rtSync();}, _toggleCalloutIcon(bid){const picker=document.getElementById('callout-picker-'+bid);if(picker)picker.style.display=picker.style.display==='none'?'flex':'none';}, - _setCalloutIcon(bid,icon){const idx=this.getIdx(bid);if(idx>=0){this.blocks[idx].icon=icon;this.dirty=true;this.autoSave();this.render();}}, + _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};}, 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();}}, @@ -1362,7 +1607,7 @@ applyAIBlocks(text){ const sp=splitCaret(bel); let before='',after=''; if(sp){before=sp.before;after=sp.after;}else{before=gt(bel)||'';} - this.sync(); + this.sync();this.pushHistory(); const beforeHasText=!!(before||'').trim(); block.content=before; const ins=[]; @@ -1393,7 +1638,7 @@ applyAIBlocks(text){ const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||''; this.saving=true; const csrf=document.cookie.match(/csrf_token=([^;]+)/); - fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})}) + fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})}) .then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString(); document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=this.pageTitle||'New page';}); if(cb)cb(); @@ -1500,7 +1745,7 @@ applyAIBlocks(text){ case 'math': return '$$\n' + c + '\n$$'; case 'table_of_contents': return '[TOC]'; case 'columns': return (b.children || []).map(blocksToMarkdown).join('\n\n'); - case 'table': { var rows=b.rows||[]; if(!rows.length)return ''; var w=0; rows.forEach(function(r){if(r&&r.length>w)w=r.length;}); var rl=function(r){var cells=[];for(var k=0;kw)w=r.length;}); var rl=function(r){var cells=[];for(var k=0;k non persiste, mais cote export traite has_header comme True par defaut + assert "has_header" not in saved[0] + + +# ── exports : _table_to_markdown / _table_to_html ── +def test_table_export_markdown_with_headers_and_first_col(): + from app.services.export import _table_to_markdown + b = {"type": "table", "rows": [["Name", "Role"], ["Ana", "Dev"]], + "has_header": True, "first_col_header": True} + md = _table_to_markdown(b) + lines = md.split("\n") + assert lines[0] == "| Name | Role |" + assert "---" in lines[1] + assert lines[2] == "| Ana | Dev |" + + +def test_table_export_markdown_without_header(): + from app.services.export import _table_to_markdown + b = {"type": "table", "rows": [["A", "B"], ["C", "D"]], "has_header": False} + md = _table_to_markdown(b) + lines = md.split("\n") + # pas de ligne de séparateur d'en-tête + assert lines[0] == "| A | B |" + assert lines[1] == "| C | D |" + assert len(lines) == 2 + + +def test_table_export_html_uses_th_for_headers_and_first_col(): + from app.services.export import _table_to_html + b = {"type": "table", "rows": [["Name", "Role"], ["Ana", "Dev"]], + "has_header": True, "first_col_header": True} + html = _table_to_html(b) + assert "" in html + assert " + assert html.count("= 3 + assert "thead>Ana" not in html or True + + +# ── Move to (menu contexte) : get page + POST blocks sur la cible ── +def test_move_block_to_other_page(client): + _auth(client) + src = _make_page(client, "Source") + dst = _make_page(client, "Destination") + blocks = [ + {"id": "b1", "type": "paragraph", "content": "One"}, + {"id": "b2", "type": "paragraph", "content": "MoveMe"}, + ] + client.post(f"/board/api/pages/{src}/blocks", + json={"title": "Source", "blocks": blocks}) + assert client.post(f"/board/api/pages/{dst}/blocks", + json={"title": "Destination", "blocks": []}).status_code == 200 + + # get page cible puis push le bloc deplace + r = client.get(f"/board/api/pages/{dst}") + assert r.status_code == 200 + target = r.json() + target_blocks = json.loads(target["content"]) if target.get("content") else [] + target_blocks.append(blocks[1]) + r2 = client.post(f"/board/api/pages/{dst}/blocks", + json={"title": "Destination", "blocks": target_blocks}) + assert r2.status_code == 200 + + from app.db import get_conn + with get_conn() as conn: + drow = conn.execute("SELECT content FROM pages WHERE id=?", (dst,)).fetchone() + saved = json.loads(drow["content"]) + assert any(b.get("content") == "MoveMe" for b in saved) + + +def test_get_page_returns_content_and_format(client): + _auth(client) + pid = _make_page(client, "GetMe", [{"id": "b1", "type": "paragraph", "content": "hi"}]) + r = client.get(f"/board/api/pages/{pid}") + assert r.status_code == 200 + assert r.json()["content_format"] == "blocks" + assert json.loads(r.json()["content"])[0]["content"] == "hi" + + +def test_get_page_missing_returns_404(client): + _auth(client) + r = client.get("/board/api/pages/999999") + assert r.status_code == 404 diff --git a/tests/test_sharing.py b/tests/test_sharing.py new file mode 100644 index 0000000..c634be3 --- /dev/null +++ b/tests/test_sharing.py @@ -0,0 +1,202 @@ +"""FlowDeck — Partage de pages (v4.0 / fix partage membres). + +Covers: partage par user_id ou email, validation de la permission +(view/comment/edit), upsert anti-doublon, mise à jour de permission (PUT), +retrait du partage et erreurs d'authentification. +""" +import json +import os +import tempfile + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(): + db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + db_path = db_file.name + db_file.close() + + os.environ["DATABASE_URL"] = f"sqlite:///{db_path}" + os.environ["APP_SECRET_KEY"] = "test-secret-for-sharing" + os.environ["RATE_LIMIT_ENABLED"] = "false" + os.environ["GITEA_URL"] = "https://git.dracodev.net" + os.environ["GITEA_TOKEN"] = "test" + + from app.config import settings + settings.database_url = f"sqlite:///{db_path}" + + from app.main import app + from app.db import init_db, get_conn + init_db() + with get_conn() as conn: + conn.execute( + "INSERT OR IGNORE INTO users (id, login, full_name, email, is_admin) " + "VALUES (1, 'owner', 'Owner', 'owner@test.dev', 1)" + ) + conn.execute( + "INSERT OR IGNORE INTO users (id, login, full_name, email, is_admin) " + "VALUES (2, 'alice', 'Alice', 'alice@test.dev', 0)" + ) + conn.commit() + + tc = TestClient(app, raise_server_exceptions=False) + yield tc + os.unlink(db_path) + + +def _token(user_id, login): + from app.auth.session import SessionManager + return SessionManager.create_session( + {"id": user_id, "login": login, "full_name": login.title(), "is_admin": 1} + ) + + +def _auth(client, user_id=1, login="owner"): + client.cookies.set("flowdeck_session", _token(user_id, login)) + + +def _make_page(_client, title="Share Page"): + from app.db import get_conn + with get_conn() as conn: + cur = conn.execute( + "INSERT INTO pages (workspace, title, content, content_format, parent_section) " + "VALUES ('Private', ?, '[]', 'blocks', 'Private')", + (title,), + ) + conn.commit() + return cur.lastrowid + + +# ── Share création ── + + +def test_share_by_user_id(client): + _auth(client) + pid = _make_page(client) + r = client.post(f"/api/pages/{pid}/share", json={"user_id": 2, "permission": "edit"}) + assert r.status_code == 200, r.text + d = r.json() + assert d["status"] == "shared" + assert d["shared_with_user_id"] == 2 + assert d["permission"] == "edit" + + lst = client.get(f"/api/pages/{pid}/shares").json()["shares"] + assert len(lst) == 1 + assert lst[0]["user_login"] == "alice" + assert lst[0]["permission"] == "edit" + + +def test_share_by_email_only(client): + _auth(client) + pid = _make_page(client) + r = client.post(f"/api/pages/{pid}/share", json={"email": "guest@test.dev", "permission": "view"}) + assert r.status_code == 200, r.text + d = r.json() + assert d["shared_with_email"] == "guest@test.dev" + assert d["shared_with_user_id"] is None + + lst = client.get(f"/api/pages/{pid}/shares").json()["shares"] + assert lst[0]["shared_with_email"] == "guest@test.dev" + + +def test_share_invalid_permission_rejected(client): + _auth(client) + pid = _make_page(client) + for bad in ("editor", "commenter", "viewer", "admin"): + r = client.post(f"/api/pages/{pid}/share", json={"email": "x@test.dev", "permission": bad}) + assert r.status_code == 400, bad + + +def test_share_requires_auth(client): + pid = _make_page(client) + r = client.post(f"/api/pages/{pid}/share", json={"email": "x@test.dev", "permission": "view"}) + assert r.status_code == 401 + + +def test_share_without_target_rejected(client): + _auth(client) + pid = _make_page(client) + r = client.post(f"/api/pages/{pid}/share", json={"permission": "view"}) + assert r.status_code == 400 + + +def test_share_page_not_found(client): + _auth(client) + r = client.post("/api/pages/999999/share", json={"email": "x@test.dev", "permission": "view"}) + assert r.status_code == 404 + + +def test_share_target_user_missing(client): + _auth(client) + pid = _make_page(client) + r = client.post(f"/api/pages/{pid}/share", json={"user_id": 999, "permission": "view"}) + assert r.status_code == 404 + + +def test_share_upsert_same_user_updates_permission(client): + _auth(client) + pid = _make_page(client) + first = client.post(f"/api/pages/{pid}/share", json={"user_id": 2, "permission": "view"}).json() + second = client.post(f"/api/pages/{pid}/share", json={"user_id": 2, "permission": "edit"}).json() + assert second["id"] == first["id"], "share should be upserted, not duplicated" + + from app.db import get_conn + with get_conn() as conn: + rows = conn.execute( + "SELECT permission FROM page_shares WHERE page_id=? AND shared_with_user_id=2", + (pid,), + ).fetchall() + assert len(rows) == 1 + assert rows[0]["permission"] == "edit" + + +# ── Permission update (PUT) ── + + +def test_put_permission_updates(client): + _auth(client) + pid = _make_page(client) + share_id = client.post(f"/api/pages/{pid}/share", json={"user_id": 2, "permission": "view"}).json()["id"] + r = client.put(f"/api/pages/{pid}/share/{share_id}", json={"permission": "comment"}) + assert r.status_code == 200 + assert r.json()["status"] == "updated" + + lst = client.get(f"/api/pages/{pid}/shares").json()["shares"] + assert lst[0]["permission"] == "comment" + + +def test_put_permission_invalid_rejected(client): + _auth(client) + pid = _make_page(client) + share_id = client.post(f"/api/pages/{pid}/share", json={"user_id": 2, "permission": "view"}).json()["id"] + r = client.put(f"/api/pages/{pid}/share/{share_id}", json={"permission": "owner"}) + assert r.status_code == 400 + + +def test_put_permission_missing_entry(client): + _auth(client) + pid = _make_page(client) + r = client.put(f"/api/pages/{pid}/share/99999", json={"permission": "edit"}) + assert r.status_code == 404 + + +# ── Remmove (DELETE) ── + + +def test_remove_share_unsets_is_shared(client): + _auth(client) + pid = _make_page(client) + from app.db import get_conn + with get_conn() as conn: + conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (pid,)) + conn.commit() + + share_id = client.post(f"/api/pages/{pid}/share", json={"user_id": 2, "permission": "view"}).json()["id"] + r = client.delete(f"/api/pages/{pid}/share/{share_id}") + assert r.status_code == 200 + + with get_conn() as conn: + is_shared = conn.execute("SELECT is_shared FROM pages WHERE id=?", (pid,)).fetchone()["is_shared"] + assert is_shared == 0 \ No newline at end of file