Files
flowdeck/CHANGELOG.md
T
bruno 3d3b46b58b
FlowDeck CI / lint (push) Successful in 1m34s
FlowDeck CI / test (push) Failing after 17m31s
FlowDeck CI / docker (push) Skipped
fix: sélection multi-blocs fonctionnelle (Ctrl+C/X, drag souris, clic droit) + identité visuelle (v7.69.0)
Trois bugs d'éditeur, chacun avec sa cause racine prouvée par trace
d'événements — le mécanisme Clipboard livré en v7.68 était inutilisable.

1) Ctrl+C / Ctrl+X multi-blocs ne faisaient RIEN.
   Cause : le navigateur ne sait pas sérialiser une sélection qui traverse
   deux `contenteditable` (chaque bloc en a un). `getSelection().toString()`
   renvoie `""` et Chromium n'émet AUCUN événement `copy` — les handlers
   `ct.addEventListener('copy')` n'étaient donc jamais atteints.
   → le raccourci est intercepté au keydown (capture sur document, garde
   `closest('#_blocksCt')` pour ne pas voler Ctrl+C ailleurs) et le markdown
   est écrit dans le presse-papier par nos soins. Sélection mono-bloc =
   comportement natif inchangé.

2) Glisser la souris d'un bloc à l'autre ne sélectionnait qu'un bloc.
   Cause : `mouseup` (on reconstruit la plage) → `click` → `focusin` → le
   navigateur efface la sélection. La plage était reconstruite puis détruite
   dans la foulée.
   → la plage reconstruite est mise en attente (`_mdSelRange`) et restaurée
   au tour de boucle suivant si le focus l'a effectivement effacée. Chaque
   nouveau geste (mousedown) remet l'attente à zéro, donc un clic pour
   placer le curseur reste normal.

3) Le clic droit effaçait le surlignage.
   Cause : `.bm-search` prenait le focus 10 ms après l'ouverture du menu.
   → le menu ne vole plus le focus quand un texte est sélectionné. Les
   actions Clipboard utilisaient déjà la sélection figée à l'ouverture
   (`d._clipSt`) ; le `preventDefault` du clic droit est remonté sur
   `mousedown` (sur `contextmenu` il arrive trop tard).

Identité visuelle (bannière + logo du projet ajoutés à la racine) :
- assets optimisés servis : `static/img/banner.webp` 22,5 Ko (l'original
  fait 1,6 Mo) + JPEG de secours, `logo.webp` 5,3 Ko + `logo-512.jpg` ;
  originaux conservés dans `assets/` pour régénération.
- icônes PWA régénérées à partir du vrai logo (72 → 512 px, marge
  maskable sur 192/512) + apple-touch-icon.
- landing : logo dans la barre de navigation, bannière en <picture>
  (WebP + JPEG) avec dimensions déclarées (pas de décalage de mise en page).
- `og:image`, `twitter:card` et `meta description` ajoutés sur la landing
  ET dans base.html — la landing a son propre <head>.

Tests : `tests/test_v769_selection_persistence.py` (19 statiques) +
`e2e/v769_selection_persistence.spec.js` (5, vrais gestes : drag souris,
Ctrl+C/Ctrl+X au clavier, clic droit). `e2e/v768_columns_clipboard.spec.js`
corrigé : les gestes simulés (`execCommand`, plage fabriquée en evaluate)
sont remplacés par de vrais gestes, et le token statique obsolète
`si>=ei)return null;` aligné sur la garde `isCollapsed` réelle.

pytest 1413 passed / 0 failed · ruff OK · OpenAPI 526 chemins / 7.69.0
2026-10-08 17:27:14 -04:00

5357 lines
314 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Changelog - FlowDeck
## v7.69.0 (2026-10-08) — La sélection multi-blocs fonctionne enfin (+ identité visuelle)
### Fixed
- **Ctrl+C / Ctrl+X multi-blocs ne faisaient RIEN** — cause racine : le navigateur
ne sait pas sérialiser une sélection qui traverse deux `contenteditable` (chaque
bloc en a un). Résultat : `getSelection().toString()` renvoie `""` et Chromium
**n'émet aucun événement `copy`** — nos handlers `ct.addEventListener('copy')`
n'étaient donc jamais atteints, et le raccourci mourait en silence.
→ le raccourci est maintenant intercepté au `keydown` (capture sur `document`,
garde `closest('#_blocksCt')` pour ne pas voler Ctrl+C ailleurs) : on écrit le
markdown dans le presse-papier nous-mêmes. La sélection mono-bloc reste native.
- `Ctrl+X` supprime les blocs traversés en conservant la tête du premier et la
queue du dernier.
- **Glisser la souris d'un bloc à l'autre sélectionnait un seul bloc** — cause
racine : la séquence complète est `mouseup` (on reconstruit la plage) → `click`
→ `focusin` → **le navigateur efface la sélection**. La plage était donc
reconstruite puis détruite dans la foulée. → la plage reconstruite est mise en
attente (`_mdSelRange`) et restaurée au tour de boucle suivant, si le focus
l'a effectivement effacée. Un nouveau geste (mousedown) remet l'attente à zéro,
donc un clic pour placer le curseur reste normal.
- **Le clic droit effaçait le surlignage** — la recherche du menu prenait le
focus 10 ms après son ouverture, ce qui détruisait la sélection. → on ne vole
plus le focus quand un texte est sélectionné. (Les actions Clipboard utilisaient
déjà la sélection figée à l'ouverture du menu : `d._clipSt`.)
- Le `preventDefault` du clic droit est bien sur `mousedown` (sur `contextmenu`
il arrive trop tard — le curseur a déjà déplacé la sélection).
### Added
- **Identité visuelle** : la bannière et le logo du projet sont intégrés.
- assets optimisés : `static/img/banner.webp` **22,5 Ko** (l'original fait
1,6 Mo) + `banner.jpg` de secours, `logo.webp` **5,3 Ko** + `logo-512.jpg` ;
originaux conservés dans `assets/` pour régénération.
- icônes PWA régénérées à partir du vrai logo (72 → 512 px, marge maskable
sur 192/512) + `apple-touch-icon`.
- landing : logo dans la barre de navigation, bannière en `<picture>`
(WebP + JPEG) avec dimensions déclarées pour éviter tout décalage de mise en
page ; `og:image`, `twitter:card` et `meta description` ajoutés au `<head>`.
### Tests
- `tests/test_v769_selection_persistence.py` (19 tests statiques) +
`e2e/v769_selection_persistence.spec.js` (5 tests, **vrais gestes** : glisser
de souris, `Ctrl+C`/`Ctrl+X` au clavier, clic droit).
- `e2e/v768_columns_clipboard.spec.js` : les gestes simulés (`execCommand`,
plage fabriquée en `evaluate`) sont remplacés par de vrais gestes.
## v7.68.0 (2026-10-08) — Colonnes rendues, tables IA, copier/couper/coller multi-blocs
### Fixed
- **Colonnes / équation / TOC n'étaient JAMAIS rendus** : `renderBlock()`
n'appelait pas `renderColumnsBlock()` / `renderMathBlock()` / `renderTOC()`
(3 renderers morts depuis l'extraction v7.22) → un bloc colonnes — créé par
le slash « Column list », par **Turn into → 2/3/4/5 colonnes** — tombait dans
le rendu générique : un paragraphe vide. Dispatch ajouté.
- la **× de suppression de colonne** était en `opacity:0` **sans règle de
survol** (invisible mais cliquable) → visible au survol du bloc colonne.
- **Tables rendues par l'IA** : `_tblIsData`/`_tblIsSep` exigeaient un `|` de
tête → le GFM sans pipe (`A | B` / `--- | ---`, ce que produit souvent le
modèle) devenait des paragraphes. Le pipe de tête est **optionnel** en GFM ;
garde conservée : une ligne de séparation doit faire ≥2 cellules (un « texte »
suivi d'un « --- » reste un séparateur de section). `md2b` et `paste2b`
partagent les helpers → alignés d'office.
- **Coller un SEUL bloc structuré** (table, code, titre) retombait en texte
brut (`if(parsed.length<=1) return fallback()`) → seul un bloc paragraphe
(une ligne) garde le comportement inline.
- **« Copy link to block »** du menu de bloc : `data-bm-act="link"` alors que
le handler attend `'copy-link'` → l'action ne faisait rien.
### Added
- **Copier / couper une sélection de texte MULTI-BLOCS** :
- `_clipState()` (sélection vivante traversant ≥2 blocs) + `_clipMarkdown()`
(début partiel → blocs entiers sérialisés par `blocksToMarkdown` → fin
partielle) ; `clipCopy()` / `clipCut()` (la coupure garde la tête du 1ᵉʳ
bloc et la queue du dernier, le milieu part) ;
- listeners **`copy` / `cut`** sur `#_blocksCt` : Ctrl+C / Ctrl+X passent par
les événements natifs du navigateur ; dans un seul bloc, on laisse le
navigateur faire (copie native) ;
- groupe **Clipboard** (`Cut Ctrl+X` / `Copy Ctrl+C` / `Paste Ctrl+V`) en
tête du menu du clic droit, avec la sélection **figée à l'ouverture** du
menu (la recherche prend le focus et détruit la sélection) ;
- clic droit **sur un bloc sélectionné** → la sélection multi-blocs (handle +
Maj-clic) est conservée et le Clipboard agit dessus (`copySelectedBlocks`) ;
- `pasteBlocks()` colle après le dernier bloc quand aucun bloc n'est focalisé.
### Tests
- `tests/test_v768_columns_clipboard.py` (4).
- **e2e** `e2e/v768_columns_clipboard.spec.js` (4) : slash « Column list » +
« Add column » + **Turn into → 2 colonnes** (bloc `.block-column` réellement
rendu), table IA **sans pipe de tête** (vrai `.ftable-editor` + garde
« texte »/« --- »), copier→`readText` → couper (blocs réduits à la tête +
queue) → coller (blocs revenus), clic droit → groupe Clipboard → Copy.
- le raccourci clavier n'est pas déclenché en headless Chromium (pas de
presse-papier OS) : le test passe par `document.execCommand('copy'/'cut')`,
qui émet les **mêmes** événements que Ctrl+C/X.
- `pytest tests/ -q -n auto` → **1394 passed, 0 failed**, `ruff check app tests`
→ OK.
## v7.67.0 (2026-10-08) — Réactions dans le tiroir Comments (+ suppression) et refonte du menu de sélection
### Added
- **Les réactions vivent dans le tiroir « Comments »** : section **Reactions**
au-dessus des fils — émoji, compte (si >1) et **texte ancré** lu dans le DOM
(`reactionList()` / `_anchorText()`, offsets source hors pastilles). L'état
vide « No comments yet » ne s'affiche plus s'il reste des réactions.
- **Suppression d'une réaction — deux entrées, un seul chemin** :
- **✕** sur la ligne de la section Reactions (visible uniquement sur **mes**
réactions) ;
- au **survol de la pastille**, l'émoji que j'ai posé devient une **croix**
(`::after`, aucun nœud ajouté → offsets source inchangés), un clic retire.
- les deux appellent `removeReaction()` → `POST /reactions` (toggle) →
`loadReactions()` + repeindre les ancres.
### Changed
- **Refonte du menu de sélection façon capture** :
- ligne 2 `[A] B I U T Tx` (le barré est conservé — absent de la maquette,
décision volontaire pour ne pas perdre la fonction) ;
- ligne 3 `🔗 S </> √x …` (la réaction en est sortie) ;
- **ligne 4 = une rangée scannée** : `💬 Comment` (libellé) à gauche,
`🙂+` réaction à droite (`.ft-row-split`) ;
- entête **Skills** avec `≡` à droite (`.ft-skills-head::after`), liste
**scrollable** (`.ft-skills`, `max-height` + `overflow-y`) ;
- **✨ Ask AI retiré** de la toolbar : doublon exact de « Edit with AI » du
pied (même `fmtAskAI()`).
### Fixed
- **Flake de gate corrigé (racine)** : `test_v55.py::TestOGParser::
test_fetch_og_metadata_success` dépendait d'un **vrai** `socket.getaddrinfo
("example.com")` via le garde SSRF `_is_public_host` ; sous `pytest -n auto`
un `socket.timeout` (non couvert par `except socket.gaierror`) remontait
dans le `except Exception` de `fetch_og_metadata` → repli silencieux
(`title='example.com'`) → AssertionError, 3 fois sur 5 runs.
- `_is_public_host` catch désormais **`OSError`** : le garde renvoie un
booléen, il ne lève jamais (les autres appelants gagnent aussi) ;
- fixture `stub_dns` sur `TestOGParser` (même motif que
`test_agent_web_tools.stub_dns`) : zéro DNS dans les tests OG.
### Tests
- `tests/test_v767_reactions_drawer.py` (3) ; `tests/test_v762_selection_toolbar.py`
(ligne 4 + styles) ; `tests/test_v764_comments_reactions.py` (position de
`fmtReact`).
- **e2e** : `v764_comments_reactions` étendu — section Reactions dans le tiroir
(texte ancré), **suppression A** (✕ du tiroir → pastille disparue),
**suppression B** (croix `::after` visible seulement au survol, puis clic) ;
`v762_selection_toolbar` — `Comment` + `ft-row-split .ft-react`,
`ft-skills-head` = « Skills », plus de bouton ✨.
- `pytest tests/ -q -n auto` → **1390 passed, 0 failed** (flake corrigé),
`ruff check app tests` → OK.
## v7.66.0 (2026-10-08) — Escape du menu slash retire le « / » + bouton « commenter » à droite du bloc
### Added
- **Bouton « commenter ce bloc » à droite du bloc** (`.block-comment-btn`,
gouttière 64px, visible au survol/focus comme le handle) : icône **bulle de
conversation avec un +**, `onclick="E.commentBlock(idx)"` → tiroir de
commentaires ancré sur le bloc (ou sur la sélection vivante s'il y en a une).
Icône = tracé `message-square` (Feather) + `+` centré.
Masqué sur mobile (2 breakpoints, comme le handle) et sur page verrouillée.
- remplace l'ancien `.block-actions` de `renderBlock` : rendu en `opacity:0`
**sans règle de survol** (jamais de classe `.visible`) → un `+` invisible
qui capait les clics dans la gouttière droite.
### Fixed
- **Escape ferme le menu slash ET retire le « / »** : nouvelle `SM.escClose()`
appelée par les **deux** chemins de fermeture (focus dans le bloc → `onKd`,
focus dans l'entrée du menu → listener `document`). Ne touche au caractère
que si c'est le `/` d'ouverture, remet le caret et sauvegarde (`sync` +
`autoSave`).
- **`fmtComment()` restaure la sélection de la toolbar** (`_fmtRestore()`)
avant d'ancrer : `commentBlock()` ne se replie plus sur `this._sel` (plage
potentiellement périmée reprise par le bouton de droite / le menu de bloc).
### Tests
- **e2e** `e2e/v766_slash_esc_comment_btn.spec.js` : « / » → menu visible →
Escape → menu fermé et bloc vide ; bouton présent, `opacity` 0 → 1 au
survol, **à droite** du wrapper (boîte mesurée), clic → tiroir → ancre = le
bloc entier.
- `tests/test_v766_slash_esc_comment_btn.py` (3) ; `pytest tests/ -q -n auto`
→ **1386 passed**, `ruff check app tests` → OK.
## v7.65.0 (2026-10-08) — Comment ancré sur la SÉLECTION de texte (pas le bloc) + retrait du bouton bleu « 💬 Comment »
### Changed
- **`commentBlock()` ancre sur la sélection de texte** : la plage vivante dans le
bloc est utilisée d'abord (plage de la toolbar de sélection en repli, le clic
sur le bouton ayant pu la faire disparaître du DOM), sinon tout le bloc.
Les offsets passent par `_srcOffset()` → `anchor_start/anchor_end` = offsets
dans le texte source, donc l'ancre jaune ne couvre que le texte sélectionné
(`_paintAnchors` / réactions inchangés).
- **Retiré le bouton bleu flottant « 💬 Comment »** (`#_commentSelBtn`) qui
apparaissait au-dessus de la sélection : le déclencheur est le bouton 💬 de
la toolbar de sélection, le menu de bloc (💬 Comment, Ctrl+⇧+M) et le raccourci.
`showCommentBtn()`/`hideCommentBtn()` et le listener `mousedown` associés sont
supprimés.
- Fermer le tiroir (`toggleComments`) remet `_commentSel` à zéro : un brouillon
abandonné ne peut plus attacher le commentaire suivant à une ancienne plage.
### Tests
- **e2e** `e2e/v764_comments_reactions.spec.js` : sélection d'une **sous-chaîne**
(`reaction a un`) → toolbar 💬 → tiroir → envoi ; assert `#_commentSelBtn`
absent et `.fd-anchor[data-cid]` = **uniquement** la plage sélectionnée
(avant **et** après reload).
- `pytest tests/ -q -n auto` → **1383 passed**, `ruff check app tests` → OK.
## v7.64.1 (2026-10-08) — Fix : Delete sur un bloc vide supprime le bloc
### Fixed
- **La touche Delete ne supprimait pas le bloc vide** : seul `Backspace` avait la
garde « bloc sans texte → supprimer le bloc ». La branche couvre désormais les
**deux touches** (`onKd`, une seule garde partagée) — Delete vide le bloc puis
le supprime au coup suivant, comme Backspace ; le dernier bloc de la page n'est
jamais supprimé (l'éditeur garde un paragraphe).
- Le vide est jugé avec **`gtTok(el)`** (le texte lu à la sauvegarde) et non
`textContent` : une pastille de réaction seule dans le bloc ne l'empêche plus
d'être supprimé.
### Tests
- **e2e** `e2e/v7641_delete_key.spec.js` : texte → Delete (bloc vidé, pas
supprimé) → Delete (bloc supprimé, le précédent reste) → dernier bloc vide
jamais supprimé.
## v7.64.0 (2026-10-08) — Commentaires façon Notion : ancres jaunes, tiroir refondu, réactions sur texte
### Added
- **Réactions emoji sur un texte sélectionné** :
- bouton **🙂+** dans la 3ᵉ ligne de la toolbar de sélection, infobulle
« React to selected text » → ouvre le **sélecteur d'émoji existant** en mode
réaction : en-tête = texte sélectionné (+ ✋), onglets masqués (emoji only),
ligne **Recent** au-dessus de la grille **People**, Filter… et barre de
catégories inchangées (dont `+` émoji custom).
- **stockage** : migration 37 (`text_reactions`, une ligne par plage × émoji ×
utilisateur, `UNIQUE` qui rend le POST idempotent) + endpoints
`GET/POST /api/pages/{id}/reactions` (GET groupé par plage : `emoji/count/mine`,
POST = toggle avec validation de plage non vide).
- **affichage** : la plage est peinte en jaune (`.fd-anchor-react`) avec une
**pastille** juste après (`😀` + compte si >1) ; clic sur la pastille →
réouvre le sélecteur pour cette plage (ajout/suppression).
- les pastilles ne sont **pas** du contenu : `gtTok`/`gtMd` les ignorent et les
offsets `_srcOffset/_srcNodeLen` les excluent → `sync()` conserve le texte
d'origine (vérifié dans le e2e) et les ancres ne décalent jamais.
- **Ancres de commentaires visibles** — `_paintAnchors()` (appelé après chaque
`render()`, après `loadComments` et `loadReactions`) entoure la plage commentée
d'un **jaune** (`.fd-anchor`, gris si résolu) ; **clic sur l'ancre → le tiroir
s'ouvre et centre le fil** (flash de surlignage).
- **Tiroir de commentaires refondu** (image 1) : fil en carte arrondie avec
**avatar coloré + nom + horodatage + ✓ (resolve/reopen) + ⋯** (menu Copy link /
Delete), étiquette « 💬 on selected text », et champ de saisie **« Add a
comment... »** avec **@** (insère la mention) et **bouton circulaire ↑** (envoi,
`Entrée` aussi) — remplace le bouton « Comment ».
- **Liens de commentaire** : `Copy link` copie `…#cmt-<id>` ; au chargement d'une
page avec ce hash, le fil correspondant s'ouvre et se centre.
- **Chargement initial** : `loadComments()` + `loadReactions()` à l'ouverture de la
page → les ancres/pastilles sont peintes dès le premier rendu (et `commentCount`
du tiroir est enfin renseigné).
### Fixed
- **`resolveComment` ne pouvait pas rouvrir** : il envoyait toujours
`resolved:true` → accepte désormais l'objet du fil et bascule
(`resolveComment(c)`), le bouton ✓ du fil est donc réellement un toggle.
- **`showCommentBtn` ignorait les sélections à ancre « élément »** (double-clic,
sélection programmatique) → `closest('[data-bid]')` partait du wrapper et le
bouton 💬 n'apparaissait pas → gère les 2 types de nœud (même fix que `showFmt`
en v7.62).
- **`gtTok` chemin rapide (`innerText`) incluait les pastilles de réaction** →
sélecteur enrichi (`.fd-react-chip`) pour basculer sur le marcheur qui les
exclut : le texte sauvé reste identique.
### Tests
- `tests/test_v764_comments_reactions.py` (8 tests) : table + migration 37,
endpoints enregistrés, **toggle API réel** (added → removed → added, grouping,
400 sur plage vide), markup toolbar/tiroir, peinture des ancres, mode réaction
du sélecteur.
- **e2e Playwright** `e2e/v764_comments_reactions.spec.js` : bouton 🙂+ avec la
bonne infobulle → sélecteur (en-tête = texte, Filter…, onglets masqués) →
pastille + plage jaune **avec contenu inchangé après `sync()`** → 💬 → tiroir
(avatar, nom, ✓, ⋯, Copy link) → ancre jaune cliquable → ✓ résout → reload :
ancres et pastilles rechargées depuis la base ; **3/3 specs e2e verts**
(`--workers=1`).
- **Suite complète** : `pytest tests/ -q -n auto` → **1383 passed / 0 failed**,
`ruff check app tests` → All checks passed.
## v7.63.0 (2026-10-08) — Menus mobiles façon Notion (barre au « / » + Insert block plein écran)
### Added
- **Barre horizontale mobile** — sur un viewport ≤ 768 px, taper `/` sur une ligne
vide affiche une **barre flottante défilante sous le curseur** (au lieu de la popup
desktop), 15 boutons dans l'ordre Notion : `+▾`, `Turn into▾`, `@`, 💬, 🖼, 🗑,
`←`, `→`, `↑`, `↓`, `↩`, `↪`, `Color▾`, `More▾`, `⌨` :
- `+▾` → **feuille plein écran « Insert block »** : titre centré, bouton bleu
« Cancel », liste verticale par groupes (Basic blocks / AI writing / Media /
Data / Synced / Actions) avec icône, nom et **raccourci markdown** à droite
(`#`, `##`, `-`, `1.`, `[]`, `>`, `"`, `---`…) — items partagés avec la popup
desktop (`SM._slashItemHtml`), choix → `applySlash` → tout se referme.
- `Turn into▾` / `Color▾` → feuilles réutilisant **les mêmes générateurs d'items**
que les sous-menus du menu de bloc (`_blockTurnItems` / `_blockColorItems`
extraits de `_blockTurnMenuOpen` / `_blockColorMenuOpen`).
- `More▾` → menu contextuel de bloc existant (recherche + 10 actions + pied).
- `@` insère `@` et ouvre le sélecteur de mention ; 💬 commentaire ; 🖼 insère un
bloc image ; 🗑 supprime ; `⌨` masque le clavier ; `↩ ↪` annuler/rétablir.
- **Déplacement de bloc `moveBlock(bid, dir)`** — `↑↓` réordonne (y compris dans un
parent toggle/columns), `→` indente dans le frère précédent (toggle/columns),
`←` ressort à la racine ; garde-fous avec toast (déjà en haut/bas, déjà à la
racine, pas de cible).
- **Fermeture automatique** de la barre quand le `/` n'est plus au début du bloc ;
taps dans la barre/la feuille n'essaient plus de fermer le menu (exclusion
`MTB.contains` sur le handler mousedown hors-zone).
- **Raccourcis slash alignés sur les préfixes markdown** (description Notion) :
`-` (puces), `[]` (à faire), `>` (toggle), `---` (séparateur) — partagés avec la
popup desktop ; groupe `SYNCED` libellé « Synced ».
### Tests
- `tests/test_v763_mobile_menus.py` : ordre des 15 boutons, câblage de chaque
action, structure de la feuille (titre/Cancel/groupes), branchement mobile,
styles — et **`moveBlock` exécuté pour de vrai sous node** (ordre, indent,
outdent, garde-fous).
- **e2e Playwright mobile** `e2e/v763_mobile_menus.spec.js` (viewport 390×844,
`hasTouch`) : barre visible au `/` avec les 15 boutons dans l'ordre + `overflow-x:auto`
et popup desktop masquée, « Insert block » (groupes, raccourci `#` de Heading 1)
→ le bloc devient `heading_1`, « Turn into » (coche du type courant), « Color »
(3 groupes), « More » (menu de bloc + pied), `↑` qui réordonne réellement les blocs.
- `tests/test_v761_block_menu.py` adapté aux extractions `_blockTurnItems` /
`_blockColorItems`.
## v7.62.0 (2026-10-08) — Menu contextuel au highlight de texte (toolbar façon Notion)
### Added
- **Toolbar de sélection** — dès qu'un texte est surligné dans l'éditeur, un menu
flottant apparaît **sous la sélection** (au-dessus si manque de place), centré et
clampé au viewport, en 4 lignes comme dans Notion :
1. **Style de texte** — `T` + libellé **dynamique** du type de bloc (Normal Text,
Heading 1… ; le type courant) + `›` → ouvre le sous-menu **Turn into** existant
(coche sur le type actuel, fermeture au survol sortant).
2. **A** (sous-menu Color : texte/fond + dernière couleur), **B** gras, *I* italique,
**U** souligné, **T** barré, **Tx** code inline.
3. 🔗 lien, **S** surlignage (`<mark>`), `</>` bloc code, **√x** équation inline
(prompt LaTeX → KaTeX), **…** → menu contextuel de bloc (recherche + 10 actions +
pied « Last edited by … / N words, M characters »).
4. 💬 Comment (`Ctrl+⇧+M`), ✨ Ask AI (`Ctrl+J`).
puis la section grise **Skills** (Improve writing, Proofread, Explain, Reformat →
`runSkill` sur le bloc) et le pied **Edit with AI** `Alt+⇧+E`.
- **Info-bulle claire à droite** des éléments du menu — extension du système de
tooltip existant (`app.js`) : `data-tooltip-html` (ex. en italique + description
pour la ligne 1) + classe `.tooltip-light`, position à droite de l'élément (débord
→ à gauche), sous l'élément hors toolbar.
- **Persistance du formatage inline** : nouveau sérialiseur **`gtMd()`** (DOM formaté
→ source markdown) appelé par `sync()` (blocs + enfants toggle/columns) — le
formatage posé ressort en `**gras**`, `*ital*`, `__souligné__`, `~~barré~~`,
`` `code` ``, `==surlignage==`, `[texte](url)` ; les chips wiki gardent leur token.
`mdEsc()` rend en plus `__…__` (`<u>`), `==…==` (`<mark>`) et `$$…$$`
(équation inline KaTeX, `data-tex` conservé pour l'aller-retour).
- **Fermeture propre** : clic hors sélection → le menu se referme ; clic **dans** la
toolbar → sélection et menu intacts (`@mousedown.prevent` + garde `mouseup`).
### Fixed
- **`askAI(idx)` était écrasé par `askAI()`** (même clé dans l'objet éditeur, définie
plus bas) : « Ask AI » du menu de bloc et `Ctrl+J` ouvraient l'IA de **page** au
lieu du composeur du bloc → renommé **`askAIBlock(idx)`** (appels : menu de bloc,
`Ctrl+J`, `Alt+⇧+E`).
- **`ed.__x` introuvable sous Alpine v3** — le data de `.page-editor-wrapper` était
lu via `ed.__x` (API Alpine v2, plus exposée depuis le bundle CSP) : `showFmt`,
`hideCommentBtn` et les handlers de mentions ne s'exécutaient **jamais** → la
toolbar de sélection n'apparaissait pas. Accès unifié via `_xed()`
(`_x_dataStack[0]`, repli `__x`), 4 sites éditeur.
- **Ancre de sélection** — `closest('[data-bid]')` partait du parent de l'ancre :
quand l'ancre est un nœud **élément** (sélection programmée, double-clic), le bloc
n'était jamais trouvé → `showFmt` pas appelé. Gestion des 2 types de nœud.
- **Barre d'espace = exception** — `checkMd()` recevait l'**id** du bloc au lieu de
l'index : chaque espace levait `Cannot read properties of undefined (reading 'id')`
et les raccourcis markdown (`# `, `- `) ne se déclenchaient jamais.
### Tests
- `tests/test_v762_selection_toolbar.py` : ordre des 4 lignes + Skills + pied,
câblage réel des actions, persistance `gtMd`/`mdEsc` (**roundtrip réel sous node**),
placement/fermeture, styles ; `tests/test_v761_block_menu.py` adapté
(`askAIBlock`).
- **e2e Playwright** `e2e/v762_selection_toolbar.spec.js` (instance 8081) : structure
des 4 lignes, sous-menu Turn into (coche du type courant), menu « … » (recherche +
pied), géométrie (sous la sélection, dans le viewport), **persistance du gras et du
surlignage après reload**, fermeture hors sélection → 1 passed (captures dans
`e2e/shots/` — répertoire gitignoré).
- **Suite complète** : `pytest tests/ -q -n auto` → **1368 passed / 0 failed**,
`ruff check app tests` → All checks passed.
## v7.61.0 (2026-10-08) — Menu contextuel de bloc refondu façon Notion
### Added
- **Panneau de gauche** : barre de recherche « Search actions... » (filtre en
direct), titre de section « Text », puis les 10 actions dans l'ordre Notion :
Turn into ›, Color ›, Copy link to block (`Alt+⇧+L`), Duplicate (`Ctrl+D`),
Move to (`Ctrl+⇧+P`), Delete (`Del`), Comment (`Ctrl+⇧+M`), Suggest edits
(`Ctrl+⇧+Alt+X`), Ask AI (`Ctrl+J`), Skills › — avec les raccourcis
affichés, et le pied de page « Last edited by … / Today at H:MM / N words,
M characters » (auteur lu dans le compte, heure = `updatedAt` du bloc page,
taille calculée sur le contenu du bloc).
- **Sous-menu « Turn into »** (survol du panneau de gauche → panneau de
droite, fermeture différée) : Text ✓ (type actuel coché), Heading 1-4,
Bulleted list, Numbered list, To-do list, Toggle list, Code, Quote,
Callout, Block equation, Synced block, et **2/3/4/5 columns** avec
info-bulle « Create N columns of blocks » ; `turnInto(idx, type, cols)`
crée désormais N colonnes et ouvre le sélecteur de source pour les blocs
synchronisés.
- **Sous-menu « Color »** : sections **Last used** (raccourci `Ctrl+⇧+H`),
**Text color** (Default, Gray, Brown, Orange, Yellow ✓, Green, Blue,
Purple, Pink, Red — glyphe « A » à la couleur) et **Background color**
(même palette avec carré de couleur) ; la couleur utilisée est cochée, la
dernière est mémorisée (`localStorage fdLastBlockColor`).
- **Sous-menu « Skills »** : liste réelle des compétences de l'espace
(`GET /api/agent/skills`) → applique le `prompt_template` au contenu du
bloc via l'IA et insère le résultat dessous.
- **Raccourcis** — handler clavier dédié : `Ctrl+J` (Ask AI), `Ctrl+⇧+H`
(dernière couleur), `Ctrl+⇧+M` (commenter le bloc : sélection du contenu +
tiroir de commentaires), `Ctrl+⇧+Alt+X` (suggérer une édition → résultat
inséré dessous), `Alt+⇧+L` (copier le lien), `Ctrl+⇧+P` (déplacer vers…),
`Ctrl+D` (dupliquer — déjà géré par la saisie, pas de double), `Del`
(**seulement avec le menu ouvert**, jamais pendant la saisie), `Échap`
(fermer).
- **Actions Suggest edits / Ask AI / Comment / Skills** réelles côté éditeur
(`suggestEdit`, `askAI`, `commentBlock`, `runSkill`) — Ask AI ouvre le
composeur inline sans effacer le contenu du bloc.
- **Styles** : `.bm-group`, `.bm-foot`, `.bm-check`, `.bm-current`,
`.bm-dot`, sous-menu `60vh` défilable, recherche focalisée à l'ouverture.
- **Tests** — `tests/test_v761_block_menu.py` : **9 tests** (structure et
ordre du panneau, dispatch des 10 actions, 3 sous-menus, survol/fermeture,
les 9 raccourcis, comportement node : `turnInto(…, 'columns', 3)` → 3
colonnes, styles) + `test_v7593` adapté à la nouvelle signature.
### Removed
- Entrées **Copy / Cut / Paste blocks** du menu (absentes de la spécification
Notion) — les raccourcis `Ctrl+C/X/V` sur blocs sélectionnés sont conservés.
- Pastilles de couleurs inline : remplacées par le sous-menu Color nommé
(palette portée à 10 couleurs alignées Notion : +Brown, Pink réel).
## v7.60.0 (2026-10-07) — Éditeur : bouton + à gauche du handle de bloc
### Added
- **Bouton `+` à gauche du handle** (les 6 points qui ouvrent le menu ⋮⋮ /
servent au drag) : clique → **nouveau bloc inséré sous le bloc courant**,
via le mécanisme déjà en place (`E.addAfter(idx)` → `addAt(idx+1)`).
- Positionné dans la gouttière (`left:-64px`, gouttière `.blocks-container`
de 64px, `-56px` quand elle passe à 32px), apparaît au survol/focus du bloc
comme le handle, masqué avec lui sur écran étroit (≤768px et le second
breakpoint — aucun débordement horizontal ajouté au conteneur.
- `aria-label` + `title` « Add block below » (accessibilité).
- **Tests** — `tests/test_v760_block_insert_btn.py` : **2 tests** (ordre
dans le wrapper, câblage `E.addAfter`, positions/masquages CSS).
## v7.59.3 (2026-10-07) — Correction : « Turn into » du menu contextuel
### Fixed
- **« Turn into… » ne faisait rien** — la garde validait le type avec
`_BLOCK_TURN_TYPES.indexOf(type)` alors que ce tableau contient des **objets**
`{id, name}` : `indexOf` d'une chaîne renvoyait toujours `-1` → `turnInto()`
retournait immédiatement après l'ouverture du sous-menu. Correction :
`_BLOCK_TURN_TYPES.some(t=>t.id===type)`.
- **Texte conservé** — convertir un bloc en **To-do** ou **Toggle** vidait le
contenu (ces deux types étaient exclus de la conservation par erreur) ; seul
un type sans champ texte (`image`, `embed`, `divider`, `button`, `columns`,
…) le vide désormais, comme dans `Notion`.
- **Tests** — `tests/test_v7593_turn_into.py` : **4 tests**, dont 1
comportemental **node** qui exécute la vraie `turnInto()` extraite de la
source : conversion effective du type, texte conservé (To-do = étiquette +
case décochée), contenu vidé pour un type sans champ texte.
## v7.59.2 (2026-10-07) — Corrections : aide, « Insert below », listes numérotées
### Fixed
- **Aide** — bouton **✕** en haut à droite du panneau (classe partagée
`settings-close` : 32 px desktop / **44 px mobile**) + méthode `close()` dans
`help.js` (retour à la page précédente, sinon `/workspaces`) ;
`.settings-panel` passé en `position:relative` pour ancrer les boutons au
panneau (hamburger + ✕) plutôt qu'à la page.
- **Éditeur — « Insert below » (/Write with AI)** : on insère le **markdown
source** conservé à la génération (`AIC._md`) au lieu du texte extrait du
HTML rendu (`_resultText()` ne reste qu'en repli pour les messages d'erreur) —
titres, listes, gras et numéros survivent à l'insertion.
- **Listes numérotées** — elles n'avaient **aucun numéro au rendu** : le CSS
ne déclarait que le retrait, jamais le marqueur. Le rang des blocs
`numbered_list` contigus est désormais calculé au rendu (`data-num`) et
affiché via `content: attr(data-num) ". "` → **`/numbered list` et
l'insertion depuis l'assistant affichent enfin 1, 2, 3**.
- **`md2b`** (markdown → blocs, chemin agent) : les cases à cocher `- [ ]` /
`- [x]` sont testées **avant** la branche des puces (une ligne `- [ ]`
était avalée par cette branche) — aligné sur l'ordre déjà correct de
`paste2b` ; le séparateur `1)` est accepté (CommonMark).
- **Menu slash** — une requête tapée **dans le bloc** (`/numbered list`, focus
qui n'a pas rejoint l'entrée du menu) est répercutée dans le filtre au lieu
d'être ignorée.
- **Tests** — `tests/test_v7592_ui_fixes.py` : **5 tests**, dont 1 test
comportemental **node** qui exécute le vrai `md2b` extrait de la source.
- `ruff` : ordre d'imports de `tests/test_agent.py` (I001 préexistant au pull).
## v7.59.1 (2026-10-07) — Fix fournisseur Mistral (clé valide, 403 tier_not_allowed)
### Fixed
- **Mistral « Test connection » échouait malgré une clé valide** — cause
racine : `mistral-large-latest` (modèle par défaut + tête de liste) n'est
pas servi par les plans d'abonnement basiques → `403 tier_not_allowed`
(code 1910). La clé elle-même fonctionne (`/v1/models` 200, chat 200 sur
small/medium/nemo/codestral — vérifié en direct).
- `PROVIDERS["mistral"]` default → `mistral-small-latest` (tous plans).
- `PROVIDER_MODELS["mistral"]` réordonnée : modèles tous-plans d'abord
(le test de connexion sélectionne le premier candidat).
- `mistral` ajouté à `_CHAT_VALIDATED_PROVIDERS` : le fetch des modèles
sonde chat/completions et exclut les modèles hors plan (46 listés →
25 réellement utilisables avec la clé du compte).
- **Migration 36** : `default_model` des lignes mistral pointant sur un
modèle bloqué réinitialisé (→ nouveau défaut), `llm_config.model` idem.
### Tests
- `tests/test_agent.py::test_mistral_defaults_are_tier_safe` · suite agent
OK · test de connexion live sur l'instance déployée : `{"ok":true,
"model":"mistral-small-latest","reply":"PONG","verified":true}`.
## v7.59.0 (2026-10-07) — Mobile : drawer réglable, side-nav settings, aide refondue
### Fixed
- **Scroll souris du drawer sidebar mobile** — `display:block` du breakpoint
≤768px cassait le `flex:1` de `.sidebar-scroll` (hauteur = contenu, jamais
de zone scrollable). Remis en `display:flex;flex-direction:column`.
- **Rate limiter du routeur de test** — fenêtre remise à zéro à chaque login
: suite e2e via un seul login `beforeAll` + cookies `storageState`.
### Changed
- **Settings mobile : le menu devient une side-navigation gauche** ouverte
par un hamburger (`.settings-menu-btn`) avec backdrop, au lieu des onglets
horizontaux ; fermeture auto au choix de section. Squelette panneau
(overlay/panel/nav) extrait de `settings.html` vers
`/static/css/settings.css` partagé avec l'aide.
- **Page `/help` refondue au complet** — ancien monologue HTML inline de la
route remplacé par `app/templates/help.html` : même panneau 2 colonnes que
settings, nav latérale en 13 sections couvrant toute l'app (démarrer,
éditeur, bases & vues, tâches/dépendances, workspaces/forges, collaboration,
bibliothèque/recherche, agent IA & automations, API v2/webhooks/clipper,
PWA offline, comptes & SSO, raccourcis, tips) ; `static/js/help.js`
(`Alpine.data helpInit`).
### Tests
- Gate e2e `mobile_regression.spec.js` : 6/6 (drawer + wheel scroll +
side-nav settings + aide mobile/desktop) · `test_app.py` +
`test_pwa_offline.py` : 230/230.
## v7.58.0 (2026-10-06) — Add plugins : catalogue on/off à effet réel (phase 8/8)
### Added
- **`app/services/plugins.py` + migration 35** — table `plugins` (`slug`,
`name`, `description`, `enabled`) pré-remplie avec **3 modules câblés** :
`web-tools`, `web-clipper`, `automations`. Ligne absente = activé (défaut
sûr : rien n'est coupé par accident).
- **Effet réel, jamais un simple drapeau** :
- `automations` OFF → dépendance FastAPI posée **à l'`include_router`**
(3 lignes dans `main.py`, aucun router touché) → toutes les routes
`/workspace/automations*` refusées, **et** le scheduler de fond passe en
veille (garde sur chaque tick) ;
- `web-clipper` OFF → `GET /extensions` + tout `/api/v2/web-clipper/*`
refusés ;
- `web-tools` OFF → `web_search` et `fetch_url` retirés du **schéma** de
l'agent et de `execute()` (`ToolRegistry._all()`) : le LLM ne les voit plus.
- **UI masquée côté serveur** — nouveau global Jinja `plugin_enabled(slug)` :
`settings.html` rend la nav « Extensions » / « Automations » sous
`{% if %}` (élément **absent** du DOM, pas seulement caché) et conditionne
les sections en `x-show`.
- **Menu +** — l'entrée « Add plugins » devient une vraie section : liste des
3 plugins (état + description) avec bascule ;
`GET /api/agent/plugins` + `PATCH /api/agent/plugins/{slug}` (slug inconnu →
404, 401 sans session).
- **Tests** — `tests/test_v758_plugins.py` : **10 tests** — catalogue,
persistance en base, routes refusées (302 hors `/api` + 404 JSON pour
`/api*`, handler unifié de `main.py`), outils retirés du registre, nav
disparue du `/settings` rendu, câblage du menu, 401.
## v7.57.0 (2026-10-06) — Connecteurs : Discord, Telegram, Teams, MCP (phase 7/8)
### Added
- **Presets Discord / Telegram** — champ **Type** dans le formulaire « Ajouter
un connecteur » (Personnalisé / Discord / Telegram / Serveur MCP) : URL et
schéma d'authentification pré-remplis. Colonnes `kind` + `auth`
(`bearer`/`bot`/`none`) ajoutées à `agent_connectors` (migration **34**) ;
Discord envoie son jeton préfixé dans l'en-tête d'autorisation, Telegram place
le jeton **dans l'URL** via le substitut `{secret}` — substitué à l'appel,
**jamais stocké en clair** (la colonne url ne contient que le motif).
- **Teams** — scope `ChannelMessage.Read.All` ajouté aux Graph scopes M365
(consentement admin requis à la reconnexion).
- **`app/services/mcp_client.py`** — client MCP (streamable HTTP) : handshake
`initialize` → `notifications/initialized` → `tools/list` → `tools/call`
(JSON-RPC 2.0, réponse JSON **ou** premier `data:` d'un `text/event-stream`),
garde SSRF conservée, erreurs JSON-RPC remontées telles quelles, sortie
bornée à 20 000 car. Les outils sont **cachés** en base (`tools_json`) — le
bouton « Tester le connecteur » rejoue le handshake et met à jour la liste.
- **Outils dynamiques au registre** — `ToolRegistry._all()` merge ce cache à
chaque run : chaque outil est exposé au LLM sous `mcp_<serveur>_<outil>` avec
son `inputSchema` d'origine, et `execute()` dispatche sur `tools/call`.
Serveur désactivé → outil **absent du schéma** ; échec réseau →
`ToolResult(error)` sans casser le run.
- **Menu +** — badge `· N outil(s)` sur la fiche d'un serveur MCP ;
`POST /api/agent/connectors` accepte `kind` + `auth` (400 sur valeurs
inconnues).
- **Tests** — `tests/test_v757_mcp_discord.py` : **12 tests**, 0 appel réseau
réel (`_rpc` / `_get` monkeypatchés) — presets, `{secret}` absent de la base,
handshake + séquence d'appels, cache, dispatch LLM, serveur désactivé,
erreurs RPC, parse SSE, scopes Teams, câblage menu.
## v7.56.0 (2026-10-06) — Connecteurs : Google + Microsoft 365 (phase 6/8)
### Added
- **`app/services/oauth_connectors.py`** — flow OAuth2 complet **PKCE (S256)** pour
2 fournisseurs décrits par 1 dict :
- **Google** : Drive / Gmail / Calendar **en lecture seule** ;
- **Microsoft 365** : Graph `Files.Read` / `Mail.Read` / `Calendars.Read` ;
- `begin()` → URL d'autorisation + state + code_verifier ; `complete()` →
échange du code ; `access_token()` → **refresh automatique** (60 s avant
expiration, `refresh_token` conservé si le fournisseur n'en renvoie pas) ;
`api_get()` → lecture bornée, `path` absolu refusé + validation SSRF ;
- tokens chiffrés **Fernet** via `_encrypt_tokens` de `calendar_sync`
(réutilisation, aucune nouvelle dépendance) dans la table
**`connector_tokens`** (migration **33**, PK `(kind, user_id)`).
- **4 routes OAuth** (`/api/agent/connectors/oauth/{kind}/…`) : `status`,
`authorize` (cookies d'état HttpOnly 10 min + retour same-origin validé),
`callback` (GET = SAFE_METHODS, `state` comparé en temps constant, tokens
stockés puis cookies purgés, redirection `?oauth=connected` /
`?oauth_error=…`), `disconnect`. **OpenAPI : 523 chemins**.
- **Config + `.env.example`** : `GOOGLE_CLIENT_ID/SECRET`, `MS_CLIENT_ID/SECRET`
(vidés = « non configuré »), redirect URI dérivé d'APP_BASE_URL.
- **Catalogue** : les 2 nouveaux natifs apparaissent avec le badge
« non connecté — lancer la connexion OAuth » / « connecté · N scope(s) » ;
`connector_fetch` et `Tester` passent par l'API Graph/Google avec le token de
l'utilisateur (outil LLM = `user_id` désormais transmis).
- **Menu +** : « Se connecter » (redirige vers le fournisseur) / « Déconnecter »
sur la fiche du connecteur, + toast au retour du flux (URL nettoyée via
`history.replaceState`).
### Tests
- `tests/test_v756_oauth_connectors.py` — **13 tests** : URL d'autorisation
(PKCE, scopes, redirect URI, cookies), 400 non configuré, callback → tokens
**chiffrés** en base + redirection + `status.connected`, **state forgé refusé
sans aucun appel réseau**, callback sans session → `oauth_error=session`,
refresh (grant_type, conservation du refresh_token), `api_get` (Bearer + hôte
fixe + URL absolue refusée), non connecté, déconnexion, catalogue, outil LLM,
401 sans session, 404 kind inconnu, câblage menu. Aucun appel réseau réel
(`_post_form` / `_api_get` monkeypatchés).
- `test_v755` adapté : **5 natifs** (gitea, github, web, google, ms365).
- **Suite complète : 1319 passed / 0 failed** (`-n auto`), `ruff` 0,
`eslint` 0 problème.
## v7.55.0 (2026-10-06) — Menu + : Connecteurs — socle (phase 5/8)
### Added
- **Table `agent_connectors`** (migration **32**) : `name`, `url`,
`secret_encrypted`, `enabled`, `status`, `detail` — colonnes **plates**, pas de
`config_json` (les scopes OAuth des phases 6-7 ajouteront le leur).
- **`app/services/connectors.py`** — 1 fichier (pas de package) :
- **3 connecteurs natifs** (`gitea`, `github`, `web`) servis à la volée, statut
dérivé de la config **sans réseau** (`native_state()`) ;
- CRUD des connecteurs personnalisés : l'URL passe par **`_validate_url`
(garde SSRF)** à la création, la clé est **chiffrée Fernet** (`encrypt_secret`)
et **jamais renvoyée** (seul `has_secret` l'est) ;
- `probe()` : appel de contrôle (`_get`, timeout 10 s, re-vérification de
l'hôte après redirection) qui **persiste** `status`/`detail` ;
- `connector_fetch()` : lecture pour l'LLM — API Gitea/GitHub (native),
recherche web (native) ou GET du connecteur personnalisé, borné à 20 000 car.
- **API** (`/api/agent/connectors`) — `GET` (catalogue), `POST` (création, 400 sur
URL privée), `PATCH` (name/enabled/secret), `DELETE`, `POST /connectors/probe`
; session requise partout (401), CSRF global. **OpenAPI : 519 chemins**.
- **Outil LLM `connector_fetch`** (`tool_registry`, 26ᵉ outil) — args
`connector` (id / nom / kind), `path`, `query` ; connecteur désactivé ou
inconnu = erreur outil, **jamais une exception qui casse le run**.
- **Menu + : section « Connecteurs »** — catalogue avec badge de statut
(✓/✗/⚠/?), fiche par connecteur (Tester, Activer/Désactiver, Supprimer — les
2 derniers masqués pour les natifs) et formulaire « Ajouter un connecteur
personnalisé » (nom, URL, clé en `type="password"`).
### Tests
- `tests/test_v755_connectors.py` — **13 tests** : 3 natifs listés, clé jamais
renvoyée + chiffrée en base (roundtrip Fernet), **5 URLs refusées** (localhost,
127.0.0.1, metadata cloud, ftp:, file:), toggle persisté + 404, 401 sans
session, probe OK (clé bien envoyée) / probe erreur persistée, outil
`connector_fetch` (schéma, succès, désactivé, inconnu), câblage du menu.
- `test_v751`/`test_v753`/`test_v754` adaptés : **1 seule section « bientôt »**
(plugins).
- **Suite complète : 1306 passed / 0 failed** (`-n auto`), `ruff` 0,
`eslint` 0 problème.
## v7.54.0 (2026-10-06) — Menu + : Mémoire de l'agent (phase 4/8)
### Added
- **Mémoire de l'agent** — le moteur n'envoie jamais l'historique des messages
(`AgentEngine.run()` ne construit que système + objectif courant) : chaque run
repartait de zéro. Désormais une **ligne résumé par conversation**
(`app/services/agent_memory.py`, table `agent_memory`, migration 31) est
écrite à la fin de chaque run et **ré-injectée au contexte du run suivant**
quand le toggle est activé.
- `context_for()` : bloc « ## Mémoire de la conversation », **budget 4 000
caractères** (troncature par la gauche avec marqueur) ;
- `remember()` : 1 note par échange (objectif 180 car. → réponse 700 car.),
**20 notes max**, upsert, et **jamais une erreur ne fait échouer un run** ;
- OFF = ni lecture ni écriture (contexte strict du run courant).
- **Toggle « Mémoire » dans le menu +** — entrée racine à l'état réel
(🧠 activée / 💭 désactivée avec le libellé correspondant), bascule via
`PATCH /api/agent/conversations/{id}` (`memory_enabled` ajouté au whitelist),
état persisté et reflété à l'ouverture/à la création de la conversation.
- **`agent_conversations.memory_enabled`** (migration **31**, défaut `1`) +
config **`AGENT_MEMORY_DEFAULT`** (`settings.agent_memory_default`) appliquée
à la création d'une conversation ; la réponse de création expose la valeur.
### Tests
- `tests/test_v754_memory.py` — **8 tests** : colonne + table créées par la
migration, défaut ON à la création, toggle persisté relu en base,
injection ON / absence OFF / persistance à travers un cycle OFF→ON,
budget et nombre de notes bornés, **2 runs réels du moteur** (FakeLLM qui
capture le prompt : mémoire présente ON, absente OFF), câblage front/back.
- `test_v751` + `test_v753` adaptés : **2 sections « bientôt »** restantes
(connecteurs, plugins).
- **Suite complète : 1293 passed / 0 failed** (`-n auto`), `ruff` 0,
`eslint` 0 problème.
## v7.53.0 (2026-10-06) — Menu + : Design System – Canevas (phase 3/8)
### Added
- **Section « Design System – Canevas »** — liste des canevas
(`GET /board/api/page-templates` : intégrés + personnels), puis une page de
détail avec 3 actions :
- **Créer une page à partir du canevas** (`POST …/0/use` avec `{key}` *dans le
body* pour un intégré, `POST …/{id}/use` pour un personnel) ;
- **Insérer dans le document ouvert** — `GET …/blocks` (nouvelle route) →
`ensureBlockIds()` (global côté éditeur) → concaténation dans `E.blocks` +
`autoSave/render`, désactivé si aucun document éditable n'est ouvert ;
- **Enregistrer le document ouvert comme canevas**
(`POST /board/api/page-templates` avec `page_id`).
- **`GET /board/api/page-templates/{template_id}/blocks`** — blocs d'un canevas
( intégré via `template_id=0&key=`, sinon id) ; 404 clé/id inconnu,
500 si le JSON est corrompu. **OpenAPI : 516 chemins**, `info.version` 7.53.0.
- **Canevas « Design System »** intégré (`design_system` dans
`app/services/block_templates.py`) : callout tokens, TOC, 3 toggles
composants, grille & espacement, checklist revue UI, citation.
- **Navigation** : en-tête ← gère le niveau canevas → liste.
### Fixed
- **Prefixe des canevas** : le front du panneau utilisait `/api/page-templates`
(404 silencieux) — la route réelle est `/board/api/page-templates` (router
`prefix="/board"`), comme `base.html` le fait déjà.
### Tests
- `tests/test_v753_canvases.py` — **5 tests** : canevas `design_system` listé,
route blocks intégré (sans `id`, 404 clé inconnue, `empty` par défaut),
route blocks personnel + 404, création de page depuis le canevas (blocs lus
en base, ids assignés), câblage des sections (3 « bientôt » restantes) +
garde-fou « `key` dans le body ». `test_v751` adapté (3 sections désactivées).
- **Suite complète : 1285 passed / 0 failed** (`-n auto`), `ruff` 0,
`eslint` 0 problème.
## v7.52.0 (2026-10-06) — Menu + : Compétences-skills « Gérer » + « Parcourir » (phase 2/8)
### Added
- **Sous-menu « Compétences-skills »** — 11 skills builtin + les skills
enregistrés, avec **libellés affichés** « Deep research » (`/research`) et
« Skill-creator » (`/create-new-skill`) via `FD_SKILL_ALIAS` (slugs intacts) ;
le clic épingle le skill au contexte, même chemin que la palette `/`.
- **« Gérer les compétences »** — section du menu : liste des skills,
formulaire intégré (nom, description, prompt) pour créer/éditer, suppression
avec nettoyage du chip épinglé, **export JSON** téléchargé et **import** en
fichier (`overwrite:true` → jamais de doublon).
- **« Parcourir les compétences »** — section galerie filtrable (nom /
description / slug), installation en 1 clic (`installGallerySkill`).
- **`PATCH /api/agent/skills/{skill_id}`** — édition partielle, whitelist de
colonnes figée côté serveur, réponses 400 (champ vide / aucun champ),
401 (sans session), 404, 409 (nom déjà pris). **OpenAPI régénéré** :
`docs/openapi-v2.json` → 515 chemins, `info.version` 7.52.0.
- **En-tête ← + titre de section** dans le menu (indispensable à 3 niveaux :
racine → compétences → gérer/parcourir/formulaire).
- **Fix** : épingler un skill enregistré **n'efface plus** le message déjà tapé
(`_pinDbSkill` ne vide le composer que s'il est vide).
### Tests
- `tests/test_v752_skills_menu.py` — **7 tests** : PATCH complet + partiel,
validations 400/404, 401 sans session (CSRF valide pour isoler la route),
import sans doublon (409 puis `overwrite` → 1 seule ligne), aller-retour
export → import, câblage des sections + alias, markup rendu dans la page.
- `tests/test_v751_plus_menu.py` adapté : 4 sections « bientôt » au lieu de 5.
- **Suite complète : 1280 passed / 0 failed** (`-n auto`), `ruff check app tests`
propre, `eslint static/js/agent_panel_2.js` **0 problème**.
## v7.51.0 (2026-10-06) — Menu + de l'assistant : hub de contexte (phase 1/8)
### Added
- **Menu à sections sur le bouton +** (`agent_panel.html` + `agent_panel_2.js`) :
le + ouvre *Ajouter des fichiers ou répertoires*, *Compétences-skills*,
*Connecteurs*, *Design System – Canevas*, *Add plugins*, *Mémoire (on/off)*.
Sections pas encore livrées affichées « bientôt (phase N) » et **désactivées**
(décision D3 : le menu ne promet rien que le code ne fait). Navigation clavier
↑/↓/Entrée/Échap, focus visible, Échap revient d'un niveau.
- **Parcours fichiers & répertoires** — « Parcourir… » affiche un niveau à la fois
via `/api/nav/menu` (contrat : dossier = `icon === 'folder'`) avec fil d'Ariane
cliquable ; un document s'épingle (`document:<id>`), un dossier s'épingle depuis
la ligne « 📌 Épingler le dossier » (`folder:<id>`).
- **Jeton `folder:<id>`** résolu par `ContextBuilder._mentions_context` : titre du
dossier + documents directs, budget ~12k caractères (marqueur « contenu du
dossier tronqué »). Enfants directs seulement — récursion à ajouter si les
arbres profonds deviennent réels.
- **« Rechercher… »** conserve l'ancien sélecteur de mentions (recherche par nom,
`@` inline) à l'identique : zéro régression du chemin existant.
### Tests
- `tests/test_v751_plus_menu.py` — **7 tests** : résolution du dossier, budget
borné, id inconnu, contrat `/api/nav/menu`, rendu du menu dans la page,
6 sections dont 5 désactivées, jeton `folder:` côté front et côté backend.
- **Suite complète : 1273 passed / 0 failed** (`-n auto`), `ruff check app tests`
propre, `eslint static/js` **0 erreur**.
## v7.50.2 (2026-10-06) — Peek de Library : nu comme local-workspace (plus de bande ni double scrollbar)
### Fixed
- **Le document ouvert par le bouton « Open » de Library n'occupait pas le
panneau** — `.peek-body` gardait un `padding:16px 20px` + `overflow-y:auto`
(héritage de l'ancien peek à rendu HTML) : l'iframe était amochée
(519×816 mesurés contre 559×848 en local-workspace) avec une bande grise
autour du document et une double scrollbar. Corps nu (`padding:0`,
`overflow:hidden`) : l'éditeur remplit le panneau, exactement comme le peek
« parfait » de /local-workspace (mesuré Playwright, cookie de session forgé).
### Tests
- `test_library_peek_body_has_no_inset_padding`. Suite 1266 passed / 0 failed
(-n auto) + ruff 0.
## v7.50.1 (2026-10-06) — Peek des bases : le contenu se rend enfin + tableau pleine largeur
### Fixed
- **Une base ouverte en side peek restait à « Loading database… »** —
`page_editor_embed.html` (le peek de `/pages/{id}?embed=1`) n'incluait pas
`database_table.js` : `window.FlowDeckDB` était undefined dans l'iframe et
le bloc `embed/collection` ne s'hydrat jamais. Inclus désormais (même
traitement que `page_editor.html`).
- **L'icône « Open » de fin de ligne d'un tableau de base ne faisait rien
hors page dédiée** — le panneau `#db-side-peek` n'existait que dans le
markup de `_database_table.html` ; sans lui, `openPageInSidePeek` était un
no-op. Le panneau est créé à la demande par `database_table.js`
(`ensureDbPeek`, comme `mt-peek` de My Tasks) : markup unique, le partial
ne contient plus le panneau statique.
- **Barre de scroll sous le tableau des bases** — `.database-table-container`
plafonnait à 1100 px (règle héritée de Library) : le tableau ne prenait
pas la largeur du document. Plafond retiré ; `max-width: 100%` aussi pour
les conteneurs `.db-first` (base embarquée en premier bloc) et le titre
des pages de base — les colonnes dictent seules le scroll horizontal.
### Tests
- `test_embed_editor_can_render_inline_databases` (include peek + panneau
créé à la demande, source unique de markup). Suite 1265 passed / 0 failed
(-n auto) + ruff 0.
## v7.50.0 (2026-10-06) — Side peek : le document seul (mode édition), sans sidebar ni barre
### Fixed
- **Tous les boutons « Open » ouvraient l'habillage pleine page dans le panneau
latéral** — `library.js` (OPEN de hover), `local_workspace.js` (↢ Open +
« Open in side peek » du menu contextuel) et `my_tasks.js` (peek des tâches)
chargeaient `/pages/{id}` SANS `?embed=1` : le peek affichait sidebar +
barre de navigation + éditeur (régression v7.49.0). Tous repassent en
`/pages/{id}?embed=1` = le document en mode édition SEUL ; le cadre du
panneau (garde/close/resize partagés `fdWirePeekResize`) reste identique.
- **Le bouton « Open » du calendrier My Tasks ne faisait rien** — cause
racine : le handler de clic testait `e.target.dataset.openRow`, mais les
boutons `.mt-cal-open` du calendrier (et du Kanban) ne contiennent que leur
`<svg>` : cliquer l'icône donnait `e.target` = `<svg>` sans dataset. Le
handler résout désormais `t.closest('[data-open-row]')` — un seul chemin
pour tableau, calendrier (5 vues) et Kanban.
- **Menu contextuel global « Open in side peek »** — le clic ne faisait
qu'afficher « Side peek not available in browser » : il route maintenant
vers le peek de la page courante (`_libData.openPeekById` en Library,
`_wsData.openSidePeek` en workspace local), repli nouvel onglet ailleurs.
- **Peek d'une page de base (`content_format='collection'`)** — `?embed=1`
forçait le template éditeur : une base ouverte dans le peek montrait un
éditeur de blocs vide au lieu du tableau. Le template collection est gardé
en mode embed (body.embed-mode retire sidebar + barre, le tableau reste).
### Added
- **Bouton « Open » sur les tuiles du Kanban My Tasks** — le même que les
lignes du tableau (`openButton(t)`, `.mt-hover-only` révélé au survol de la
carte, overlay absolu pour ne pas déformer la tuile) ; ouvre le contenu de
la tâche dans le panneau latéral.
- **Bouton « pleine page » des peeks de bases** — navigue vers
`/pages/{pageId}` (pleine page, sidebar + barre conservées) au lieu de
l'URL `?embed=1` de l'iframe ; `dataset.page` porte l'id résolu.
### Tests
- `test_my_tasks_workspace_coherence.py` : les 4 peek JS (`library`,
`my_tasks`, `database_table`, `local_workspace`) doivent porter
`?embed=1` (gate de cohérence inversé — l'ancien test imposait l'URL nue) ;
nouveaux : résolution `closest('[data-open-row]')`, bouton Open sur tuiles
Kanban, collection embed = tableau. Suite 1264 passed / 0 failed
(-n auto) + ruff 0.
## v7.49.2 (2026-10-05) — Pages de base : titre au-dessus, tableau à gauche
### Fixed
- **Le tableau d'une page de base commençait au 1/3 de la page** — une base
créée depuis un document est un bloc `embed collection` rendu dans la
colonne éditeur `.blocks-container` (`max-width: 900px; margin: 0 auto`) :
centrée sur un grand écran, donc décalée loin du sidebar (mesuré x=694 sur
1920 px). Nouvelle classe `.db-first` sur le wrapper éditeur quand le
premier bloc est une base : titre et blocs passent à `margin: 0` +
`padding-left: var(--space-lg)` (liseré de la topbar) — tableau au ras du
sidebar (x=256 mesuré), titre au-dessus, plus de débordement de l'embed
(`max-width: 100%`, le scroll horizontal reste géré par `.db-table-wrapper`).
- **Les pages de base dédiées (`content_format='collection'`) n'avaient pas
de titre** — `page_editor_collection.html` rend le tableau sans en-tête de
page. Un `<h1 class="database-page-title">` est ajouté au-dessus du
conteneur, avec le même liseré gauche que le tableau.
## v7.49.1 (2026-10-05) — Tableau des bases aligné sur le titre
### Fixed
- **Le tableau d'une page de base était décalé sous le titre** — le conteneur
`.database-table-container` avait `padding: 0 24px` tandis que la topbar
(fil d'Ariane/titre) est à `var(--space-lg)` = 16 px : deux marges gauches
différentes. Le conteneur passe à la même valeur que la topbar, `margin: 0`
conservé — le bord gauche du tableau s'aligne désormais au ras du titre et
de la marge.
## v7.49.0 (2026-10-05) — Side peek : l'éditeur plein écran, et un calendrier complet
### Added
- **Calendrier My Tasks : cinq vues** — Mois, Année, Semaine, Jour et Planning,
avec une navigation propre à chaque granularité (`←` / `→` et « Aujourd'hui »
se recalculent : par exemple 5 – 11 octobre → 12 – 18 octobre en semaine, ou
un jour en année). L'Année affiche les douze mini-mois de l'année courante et
le Planning une ligne par jour avec ses tâches réparties par statut.
- **Icône d'ouverture sur chaque tâche du calendrier** — même bouton « Open in
side peek » que le tableau, révélé au survol. Elle porte le même attribut de
ligne (`data-open-row`), donc un seul gestionnaire de clic pour toutes les vues :
le titre est transmis par `data-open-title`, disponible là où il n'y a pas de
ligne `.mt-row` (vues Jour, Semaine et Année).
### Fixed
- **Impossible d'ajuster la largeur du side peek des bases** — le panneau avait
été réécrit en bindings Alpine (`:class="dbPeekOpen"`, `@click.outside`) alors
que le conteneur n'a aucun `x-data` : bindings inertes, `openPageInSidePeek`
passait par `container.__x.$data` (undefined), le panneau ne s'ouvrait pas et
la poignée de redimensionnement ne réagissait pas. Repassé en vanilla JS
(câblage direct sur le document, repli discret pour les collections embarquées
inline). Au passage : les quatre implémentations dupliquées du drag de
largeur (Library, workspace local, My Tasks, bases) sont remplacées par un
helper unique `window.fdWirePeekResize` (`app.js`) — pointer capture, borne
300 px–90 vw, clic = fermer, largeur persistée (`fd_peek_width`) et commune
à tous les peeks, restaurée au chargement. My Tasks ne capturait ni le
pointeur ni la largeur : son drag était erratique et non mémorisé.
- **Largeur des pages standardisée sur celle de Library (1100 px)** — le
conteneur des bases (`.database-table-container`) passe à `margin: 0` : le
tableau est collé à gauche, au ras de la marge, au lieu d'être centré dans la
page. `/trash` utilisait la classe `.lib-container` dont la règle vivait dans
le `<style>` de library.html : rendue hors Library elle était pleine largeur —
la règle est remontée dans `app.css` (globale). La page `/db` (`.db-index`)
était à 1200 px, alignée à 1100 px.
- **Le side peek de Library n'affichait plus le contenu du document** — deux
défauts cumulés. L'`<iframe>` vivait dans un `<template x-if="peekItem">` et
portait un binding `:src` : le build CSP d'Alpine n'évalue pas ce type
d'expression, si bien que le `src` restait vide et que le panneau affichait un
document blanc. L'iframe est désormais un élément permanent, hors du template,
dont le `src` est posé en JavaScript impératif — le motif déjà employé par les
aperçus du workspace local.
- **Ouvrir un autre document dans le peek laissait le précédent affiché** —
comme l'URL ne changeait pas, le navigateur ne rechargeait pas l'iframe. Le
peek passe maintenant par `about:blank` avant de charger le nouveau document,
et `closePeek()` décharge l'éditeur : il ne reste plus en mémoire, son autosave
ne tourne plus, alors qu'il est invisible.
- **Le side peek affichait une version allégée de l'éditeur** — il chargeait
`/pages/{id}?embed=1`, qui masque sidebar, barre d'outils et actions. Il charge
désormais la même page que l'ouverture plein écran, dans les quatre peek du
site (Library, My Tasks, bases de données, workspace local).
- **Le peek des bases pouvait ouvrir un document sans rapport** — quand la
résolution de la ligne échouait, `resolveRowContentPage` retombait sur
`return rowId` : un id de ligne de base n'est PAS un id de page, la page
ouverte portait donc simplement le même identifiant — exactement le piège que
le commentaire du fichier décrivait deux lignes plus haut. L'échec est
désormais signalé, et le message est rendu **dans le panneau** (et non via
`toast()`) : sous le build CSP d'Alpine, aucun composant n'est initialisé et
`window.showToast` reste sans effet.
- **Une iframe `src=""` se résout en l'URL courante** — sur une page de base, le
navigateur chargeait une **copie complète de la page** dans l'iframe cachée, et
la fermeture du peek (`iframe.src = ''`) rechargeait la page entière dans le
panneau. Le gabarit et la fermeture utilisent désormais `about:blank`.
### Known issues (préexistants, hors périmètre)
- Sous le build CSP d'Alpine (`alpine.csp.min.js`, v3.17.4), les expressions
`x-data` inline ne sont pas évaluées : `Alpine.$data(el)` ne renvoie aucune
clé sur les cinq éléments `[x-data]` d'une page. Conséquence directe : les
**toasts ne s'affichent nulle part**, car `window.showToast` (`base.html`) lit
`el.__x`, propriété du build de développement. `refreshSidebarFromEvent` et
le repli de la sidebar (`app.js`) relèvent du même défaut. Constaté en
vérifiant le message d'erreur du peek ; non traité ici.
## v7.48.0 (2026-10-05) — My Tasks aligné sur Library, side peek, navigation SPA fiable, Ctrl+S
### Added
- **Side peek sur My Tasks** — le bouton « Open » de chaque tâche ouvre le
document dans le **panneau latéral** de la page Library (`/pages/{id}` dans une
iframe), au lieu de quitter le tableau de bord. Panneau redimensionnable,
fermeture par `✕` ou `Escape`, bouton « Ouvrir dans une page entière ».
- **Ctrl/Cmd + S dans l'éditeur** — sauvegarde explicite du document en cours
d'édition (même `save()` que l'autosave, donc même file hors-ligne). Sans lui,
le navigateur ouvrait sa boîte « Enregistrer la page ». Inactif sur une page
verrouillée (`read-only`), et seulement quand l'éditeur est réellement monté :
`window.E` survit aux navigations partielles, la touche aurait donc pu
sauvegarder un éditeur détaché depuis une autre page.
### Changed
- **My Tasks adopts le gabarit de Library** — conteneur centré 1100 px, en-tête
`h1` 700 + compteur à droite, onglets et outils de part et d'autre, lignes en
`div` (et non `<table>`), hauteur 38 px, survol `var(--bg-hover)`, et palette
entièrement prise sur les tokens du thème.
### Fixed
- **Bouton d'ouverture visible seulement au survol** — il reprend exactement le
`.btn-open` / `.hover-only` de Library (même SVG, même libellé « Open », même
révélation `opacity`), et reste visible sur écran tactile (`@media (hover: none)`)
où il n'y a pas de survol.
- **My Tasks vide ou a composants blancs au retour de navigation** — trois
défauts distincts, tous dans le chemin de navigation partielle :
- `fdLoad` **ignorait silencieusement** un clic quand une navigation était déjà
en vol (`if (loading) return;`) : ni l'URL ni le contenu ne changeaient. Si la
promesse htmx ne se résolvait jamais, `loading` restait à `true` **définitivement**
et plus aucune navigation ne repartait — page figée jusqu'au hard refresh. Le clic
abandonne maintenant la navigation en vol par un chargement complet, et une
sentinelle de 15 s garantit que `loading` ne peut plus se bloquer.
- Le compteur de scripts de la zone swappée écoutait `load` **en capture sur
`document`**, donc voit tous les `<script>` de la page : le premier script
sans rapport suffisait à déclencher le montage Alpine **avant** que les scripts
de la page n'aient tourné (composants affichés à l'état brut). Il ne compte
plus que les nœuds réellement attendus.
- `my_tasks.css` était liée **dans** `.main-wrapper` : un `<link>` dans la zone
swappée peut être retiré par htmx, et la page revenait alors sans aucune feuille
de style — d'où les `<select>` / `<input type="date">` blancs en thème sombre.
La feuille est désormais liée par `base.html` (le shell, jamais swappé) ; les
deux injections par page ont été retirées.
- **My Tasks se remonte s'il est resté vide** — une coquille montée mais vide est
le symptôme d'un swap interrompu : le tableau de bord se remonte plutôt que
d'afficher du vide. Les délégations d'événements ne sont posées qu'une fois par
nœud (`__fdWired`), donc aucun double gestionnaire.
## v7.47.0 (2026-10-04) — My Tasks façon Notion : 3 vues, sources opt-in, mapping explicite
> My Tasks devient un poste de travail **transverse** (comme l'onglet *Home*
> de Notion) : toutes vos tâches, **tous workspaces confondus**. Une base
> n'y alimente plus qu'après **conversion explicite** (« Convertir en base de
> tâches ») avec le mapping de ses trois colonnes requises — et il ne peut y
> en avoir plus de 10.
### Added
- **Sources opt-in avec mapping explicite** — migration 30 : `collections`
porte `task_assignee_prop` / `task_status_prop` / `task_due_prop`. La
conversion est explicite (`POST /db/{id}/task-db/api`) : chaque rôle est
**lié** à une colonne existante du bon type, ou **créé** avec le type
attendu (`person` / `status` / `date`). Une base connectée au mapping
incomplet est listée mais **n'émet aucune tâche** — l'UI le signale au lieu
de deviner une colonne. Suppression du mapping à la déconnexion (plus de
références mortes), et la limite de **10 bases** est refusée côté serveur.
- **Trois vues** — tableau (colonnes redimensionnables non, mais statut et
échéance éditables au clic), **kanban** glisser-déposer (les colonnes sont
les options de statut de la base d'origine) et **calendrier** mensuel.
- **Filtres et tris globaux** — échéance (toutes / aujourd'hui / en retard /
cette semaine, borné à J+7), « masquer les terminées », tri par échéance,
statut, source ou création.
- **Étiquette de source** sur chaque tâche (base d'origine + workspace),
cliquable vers la base.
- **Modification en direct** — `PUT /my-tasks/api/task/{id}` écrit le champ
demandé (`status`, `due`, `title`, `assignee`) dans **la colonne mappée de
la base source**, puis relit et renvoie la tâche normalisée. Désassigner
une tâche la fait sortir de My Tasks (`left_mytasks`).
- **Ajout rapide multi-destinations** — `POST /my-tasks/api/task` crée la
tâche dans la base choisie dans la modale, **auto-assignée** (sans colonne
« Assigné à » renseignée, elle n'apparaîtrait jamais dans « mes tâches »).
- **Modale de conversion** dans la barre de la base (bouton `✓`) :
`static/js/task_db_link.js` + `static/css/my_tasks.css`.
### Changed
- **Périmètre transverse** — `flowdeck_workspace` ne cadre plus My Tasks
(il reste lu par la sidebar). Le payload `/my-tasks/api` expose `tasks`,
`sources`, `sources_total`, `max_sources` et `filters` ; **plus de
`workspace_id`** (le contract précédent est modifié, pas additionsnel).
- **`PUT /db/{id}/toggle-task/api`** reste rétrocompatible mais délègue à la
conversion complète : il ne pose plus `is_task=1` seul, ce qui créait une
source « connectée » incapable d'émettre quoi que ce soit.
- **Rendu client** — la page HTML porte la configuration (`data-config`) et
charge `static/js/my_tasks.js` ; sans aucune source connectée, le serveur
rend l'invite à convertir une base et ne charge pas le script.
### Fixed
- **`/db` était un dump JSON de debug** — la page renvoyait un `<pre>` hors
layout, sans session requise : « Ouvrir mes bases » (bouton de l'état vide
My Tasks) menait à un écran vide. `app/routers/collections/index_page.py`
la remplace par une vraie page dans le layout de l'app : les bases **du
workspace courant**, leur état (« Base de tâches » / « À configurer »), le
nombre de lignes, et ce qu'il reste à faire. Les bases à la corbeille n'y
sont pas proposées.
- **Bouton de conversion introuvable** — c'était un `✓` nu, sans libellé.
Il porte maintenant le libellé de son état réel : « Convertir en base de
tâches », « ⚠ Configurer » ou « ✓ Base de tâches ».
- **L'invite My Tasks renvoyait vers un bouton « … » qui n'existe pas** — le
message indiquait le menu `…` de la page alors que le bouton est dans la
barre de la base, à gauche de `New`. Texte corrigé, et un bouton
« Configurer » apparaît sur chaque source mal configurée.
- **Kanban et calendrier vidaient la page** — un clic sur l'onglet rendait la
vue, puis l'intercepteur de liens de `app.js` (`shouldIntercept`) traitait le
**même** clic et relançait un chargement complet : le DOM rendu était
remplacé par un `#my-tasks-app` vide et l'écran devenait blanc. Le clic est
désormais marqué `data-no-nav` sur les onglets, et `shouldIntercept` ignore
tout événement déjà traité (`defaultPrevented`) — plus aucun composant ne
peut plus être intercepté par la navigation SPA.
- **`/db` n'avait aucune feuille de style** — `my_tasks.css` (qui porte les
règles `.db-*`) n'était liée que par `_database_table_scripts.html`, jamais
par la page `/db` : les cartes, sections et états vides s'affichaient sans
aucun style. La feuille est maintenant liée explicitement.
- **My Tasks ignorait le thème sombre** — le CSS utilisait `--bg-card`, une
variable **inexistante** dans `app.css` : le repli `#fff` rendait les cartes,
onglets et panneaux blancs en mode sombre. Remplacée par `--bg-primary`, qui
suit le thème (mesuré : `rgb(25,25,25)` en sombre, `rgb(255,255,255)` en
clair).
- **États vides non centrés** — l'invite « aucune base connectée » et les états
vides de `/db` étaient un bloc de texte serré à gauche, en styles inline.
Gabarit centré commun (icône ronde, étapes numérotées, bouton), sans style
inline, donc aligné sur le thème ; `/db` recentre son en-tête et sa section
« Autres workspaces ».
- **My Tasks « non connectée » n'avait aucune feuille de style** — `_render_shell`
construisait le bloc `<link>` + `<script>` dans le même mini-template, or
seul le script était conditionné à `app_js`. C'est précisément le cas
« aucune base connectée » : la page perdait `my_tasks.css` et l'invite
retombait en texte brut aligné à gauche, sans les états vides centrés. La
feuille est désormais liée **toujours**, seul le script reste conditionnel.
- **« file Tache 1 » / « file Tache 2 » sous la colonne Tâche** — ce n'était
pas un libellé mais `collection_pages.icon`, valeur sentinelle historique
signifiant « pas d'icône » (le même filtrage existait déjà dans
`database_table.js`). `rowIcon()` la remplace désormais par `📄` dans le
tableau et le kanban.
- **Cellule « Tâche » collée au bord gauche** — `.mt-td-title` est un flex
et ne recevait pas le `padding` des autres cellules ; le titre commençait à
1 px de la bordure du tableau (mesuré : 13 px après correction).
- **Pas de bouton pour voir le contenu d'une tâche** — chaque ligne du
tableau porte désormais un bouton qui résout la page de contenu via
`GET /db/pages/{id}/open/api` (un id de ligne n'est pas un id de page, la
page est créée à la volée) puis ouvre l'éditeur. Si la résolution échoue,
on reste sur le tableau de bord et on remonte l'erreur : un repli vers
`/pages/{rowId}` ouvrirait une page **sans rapport** (piège déjà documenté
dans `database_table.js`).
- **My Tasks vide après navigation** — le script se gardait d'un drapeau
global (`window.__fdMyTasksLoaded`) : la navigation interne de `app.js`
remplace `.main-wrapper` par htmx **sans recharger le document**, le `<script>`
est rejoué mais sortait aussitôt du IIFE, et la nouvelle coquille restait
vide jusqu'au hard refresh. Le montage est désormais une fonction
rejouable, protégée par un drapeau **porté par l'élément**, rejouée sur
`htmx:afterSwap` et `popstate` (vérifié : deux aller-retours consécutifs
restituent tableau, barre d'outils et onglets).
- **Test dépendant de l'heure** — `test_today_follows_the_user_timezone`
supposait que le jour UTC soit toujours différent du jour en UTC-7, ce qui
n'est vrai que de 00h à 10h UTC : il échouait 13 heures sur 24. Le cas est
maintenant traité explicitement au lieu d'être supposé faux.
- **Bases portées par un document invisibles (`workspace_id` NULL)** — une base
née d'une page (page convertie en base, base inline, base liée, modèle
importé) n'a pas de `collections.workspace_id` : elle n'appartient qu'à travers
sa page hôte. Le filtre `c.workspace_id = ?` et le `JOIN workspaces` de
`list_task_sources` ne la trouvaient jamais, donc `/db` l'ignorait **et** elle
n'alimentait pas My Tasks — la base était visible dans la sidebar mais
introuvable et non convertible. Le rattachement passe désormais par le
workspace **effectif**, `COALESCE(workspace_id, workspace de la page hôte)`
(`collection_lifecycle.effective_workspace_sql`), utilisé par
`live_collection_ids`, `list_task_sources` et la page `/db`.
- **Fuite de bases entre utilisateurs sur `/db`** — la section « Autres
workspaces » faisait `JOIN workspaces` **sans filtre de propriétaire** : elle
exposait les bases de tous les utilisateurs de l'instance. Le périmètre est
maintenant `user_workspace_ids` (propriétaire **ou** membre). Une base sans
workspace rattachable n'appartient à personne et n'est plus listée nulle part.
- **Base à la corbeille qui alimentait encore My Tasks** — le filtre de
vivacité (`live_collection_ids`) était appliqué aux lignes mais pas aux
bases dans le nouveau service : une base dont la page hôte avait disparu
émettait toujours ses tâches (le bug v7.46.1, reintroduit par la réécriture).
- **`is_done` ne reconnaissait pas « Terminée »** — la liste de libellés
exacts ne couvrait ni le féminin, ni les accents, ni la casse. La
comparaison est faite sur le **premier mot normalisé** (minuscules, sans
accents) : « Terminée », « Complete », « Closed », « Cancelled » sont
reconnus ; « Clôture » ne l'est pas (un préfixe « clos » l'aurait fait).
- **« Cette semaine » n'était pas borné** — le filtre incluait toute tâche
datée, y compris dans six mois.
- **« Aujourd'hui » suivait UTC** — le projet stocke déjà `users.timezone`
(utilisé par le calendrier des bases) ; My Tasks l'utilise maintenant aussi
(`task_databases.user_today`), sans quoi une tâche du jour bascule dans
« demain » dès que l'utilisateur est loin d'UTC.
- **Colonne « Assigné à » écrasée par un booléen** — l'édition en direct
écrivait `bool(value)` au lieu de la liste de personnes, vidant la colonne.
### Tests
- `tests/test_my_tasks_workspace_coherence.py` — **32 tests** : périmètre
transverse, opt-in, mapping incomplet, validation des types et du double
emploi, limite de 10, assignataires, filtres/tri/états d'échéance, édition
en direct (statut, échéance, titre, désassignation), création rapide,
rendu des 3 vues, fuseau, et les cascades de suppression (corbeille, purge,
Empty Trash, workspace, base).
- `tests/test_app.py` — les 3 tests My Tasks alignés sur le nouveau contrat.
## v7.46.1 (2026-10-04) — My Tasks : la vue ne montre plus que le contenu vivant du workspace
> Signalé : « dans un workspace dont j'ai supprimé tous les documents, My
> Tasks affiche encore des tâches ». My Tasks listait **toutes** les
> `collections` du workspace sans vérifier qu'un document existait encore —
> or supprimer un document ne supprime pas sa base ni ses lignes.
### Fixed
- **Tâches orphelines** — `_collect_tasks` faisait
`SELECT * FROM collections WHERE workspace_id = ?` : une base dont la page
hôte avait été supprimée (corbeille ou purge) continuait d'afficher ses
lignes, dans un workspace vide côté sidebar. Les bases et les lignes sont
désormais filtrées sur la **vivacité de leur document**
(`app/services/collection_lifecycle.py` : `live_collection_ids`,
`live_row_ids`, `live_document_count`) — une page hôte absente ou à la
corbeille exclut la base, une page contenu à la corbeille exclut la ligne.
- **My Tasks réécrivait le cookie `flowdeck_workspace`** — un 302 +
`set_cookie` à chaque visite où le cookie différait : **ouvrir My Tasks
changeait le workspace actif de toute l'application** (sidebar, Home, arbre).
Le cookie n'est plus touché ; le workspace résolu est simplement celui du
contenu affiché.
- **Divergence de résolution du workspace** — My Tasks réimplémentait son
propre `_get_active_workspace_id` et ne comprenait pas le cookie
`gitea:owner:repo` écrit par la sidebar : il retombait alors sur
`owned[0]` et affichait les tâches d'un **autre** workspace que celui
affiché. Résolution mutualisée avec `dashboard._common._get_active_workspace`
(+ résolution du miroir Gitea local, comme `_sidebar_data`).
- **Sidebar désynchronisée du contenu** — le contexte de la sidebar venait du
cookie alors que le contenu venait du workspace résolu ; elle est
réalignée sur le workspace réellement listé (nom, `local_ws_id`, arbre).
- **État vide incohérent** — « Crée une collection » était proposé même
quand le workspace n'avait plus aucun document. Distinction désormais :
*workspace vide* (lien vers le workspace) vs *workspace sans tâche* (lien
vers `/db`).
- **Collections orphelines après suppression** — la suppression définitive
d'une page hôte (poubelle purgée, `DELETE /api/trash/{id}`, Empty Trash),
d'un workspace ou d'un utilisateur ne supprimait pas les bases : elles
restaient listées par `/db` et par My Tasks avec un `workspace_id` ou un
`parent_page_id` fantôme. Cascade appliquée sur ces 4 chemins
(`trash.purge_expired`, `board/sharing.permanent_delete`,
`board/sharing.empty_trash`, `dashboard/workspaces.delete_workspace`,
`admin.delete_user`).
- **`DELETE /db/api/{id}` levait un `IntegrityError`** dès que la base avait
une page hôte : `pages.collection_id` et
`collection_data_sources.source_collection_id` référencent `collections` en
`NO ACTION`. `delete_collections()` détache d'abord ces deux références
(la page hôte redevient une page blocs normale), puis supprime ; le reste
part en `CASCADE`. Les 5 chemins de suppression de collection en profitent
(`/db/api/{id}`, `/api/v2/collections/{id}`, sync, tool agent).
### Tests
- `tests/test_my_tasks_workspace_coherence.py` — **12 tests** : base à la
corbeille (tâches masquées **et** restaurables), ligne à la corbeille,
message « workspace vide » vs « sans tâche », suppression définitive /
purge / Empty Trash / suppression de workspace (base + lignes + pages
contenu emportées), détachement de la page hôte à la suppression d'une
base, cookie non réécrit, sidebar alignée sur le contenu, isolation entre
workspaces.
## v7.46.0 (2026-10-04) — Sidebar : navigation par onglets compressibles (Home / Chat / Meeting / Inbox)
> La rangée d'icônes du sidebar devient un **sélecteur d'onglet** : l'onglet
> actif s'étend en pilule avec son libellé, les autres se réduisent à leur
> icône, et chaque sélection **remplace intégralement** le contenu de la barre
> latérale. Ordre imposé : **Home → Chat → Meeting → Inbox**.
### Added
- **Onglet Meeting** — calendrier de productivité : filtres `Upcoming` /
`Today`, actions rapides (*Transcribe a podcast* : création de la page de
réunion + `POST /api/v2/meetings/transcribe` ; *New AI meeting note* :
commande `/meeting-note` de l'agent), et la liste chronologique des
événements.
- **`GET /api/v2/meetings/upcoming?days=30`** — nouveau endpoint authentifié
(session ou Bearer) qui agrège les collections calendaires liées de
l'utilisateur (`calendar_links`), extrait la propriété date de chaque ligne
et renvoie les événements du jour et des `days` jours suivants, triés, avec
`today` / `synced` / `provider` / `url`. Alias publics `date_prop_id` et
`row_date` dans `app/services/calendar_sync.py`.
- **Onglet Chat** — raccourcis circulaires vers les agents
(`GET /api/agent`) + historique chronologique des conversations
(`GET /api/agent/conversations`) regroupé en *Past 30 days* / *Older*. Un
clic ouvre le panneau agent **sur la conversation choisie** via le nouveau
`window.fdAgent.openConversation(id)`.
- **Onglet Inbox** — vrai centre de notifications (`GET /api/notifications`) :
bascule *All / Unread*, marquage lu au clic, *Mark all read*, état vide
*Inbox Zero — You're all caught up*. Badge non lus sur l'onglet (et compteur
dans l'en-tête du panneau quand l'onglet est actif).
- Persistance de l'onglet actif (`localStorage: fd_sidebar_tab`, valeur
validée contre la liste des onglets).
### Changed
- `sidebar-nav-row` : libellés désormais dans des `<span class="nav-tab-label">`
masqués sauf sur l'onglet actif (CSS `.nav-tab-btn`), badges de compte,
`role="tab"` / `aria-selected`. Les boutons Chat / Meeting / Inbox sont de
vrais `<button type="button">` (le bouton Inbox n'avait aucune action).
- Le contenu Home (arbre du workspace, sections, liens Library / My Tasks /
Trash / Help) est encapsulé dans le panneau `sidebarTab === 'home'` ; le
footer reste commun.
### Fixed
- **Clic sur Home → texte sur les quatre boutons** — la visibilité des
libellés ne dépendait que de `app.css`, or l'URL des assets statiques est
versionnée par le fichier `VERSION` (`?v=7.45.6`) : un navigateur conservait
donc la feuille de style d'avant la livraison, où les
`<span class="nav-tab-label">` n'étaient pas masqués. La décision est
désormais prise dans le **HTML** (`style` inline piloté par Alpine) pour les
libellés **et** les badges ; la CSS reste le repli sans JavaScript.
### Tests
- `tests/test_sidebar_tabs.py` — **15 tests** : ordre exact des onglets
(template **et** HTML rendu), accessibilité, un panneau par onglet, sources de
données réelles, persistance validée, pilule active / libellés masqués (CSS),
401 anonyme sur `/api/v2/meetings/upcoming`, filtrage *aujourd'hui et
futur*, exclusion du passé et de l'horizon, collection sans lien, validation
et clamp de `days`, cloisonnement par propriétaire de lien.
- `e2e/sidebar_tabs.spec.js` — **3 scénarios** : bascule des 4 onglets avec
mesure de largeur (pilule > icône) et vérification qu'**après le clic sur
Home seul Home garde son libellé**, appel réseau de
`/api/v2/meetings/upcoming`, et non-régression avec une CSS périmée servie
depuis le cache (le scénario échoue bien sur l'ancienne version déployée).
- Suite complète : **1182 tests verts**.
## v7.46.0 (2026-10-04) — Audit de fonctionnement : 10 anomalies corrigées
> Second volet de la v7.46.0 (le premier volet, web tools + 11 skills, est
> détaillé plus bas). Audit réalisé sur l'instance locale : 677 routes, 280
> endpoints GET sondés anonymement, 64 templates/JS croisés avec les routes,
> crawl Playwright authentifié, intégrité SQLite. **10 anomalies** trouvées,
> toutes corrigées et verrouillées par **34 tests** dédiés
> (`tests/test_anomalies_v746.py`). Suite complète : **1167 tests verts**.
### Security
- **Écritures anonymes closes** — `POST/DELETE /api/workspaces`,
`POST /api/workspaces/{id}/select`, `PUT /api/workspaces/{id}` et les
endpoints `local-workspace/items` (create/rename/delete/restore/move)
répondaient **200 sans aucune session** et écrivaient au nom de
l'utilisateur 1 (`uid = user["id"] if user and user.get("id") else 1`).
Un visiteur anonyme pouvait donc créer un espace, **renommer** et
**supprimer** n'importe quel espace de travail. Ces routes passent par
`_require_user_id()` et un nouveau `_require_ws_owner()` (propriétaire ou
admin global, sinon 403 ; workspace inexistant → 404).
- **Fuite de données via `/workspace/*`** — le router legacy
`app/routers/workspace.py` (relique v2.0.0) n'avait aucun garde :
`GET /workspace/collections/{id}/export/csv` renvoyait **le contenu réel**
d'une collection à un visiteur anonyme (titres, tâches, assignations), de
même que l'historique de page, les commentaires, les sprints et les
templates. Le router porte maintenant une dépendance
`Depends(_require_session)` ; **la seule route publique**
(`/workspace/public/{collection_id}`, page de partage) est déplacée dans un
`public_router` sans dépendance et reste accessible.
- **`password_hash` exposé** — `GET /auth/user` renvoyait la ligne `users`
complète, **sel et hachage PBKDF2 compris**, et le cookie de session
(signé mais **non chiffré**) embarquait la même donnée en base64. Nouveau
`app.auth.session.public_user()` appliqué dans `create_session`,
`refresh_session` et `/auth/user`.
- **OAuth Gitea cassé** — `gitea_oauth_client_id`/`_secret` valent les
placeholders `test-id`/`test-secret` dans `app/config.py`, valeurs non vides
donc considérées comme « configurées » : `GET /auth/login` redirigeait vers
Gitea avec un `client_id` invalide (échec d'authentification garanti).
`app/auth/providers` traite désormais les placeholders comme « provider non
configuré » → page « non configuré » avec lien vers le login local.
- **Synced blocks** — `GET /board/api/synced-blocks` levait
`AttributeError: 'NoneType' object has no attribute 'get'` (**HTTP 500**) en
accès anonyme ; `PUT`/`DELETE /board/api/synced-blocks/{sid}` n'avaient
**aucun contrôle de propriété** (un utilisateur connecté pouvait modifier le
bloc d'un autre). Ajout de `_session_user()` (401) et
`_assert_can_edit_block()` (auteur ou admin, sinon 403).
### Fixed
- **Bouton « Home » de la sidebar** — il pointait sur
`/local-workspace?ws={{ local_workspaces[0].id }}`, c'est-à-dire le **premier
espace trié par nom** et non l'espace courant : cliquer Home depuis
`NewWS` basculait vers `allo` (et inversement), ce qui donne l'impression que
« tout le site s'affiche d'un coup ». Les deux builders de sidebar
(`dashboard/_common.py`, `board/_common.py`) exposent désormais
`local_ws_id` = **ID de l'espace actif** (le miroir Gitea a été séparé dans
`gitea_mirror_ws_id` pour ne plus écraser cette valeur), et `base.html`
privilégie `local_ws_id` avant `local_workspaces[0]`.
- **`GET /api/v2/agents/conversations` → 422** — la route paramétrée
`/agents/{agent_id}` était enregistrée **avant** et capturait le segment
`conversations`. Les trois routes passent en `{agent_id:int}` : un
identifiant d'agent est un entier, le segment statique garde sa route.
- **Sélecteur de blocs synchronisés → 404** — l'éditeur de page appelait
`/api/synced-blocks` alors que les routes sont sous `/board/` :
`static/js/page_editor_scripts.js` utilise maintenant
`/board/api/synced-blocks`, et le message d'aide ne renvoie plus vers une
section « Settings → Synced Blocks » **inexistante**.
- **`/workspace/favorites` → 500 permanent** — `sqlite3.OperationalError: no
such column: f.collection_id` : la migration v2.2.0 (`app/db.py`) avait
supprimé `favorites.collection_id` et repointé `page_id` vers `pages(id)`,
mais le handler lisait encore l'ancien schéma (jointure `collection_pages`).
Requête et insertion alignées, `page_id` désormais obligatoire, suppression
restreinte au propriétaire du favori.
### Changed
- **Jinja : plus de silence** (`app/templating.py`) — une variable absente du
contexte rendait `""` sans aucune trace, ce qui avait masqué le bug du
bouton Home. `StrictUndefined` est inutilisable ici (40+ templates
optionnelles, p. ex. `embed_mode`), donc `ENV.undefined` vaut désormais
`LoggingUndefined` : **rendu identique, mais un `WARNING` journalise le nom
de la variable manquante**.
- `.env.example` : `GITEA_OAUTH_CLIENT_ID` / `GITEA_OAUTH_CLIENT_SECRET`
documentés (laisses vides = provider Gitea considéré comme non configuré).
### Tests
- `tests/test_anomalies_v746.py` — **34 tests** : absence de `password_hash`
(endpoint + cookie), placeholders OAuth (non configuré vs configuré), 9
écritures anonymes refusées en 401, propriété des espaces (403 + état en
base inchangé), 7 lectures `/workspace/*` en 401, partage public toujours
accessible, round-trip favorites, ordre de routes agents, URLs synced blocks
(fichier), 401 anonyme sur les synced blocks + 403 pour un autre
utilisateur, bouton Home (template **et** HTML rendu sur l'espace actif),
`LoggingUndefined`.
## v7.46.0 (2026-10-04) — Web tools de l'agent (recherche web, lecture de page, GitHub) & 11 nouveaux skills
### Added
- **3 nouveaux tools agent** — l'agent pouvait chercher dans FlowDeck et dans
Gitea, mais **pas sur le web**. `app/services/tool_registry.py` passe de 22 à
25 tools :
- `web_search` — recherche web, résultats toujours sourcés (titre + URL +
extrait) pour que le modèle cite au lieu d'inventer ;
- `fetch_url` — télécharge une page et la convertit en Markdown (titres,
listes, code, tableaux) ;
- `search_code` — GitHub `repositories | code | issues`, fonctionnel sans jeton
(10 requêtes/min) et à 30 avec `GITHUB_TOKEN`.
- **`app/services/web_search.py`** — couche réseau des deux tools de recherche :
provider **Exa** (sémantique + extraits) avec repli **DuckDuckGo** sans compte,
`shared_client` pour le pool de connexions, **aucune dépendance ajoutée**, URLs
du moteur filtrées des résultats, `transport` injectable pour les tests.
- **11 nouveaux presets de skills** (`app/services/skill_gallery.py`, 6 → 17) :
- locaux : `veille-documentaire`, `chasse-decisions`, `onboarding-nouveau`,
`audit-base`, `sprint-review` ;
- web : `recherche-marche`, `veille-techno`, `debug-web` (obligation de
sourcer : `[titre — URL]`, sinon « non vérifié ») ;
- pouvoir : `triage-incident`, `nettoyage-base` (aperçu avant toute écriture),
`briefing-quotidien`.
- **Config** — `WEB_SEARCH_PROVIDER` (`exa` | `duckduckgo`), `EXA_API_KEY`,
`GITHUB_TOKEN`, `WEB_FETCH_MAX_CHARS` (`app/config.py` + `.env.example`).
- **Libellés d'action** du journal agent pour les 3 tools (Recherche web
effectuée / Page web lue / Recherche GitHub effectuée).
### Security
- **`fetch_url` est protégé par SSRF** : schémas `http(s)` uniquement, résolution
DNS puis refus des IP loopback, privées, lien-local, réservées et multicast,
et **re-validation à chaque saut de redirection** — une URL publique ne peut
pas rediriger vers `169.254.169.254`. Il réutilise les garde-fous déjà éprouvés
par `importers/url_fetch` et `og_fetcher` plutôt qu'un contrôle maison.
- `search_code` ne touche que l'API publique GitHub (lecture seule) ; le
message d'erreur 403 renvoie explicitement vers `GITHUB_TOKEN`.
### Tests
- `tests/test_agent_web_tools.py` : **29 tests**, sans aucun accès réseau
(`httpx.MockTransport` + résolution DNS neutralisée — les IP littérales
passent par la résolution réelle pour que le refus des adresses privées soit
testé pour de vrai) — registre et périmètre des schémas, cohérence
galerie/registre, cycle export→import des presets web, provider + repli +
filtrage anti-bot, extraction Markdown, troncature, **6 vecteurs SSRF refusés**
(localhost, 127.0.0.1, metadata cloud, IPv6 loopback, `file:`, `ftp:`),
redirection vers metadata cloud refusée, redirection publique suivie, GitHub
repos/issues/quota.
- Non-régression : **suite complète 1126 tests verts** (`pytest -n auto`).
## v7.45.6 (2026-10-03) — Fenêtre de confirmation thématisée pour les suppressions de la poubelle
### Changed
- **Les 3 confirmations destructives de /trash** (suppression définitive
unitaire, suppression groupée, **Empty Trash**) remplacent le `confirm()` du
navigateur par une **fenêtre thématisée FlowDeck** : classes
`flowdeck-modal-overlay` / `.flowdeck-modal` déjà présentes dans `base.html`
(variables `--bg-secondary`, `--border`, `--accent`, rayon 12 px), overlay
cliquable pour annuler, **Échap** pour annuler, bouton Cancel + bouton de
confirmation coloré porteur du libellé exact (`Delete` / `Delete all` /
`Empty Trash`), titre + message reprenant le nom de la page ou le nombre de
pages concernées, `role="alertdialog"` + `aria-modal`.
- Aucun `confirm()` natif ne subsiste dans `trash.html` — le composant est
monté dans `trashData()` (`askConfirm` / `cancelConfirm` / `acceptConfirm`,
mutations de propriétés individuelles pour la réactivité Alpine).
### Tests
- `e2e/trash_ui.spec.js` : la fenêtre est ouverte, son titre et son message
vérifiés, **l'annulation est vérifiée** (page toujours présente), puis la
confirmation (suppression réelle : plus dans la poubelle + contenu effacé) et
enfin la purge via Empty Trash. Le handler `dialog` du test échoue
maintenant si un `confirm()`/`alert()` natif revient (`NATIVE_DIALOG`).
## v7.45.5 (2026-10-03) — Poubelle : actions groupées, tri/filtres réels, purge, et 4 bugs
### Added
- **Sélection multiple** sur /trash : case « Select all » (portée au filtre
courant), barre d'actions groupées **Restore / Delete / Clear** avec compteur,
et compteur de résultats (`N pages` / `N of M pages`).
- **Empty Trash** : purge totale en un clic (confirm) via le nouveau
`POST /board/api/trash/empty` — traitement en masse côté serveur (une seule
requête, pas N appels).
- **Tri** : Recently deleted (défaut) / Oldest first / Page name A → Z — remplace
le bouton décoratif « Last edited by ▾ » qui ne faisait rien.
- **Filtre par emplacement** réel (`In ▾` décoratif remplacé) : liste déroulante
construite depuis les workspaces présents dans la poubelle, avec état actif.
- **Dates par élément** : « Deleted 3 days ago · 27 days left » (rétention
30 j alignée sur `app/services/trash.py`), horodatages UTC ou ISO gérés.
- **États vides distincts** : poubelle vide vs aucun résultat pour les filtres
(avec bouton « Clear filters »), et **toasts** sur chaque action (succès et
échec) au lieu d'échecs silencieux.
### Fixed
- **Les boutons Restore / Delete ne marchaient JAMAIS** : `getCsrf()` renvoie la
CHAÎNE du jeton mais le code faisait `csrf?.[1]` → 2ᵉ caractère → 403 CSRF
systématique, avalé par `if (r.ok)`. Corrigé + toasts d'erreur.
- **« Move to Trash » depuis l'éditeur envoyait une 404** : la route appelée
(`/board/api/pages/{id}/trash`) n'existe pas — route réelle
`/api/pages/{id}/trash` — et le `.then()` naviguait quand même, donc la page
partait à l'accueil SANS être mise à la poubelle. URL corrigée + on ne
navigue plus si la réponse n'est pas ok.
- **Une page restaurée disparaissait de Library / Recents / Private** : la
suppression depuis l'éditeur réécrivait `parent_section='Trash'` et la
restauration ne le remettait pas, alors que tous les listings filtrent
`parent_section != 'Trash'`. `deleted_at` est maintenant la seule source de
vérité (écriture retirée) + réparation au boot des lignes déjà cassées
(`db.init_db`, idempotente).
- **Routes trash sans aucune authentification** : `GET /board/api/trash`,
`restore`, `delete` et `POST /api/pages/{id}/trash` ne vérifiaient rien (le
CSRF ne protège pas : cookie lisible + en-tête forgé par l'attaquant) → 401
obligatoire, comme les routes sœurs du même fichier. Vérifié anonyme : 401.
- Le sidebar de /trash listait `parent_section='Trash'` → aligné sur
`deleted_at IS NOT NULL`.
### Tests
- `tests/test_trash_api.py` (5 tests) : auth 401, soft-delete sans marqueur
`Trash`, restauration, suppression définitive, purge en masse (ne touche pas
aux pages vivantes), réparation au boot.
- `e2e/trash_ui.spec.js` : rendu, dates, sélection groupée, restauration
unitaire (le vrai test du fix CSRF), purge — noms uniques par exécution et
garde « on ne vide jamais la poubelle d'autrui ».
- `test_smoke_uncovered.py` : l'assertion qui codait le bug
(`parent_section == 'Trash'`) corrigée.
## v7.45.4 (2026-10-03) — Anti-FOUC au chargement complet + morts nettoyés
### Fixed
- **Flashs au chargement COMPLET** (F5, première visite, navigation servie par
le service worker) : le masquage `x-cloak`/`opacity` de v7.45.3 ne couvrait
que les swaps partiels fdLoad — un chargement de document peignait la page
tant qu'Alpine n'avait pas initialisé `.app-layout`, donc sidebar brute (toutes
sections ouvertes, menu utilisateur visible) **et** contenu de la zone dont la
bande rouge « You are offline. Changes will sync when connection is
restored. » (aucun `x-cloak` sur elle). Fix : `x-cloak` sur le nœud racine
Alpine `.app-layout` (base.html) — tout l'app (sidebar + zone + bandeau) reste
masqué tant que `appState()` n'est pas monté, puis Alpine retire l'attribut.
Vérifié par le probe (3 pages × plein chargement) : 0 frame de contenu brut,
`x-cloak` retiré et `display:flex` à la fin (donc pas de page blanche).
### Changed
- **`fd-navigating` supprimée** : classe posée/retirée à chaque `fdLoad` mais
sans aucune règle CSS dans le dépôt (grep css/js/html → seulement app.js) —
code mort.
- **`e2e/probe_nav_perf.spec.js` étendu à 4 scénarios avec assertions** :
navigations partielles (5 pages), **clic réel sur le bouton Home**, Home avec
réseau coupé (SW actif), et plein chargement. Asserte : 0 frame de contenu
brut peint, zone révélée (`opacity:1`) et montée (`0 [x-show]` non montés,
`x-ignore` absent), `.app-layout` sans `x-cloak` et visible. Capture
screenshot automatique si du contenu brut est peint + collecte console.
### Not reproduced
- Le symptôme rapporté (Home → rafale de menus/fenêtres + bande rouge) n'est
**pas rejouable** dans un navigateur frais : clic Home en ligne comme hors
ligne, nav partielle et plein chargement donnent 0 frame de contenu brut et 0
erreur console. Reste à confirmer côté navigateur signalé (recharge dure
après deploy, URL/instance testée).
## v7.45.3 (2026-10-03) — Navigation partielle : le contenu brut n'est plus peint
### Fixed
- **Flashs de fenêtres / frames à chaque changement de page ou de section** :
pendant la fenêtre `x-ignore` (swap → `Alpine.initTree`), la zone
`.main-wrapper` était peinte **non montée** — tous ses `[x-show]` visibles à
leur valeur brute (menus, panneaux, états loading/empty/table superposés), puis
masqués d'un coup au montage Alpine. Mesure (`e2e/probe_nav_perf.spec.js`,
instance locale) : **19 éléments bruts visibles sur 87 ms** en arrivant sur
/library (CLS 0.149, sources `lib-loading`/`lib-empty`/`lib-table`), **80
éléments sur 732 ms** sur /settings, 7 sur /workspaces, 0 sur /trash. Fix :
`opacity: 0` + `pointer-events: none` posés **dans** le handler
`htmx:afterSwap` (même task que le swap → aucun paint intermédiaire possible)
et retirés **après** `Alpine.initTree` sur les 3 chemins de démontage (scripts
chargés, zéro script, filet 4 s). Seul le contenu monté est peint.
### Added
- **`e2e/probe_nav_perf.spec.js`** — probe de fluidité de navigation : marques
htmx (`beforeRequest`/`beforeSwap`/`afterSwap`/`afterSettle`), fenêtre
`x-ignore`, échantillon par frame du nombre d'`[x-show]` bruts encore
visibles et d'`[x-cloak]` masqués, `layout-shift` (CLS) avec sources.
`node node_modules/@playwright/test/cli.js test probe_nav_perf` (npx est cassé
sur ce poste).
## v7.45.2 (2026-10-03) — Navigation partielle : montage Alpine sans course + scripts idempotents
### Fixed
- **Cascades d'erreurs Alpine en navigation partielle** (« Undefined
variable: … » puis « reading 'has' » sur `$store.fdCtx`) : sur un swap
fdLoad, les `<script src>` de page s'exécutent APRÈS le microtask
MutationObserver d'Alpine → toute la zone `.main-wrapper` s'initialisait
avant que composants ET stores (menu contextuel) existent, et le
démontage anticipé dès le premier script chargé perdait la course avec
les autres. Fix : `x-ignore` sur **toute** la zone swapée au
`htmx:afterSwap` (uniquement quand le nœud `.main-wrapper` a été
remplacé — swaps sidebar/vues inchangés), démontage **unique** quand
**tous** les `<script src>` ont exécuté (load/error + filet 4 s) via
`Alpine.initTree` (idempotent grâce au `_x_marker`). Vérifié : 7 pages ×
(complet / 1ʳᵉ / 2ᵉ visite partielle) + éditeur (3 phases) = 0 erreur.
- **`SyntaxError: Identifier 'LW' has already been declared`** : tout
script de page ré-exécuté à la 2ᵉ visite partielle plantait entièrement
(`const` de haut niveau re-déclaré) → panneaux Alpine non montés.
Garde de fichier posé sur `local_workspace.js`, `board.js`,
`settings.js`, `database_table.js`, `page_editor_realtime.js` (pattern
déjà utilisé par `page_editor_scripts.js`).
- **Enregistrement Alpine différé** : `if (window.Alpine) Alpine.data(…
sinon listener 'alpine:init'` (l'événement ne sera plus jamais émis sur
swap) dans 8 scripts de page et 7 templates inline (`accounts`,
`card_detail`, `table_view`, `team_load`, `trash`, `welcome`,
`workspace`).
- **Polices Inter orphelines** : `app.css` référençait 7 fichiers
inexistants (`inter-400-latin.woff2`…) → 302 → HTML → « Failed to
decode downloaded font » sur chaque page. Blocs legacy supprimés (les
faces variable Inter 100-900 restent).
### Added
- **Gates E2E** : `e2e/regression_editor_mount.spec.js` (éditeur en 3
phases : partielle / complet / 2ᵉ partielle) et
`e2e/regression_partial_nav.spec.js` (7 pages × 3 passages + état
fdCtx/appState) —0 erreur Alpine attendue.
## v7.45.1 (2026-10-02) — Fix logout « hors ligne » (SW) + drag & drop upload 403
### Fixed
- **Logout → page « Vous êtes hors ligne »** : le fetch event de navigation
arrive en `redirect:'manual'` → une 302 du serveur se lisait
`opaqueredirect` (status 0) → `bad status` → filet offline. Tout logout
(et toute navigation redirigée) affichait la page hors ligne alors que le
backend répondait en ~10 ms. `networkFirst` rejoue désormais la requête en
`redirect:'follow'` et sert une **302 synthétique vers l'URL finale**
(Chromium refuse une response `redirected` servie à une navigation →
`net::ERR_FAILED`). Bonus : `timeoutFetch` annule réellement la requête
(l'`AbortController` créée dans le `setTimeout` n'était pas branchée sur
`fetch`).
- **Drag & drop de fichiers/dossiers en échec silencieux (403 CSRF)** :
`_doUpload()` (POST `/api/local-workspace/upload[-folder]`) et
`toggleFavorite()` du local workspace n'envoyaient pas `X-CSRF-Token` —
les 2 derniers appels mutants du front oubliés par A19 (exemption CSRF
retirée, balayage complet des `fetch` mutants refait).
- **Gate E2E** : `e2e/regression_logout_dnd.spec.js` enregistre les 2 bugs
(logout sous SW → page login, drop de fichier → 200 + création, avec
nettoyage).
## v7.45.0 (2026-10-01) — Éditeur visuel d'automations + correction CSP (multi-instructions)
### Added
- **Pipeline visuel (steps) dans Settings → Automations** : cartes
ordonnées par étape avec badge + résumé (`📡 Déclencheur · event`,
`⚖ Condition · prop op val`, `⏳ Attente · Xs`, `⚡ Action · type → …`),
**éditeur typé par kind/type** (événements en datalist, 7 ops de
condition, 8 types d'action avec leurs champs réels : url, property/value,
collection/title, message, webhook_url/text, to/subject/body,
owner/repo/labels, agent_id/message), ajout/édition/suppression/**↑↓** via
`POST/PUT/DELETE /workspace/automations[/steps]/…`, conversion legacy
**✨ Convertir le JSON en pipeline** (trigger + conditions + actions
ordonnés) ; les textareas JSON disparaissent dès qu'un step existe.
- **Gate E2E** « éditeur visuel de steps » : création → édition → ajout
d'étape → carte `Action · webhook` visible (sous CSP réel).
### Fixed (trouvé par le gate)
- **51 expressions Alpine multi-instructions** (`activeSection='x';
loadX()` etc.) = **interdites par le parseur CSP** (une seule expression
par directive ; `;` = token inattendu) — invisible pour le scan par
tokens ! Conversion en **méthodes** : settings nav ×8 (`navTo`), menu de
section base ×7 (`closeAndSetCount/…`), parts/éditeur ×13
(`setSharePerm`, `more*`, `markAndSave`…), breadcrumb ×5
(`hoverEllipsis/goClose`), library/local ×6 (menus), board ×3
(`pickStatus/…`), ctx-menu ×2 (`addTagAndClear`), agent/card/gitea/
workspaces ×6. Scanner dédié `scan_semi` (inventaire `;` hors chaînes).
### Notes
- 39 templates Jinja parse OK, scan d'expressions = 0 incompatibilité
(4 faux positifs en chaînes), E2E **8/8**, suite **1094/1094**.
## v7.44.0 (2026-10-01) — Palette Ctrl+K : onglets Pages / ✨ Réponses IA
### Added
- **Palette `Ctrl+K` = 2 onglets** (markup + CSS + wiring dans l'IIFE) :
· **Pages** — comportement inchangé (recherche `/api/search` + actions).
· **✨ Réponses IA** — `POST /api/v2/search/ask` (debounce 150 ms,
CSRF via `getCsrf()`, gardes staleness onglet+requête) → rendu de
`answer_markdown` : **échappement AVANT injection**, `[[fdpage:ID]]` →
lien citation (ids = chiffres, type = `[a-z]+` — pas d'injection),
`**gras**`, bloc « Sources » avec liens `/pages/{id}` · `/db/{id}`.
États : hint / chargement / erreur.
- **Gate E2E étendu** (`gate A20 palette`) : ouverture → clic onglet IA →
réponse non-échaffée affichée (le backend tourne en extractif ou LLM —
probe : 200 en 1,6 s, 8 citations).
- **Reporté (backend absent)** : onglet `Fichiers` — `/api/search` ne
renvoie que `pages`/`collections` → endpoint à créer d'abord (ROADMAP).
### Notes
- Un 401 transitoire sur le 1er ask d'un run E2E a été observé (session
fraîche) — non reproductible ensuite ; à surveiller si ça revient.
## v7.43.0 (2026-10-01) — 🎉 A20 TERMINÉ : Alpine en build CSP, `unsafe-eval` retiré
### Changed (la bascule A20 phase 3)
- **`static/js/alpine.csp.min.js`** (build officiel `@alpinejs/csp`,
0 `eval`/`new Function`, parseur d'expressions maison) servi partout :
`base.html`, `import.html`, `welcome.html` + entrée `sw.js` (cache bump
v8).
- **CSP** : `script-src 'self' 'nonce-…'` — **`unsafe-eval` supprimé** (il
ne servait plus qu'Alpine standard). htmx : `allowEval: false` déjà posé
(v7.37).
- Assertion test inversée : `assert "'unsafe-eval'" not in script_src`.
- Scan statique final sur **tous** les templates : 0 expression incompatible
(4 résidus = faux positifs dans des chaînes de texte).
### Notes
- 12 surfaces migrées + gateées en amont (lots 1-3a/3b) ; **E2E 7/7 sous
CSP réel** (le route-swap du `csp_preview` sert désormais le même
fichier — les gates restent utiles contre une régression du build).
- board/table_view/teamload/card_detail : scan propre mais non gateés
(gitea down) → à vérifier au premier usage avec gitea remonté.
- Risque résiduel assumé : expressions n'ayant jamais tourné en runtime
sur ces 4 surfaces (les gates + le filet 0-erreur les attraperont).
## v7.42.0 (2026-10-01) — 🐛 BUG TOPBAR CORRIGÉ (boutons du header en texte brut)
### Fixed
- **Les `right_actions` du topbar étaient servis ÉCHAPPÉS sur TOUTES les
pages** (entities `&#34;`/`&lt;` → boutons Share/Star/Settings/Login en
texte brut). **Cause racine** : `{% set right_actions = '…' ~ fd_icon(…) ~ '…' %}`
— `fd_icon` est une **macro → `Markup`**, et `Markup.__radd__/__add__`
**échappe ses arguments `str`** → tous les segments littéraux sortent
entité-és ; le `|safe` de `_header:141` est then no-op sur un Markup déjà
échappé. Découvert en cherchant l'échec du gate éditeur CSP (v7.41.0),
reproductible partout (`curl /workspaces`).
- **Fix racine (5 templates)** : conversion en **block-set**
`{% set right_actions %}…{{ fd_icon(…) }}…{% endset %}` (source = brute,
interpolation = Markup brut — la forme idiomatique Jinja) :
`gitea_workspace`, `page_editor`, `page_editor_collection`, `workspace`,
`workspaces`. Piège du script : regex greedy multi-lignes = set avalé →
matcher **une ligne**.
- **Test permanent** `tests/test_topbar_right_actions.py` : `/workspaces`
doit contenir `class="topbar-btn"` parsé et ZÉRIE entité `&#34;`.
- Gate éditeur : l'assertion `.star-btn` **ré-ajoutée** (les boutons
rendent à nouveau).
### Notes
- Probable régression depuis A10 (activation d'autoescape) : les `~`
étaient des no-op avant, Markup.__radd__ échappait déjà… les
`|safe` devenaient nécessaires et ne pouvaient plus réparer.
## v7.41.0 (2026-10-01) — A20 phase 3 LOT 3b : gitea + agent + éditeur verts
### Changed
- **gitea_workspace** : `x-data="giteaWorkspace"` → appel `giteaWorkspace()`,
`new Date(…)` → `fmtGwDate(pp)`, x-html icône d'arbre → `bindGwIcon`
(x-init + `Alpine.effect`).
- **agent_panel** : x-html markdown → `bindMarkdown($el, m)` (effet réactif
sur `m.content`).
- **page_editor (les 12 sites `window.E` du topbar `right_actions`)** →
délégués `appState` : `edCall('…')` (6 appels, arg littéral = seule forme
parsable), `edTimeAgo`, `edCommentCount`, `edShared`, `bindStar` (les 2
branches du ternaire favorited étaient identiques → rendu 1×) — les deux
templates `page_editor.html` + `page_editor_collection.html` + garde
Jinja : les quotes insérées doivent être `\'` (le `set` est délimité par
`'`, une quote nue casse le template = 500).
- **_page_editor_content** : +3 sites (`window.E.commentOnSelection` →
`edCall`, `backlinksOpen…location.href` → `openBacklink(b)`,
`Math.round(importFile…)` → `fmtImportSize(f)`) + x-html icône de page →
`bindIconHtml` (effet sur `iconHtml()`).
- **Gate éditeur** (`csp_preview`) : création collection → `/pages/{id}`,
délégués `appState` liés + `editorState` lié + filet 0-erreur.
### Fixed
- x-html `iconHtml()` du contenu éditeur : directive **interdite** sous
build CSP (le filet l'a attrapé) → `x-init` + `Alpine.effect`.
### ⚠️ Nouveau bug pré-existant identifié (PAS introdui par ce lot)
- **Les `right_actions` du topbar sont servi ÉCHAPPÉS sur TOUTES les
pages** (entities `&#34;`/`&lt;` — les boutons Share/Star/Settings/…
s'affichent en texte brut). Reproductible : `curl /workspaces` →
`&#34;topbar-btn&#34;`. Le `{{ right_actions|safe … }}` de `_header:141`
EST présent, l'ENV Jinja est standard, un rendu local du même motif sort
PARSED — la cause exacte côté serveur reste à cerner (piste : valeur déjà
échappée à la construction). Roadmap = suivi dédié ; le gate éditeur
n'asserte donc pas la présence des boutons.
## v7.40.0 (2026-10-01) — A20 phase 3 LOT 3a : 5 surfaces de plus vertes
### Added
- **Gate `csp_preview` « surfaces simples »** : `/welcome`, `/trash`,
`/accounts`, `/workspace`, `/import` — **0 modification de code
nécessaire** sur les 4 premières (scan statique 0 expression/x-html +
registres Alpine.data posés au lot 1). **8 surfaces couvertes** au total
(base shell, library, settings, local workspace + celles-ci).
### Fixed
- **Bug pré-existant sur `/import` (visible sous les DEUX builds)** :
`x-text="'🔗 '+report.relations…"` évalué alors que `report = null`
(le `x-show` parent ne masque pas, il initialise quand même) → pageerror
« Cannot read property … 'relations' » — garde `report && report.relations`.
(Le probe montrait aussi un 401 console pré-existant sur la page — hors
périmètre, non touché.)
### Notes
- Reste ph3 : page_editor (12 sites `window.E` dans `right_actions`),
gitea_workspace (`new Date`), agent_panel (site `x-text` markdown +
1 x-html), board + table_view/teamload/card_detail (gabarits liés au
contexte Gitea, scan statique propre) → bascule réelle ensuite.
## v7.39.0 (2026-10-01) — A20 phase 3 LOT 2 : settings + local workspace verts
### Added
- **2 gates `csp_preview` de plus** : `settings` (composant lié, overlay
visible, 0 erreur) et `local workspace` (recherche focalisée via
`Alpine.nextTick`, chips filtre en SVG via `bindSvg`, 0 erreur).
**3 surfaces vertes** sous build CSP : library, settings, local workspace.
### Changed
- **settings** : `window.history.back()`/`new Date(…)` → méthodes
`historyBack`/`fmtLastLogin`/`fmtAuditDate` ; `?.` → ternaires.
- **local workspace** : `x-data="_wsInitData"` → registre `wsInitData()`
(le build CSP ne résout que le registre) ; 14 `x-html` → `x-init` +
`Alpine.effect` (`bindSvg`/`bindFileIcon`/`bindNodeIcon`/`bindChildren`/
`bindPreview`) ; `$nextTick`+`$refs` arrow → `toggleSearch()` ;
`window.FlowDeck.*` → `createPageAt`/`createFolderAt` ; `?.` → ternaires ;
`@contextmenu="_wsInitData.…"` → appel de méthode.
- **Partage d'état JS↔Alpine (piège du build CSP)** : `ji` = snapshot des
**valeurs** de toutes les propriétés `globalThis` au boot → l'objet mis
sur `window` avant Alpine est **banni** (« Accessing global variables is
prohibited »). Fix : objet porté par une **const lexicale** (non propriété
`globalThis`) + factory Alpine.data qui le retourne → **même objet**
partagé, réactivité intacte (une copie `Object.assign` aurait coupé les
mises à jour JS : preview, uploads, isDragging).
- **Bloc preview hors div racine** (structure pré-existante : le parseur
referme la racine avant, masquée par le fallback window d'Alpine
standard) → composant `wsPreview` **déléguant** vers `_wsInitData`
(`Alpine.reactive` pour la réactivité ; wrapper = objet unique, les
magics `$nextTick` ne sont redéfinissables qu'une fois — deuxième
montage du même objet = « Cannot redefine property »).
- **`.env` local : `RATE_LIMIT_REQUESTS=600`** — les rafales E2E
Playwright (5 tests × ~25 requêtes) butaient sur le 60/min par IP ;
défaut produit inchangé.
### Notes
- Reste ph3 : page_editor, board, agent_panel, import, gitea_workspace,
welcome/accounts/trash/team_load/workspace/table_view/card_detail →
puis bascule réelle (retrait `unsafe-eval`).
## v7.38.0 (2026-10-01) — A20 phase 3 LOT 1 : shell + library migres
### Added
- **`e2e/csp_preview.spec.js`** — aperçu CSP strict SANS déployer : le
build `@alpinejs/csp` (fichier officiel, `e2e/fixtures/alpine.csp.js`)
est servi **à la place** de `alpine.min.js` par interception Playwright ;
tout échec du parseur maison = `pageerror` (filet). **Première surface
verte : library** (composant lié, icônes SVG rendues via `Alpine.effect`,
recherche ouverte + focalisée, 0 erreur console/page).
### Changed
- **Composants `x-data="fn()"` → registre `Alpine.data(...)`** (15 +
`appState` + `libraryPage`) : le build CSP ne résout que le registre
(probe : globale window → `Undefined variable`) — scripts/classiques
chargés pendant le parsing = `alpine:init` toujours joint à temps.
- **base.html (shell) migré** : `x-effect document.*` → `syncSidebarClass()`,
`$nextTick(arrow)` → `initSidebarSort()`, `window.FlowDeck.*` →
`fdCreatePage/fdCreateFolder/fdGwRefresh`, `Object.keys`/`Math.min`/
`window.innerWidth` dans `x-for`/`:style` → `sidebarSections()`/
`sectionMenuPos()` — toutes les formes = simple appel de méthode.
- **x-html restants du shell** → `x-init` + `Alpine.effect` :
icône agent (`bindAgentIcon`), carte projet (`bindProjectIcon`),
library ×3 (`bindHtmlIcon`/`bindHtmlItem`), recherche library
(`toggleSearch` avec `Alpine.nextTick`), `openMoveSelected` (library).
- **eslint : 70 warnings → 0/0** : `getCsrf` (helper A38 ph1) déclaré dans
les globals du config, `/* exported openCardDetail */` +
`/* global owner, repo */` (app.js), 3 `;;` résiduels de la conversion
A38 supprimés.
### Notes
- Portes A20 ph3 : surfaces restantes = settings, local_workspace,
gitea_workspace, page_editor, board (partiels), agent_panel, import,
welcome, accounts, trash, team_load, workspace, table_view, card_detail ;
**bascule réelle** (retrait `unsafe-eval`) = quand csp_preview est vert
sur toutes les pages principales.
## v7.37.0 (2026-10-01) — A20 : htmx allowEval off + plan Alpine CSP (phase 3)
### Changed
- **htmx `allowEval: false`** dans le `<meta name="htmx-config">` : htmx
ne peut plus évaluer de JS (`hx-on`/`hx-vars`/`hx-vals`) — grep = **0
usage** dans les templates, donc zéro régression possible. `unsafe-eval`
reste **uniquement** pour Alpine standard.
- **Gate E20 renforcé** : le smoke vérifie désormais que `Alpine.$data()`
lie un vrai composant `[x-data]` de la page (le lien composant = le cœur
de tout basculement CSP).
### Notes — A20 phase 3 (unsafe-eval, scopé par probes)
Le build `@alpinejs/csp` a été **testé empiriquement** (fichier 72 Ko,
**0 `eval`/`new Function`**, parseur d'expressions maison) : il tourne sous
CSP strict, mais **bloqué sur FlowDeck** par deux familles d'usages :
- **13 expressions non parsables** par la grammaire restreinte :
arrows (`$nextTick(() => …)` ×2), `typeof` ×1, `new Date(…)` ×4,
optional-chaining `?.` ×6 (base, library, local_workspace, settings,
gitea_workspace) ;
- **24 `x-html` réactifs** (icônes SVG `getSvgIcon`/`_fileIcon`,
markdown agent, preview) — **interdits par le build CSP** (innerHTML) :
nécessitent de reposer les icônes sur des composants `Alpine.data`.
- Portée : 0 variable globale/`document` accessible dans les expressions
du build CSP (scope = données du composant + magics) → chaque site
devient une méthode de composant enregistrée via `Alpine.data`.
**Plan phase 3** : migrer composant par composant (library → settings →
local_workspace → gitea → base) avec gate E2E dédiée, puis retirer
`unsafe-eval`. En attendant : `unsafe-eval` conservé (Alpine standard).
## v7.36.0 (2026-10-01) — Fondations E2E + 2 bugs trouvés au passage
### Added
- **`e2e/smoke.spec.js`** — 2 gates vert contre l'instance de test :
· **gate A39** : bascule de vues d'une collection (clic onglet Calendar →
`?view_type=calendar`, grille `.calendar` + `.cal-header` rendue,
collection créée puis **supprimée** = répétable)
· **gate A20** : palette Ctrl+K (ouverture Alpine `.open`, recherche GET
rend `.cmd-palette-item`, fermeture Échap)
· **filet console** : 0 erreur JS/CSP (les violations atterrissent ici ;
le bruit `Failed to load resource` 401/403 est filtré)
· **Service Workers bloqués** dans le smoke : `/sw.js` sert sa page
« hors ligne » sur les navigations redirigées (`redirect:'manual'`) —
bruit PWA hors sujet, `pwa_offline.spec.js` couvre le SW
· bootstrap autonome : login OU création du compte e2e documenté,
workspace si absent — lecture seule sur les données existantes
- Commande : `cd e2e && node node_modules/@playwright/test/cli.js test smoke.spec.js`
### Fixed (trouvés par les gates)
- **Bascule de vues standalone jamais fonctionnelle** : les onglets
émettaient `?view=…` mais la route lit `view_type` (FastAPI) → l'onglet
restait sur Table quel que soit le clic (bug pré-existant, non introduit
par A28). Onglets → `?view_type=` + assertion comportementale ajoutée à
`test_all_view_tabs_present`
- **Inter bloqué par la CSP depuis v7.27** : `app.css` importait encore
Google Fonts (`@import` raté par le grep de v7.27) → violation
`style-src` sur chaque page + police tombée en fallback. Inter
**auto-hébergé** : 2 faces variables (100-900, latin + latin-ext) dans
`static/fonts/`, `@import` supprimé
### Notes
- Suite complète : **1093/1093** · ruff OK · E2E **2/2**
- Portes : A20 (unsafe-eval) attaquable avec ce filet ; A39 couvre la
bascule collection (pas la bascule htmx board → décision « rien »
maintenue) ; A38 twins reste conditionné à une couverture élargie
## v7.35.0 (2026-10-01) — Audit : A35 TERMINÉ (Python 3.13 aligné + rebuild)
### Changed
- **Drift Python résolu** (dernier reliquat A35) : tout le projet est en
**3.13** — `Dockerfile` (`python:3.13-slim` builder + runtime), CI Gitea
(`python-version: '3.13'` ×2), `README.md` ×2, ruff
`target-version = "py313"` (0 nouvelle remarque ruff). Restait 3.12 aux
6 endroits alors que `uv.lock` = `requires-python >=3.13` et le venv =
3.13.14. Zéro référence 3.12 résiduelle.
### Validation (le point que l'audit laissait « à faire par rebuild »)
- `docker build` **vert** sur `python:3.13-slim` (image `flowdeck:a35-py313`)
- dans le conteneur : `python -V` = **3.13.16**, `import app.main` OK
(version 7.35.0) → wheels `requirements.txt` construits et importables
sur 3.13
- Suite locale complète : **1093/1093** · ruff OK (target py313)
### Notes
- A35 = **TERMINÉ** (OpenAPI/README/titre faits en 7.3.9 + drift aujourd'hui)
## v7.34.0 (2026-10-01) — Audit : A38 phase 2 (0 doublon de fonction globale)
### Changed
- **Inventaire exhaustif de la duplication de fonctions** : scan de
profondeur de brace sur les 13 noms définis 2+ fois (templates +
static/js, strings/comments/backticks gérés) → **12 sont déjà scopés**
dans des IIFEs depuis A27 (aucun conflit de page possible)
- **Seul doublon global = `openCardDetail`** (corps byte-identiques ×2 dans
`board_fragment` + `detailed_board`, fragments de vues mutuellement
exclusifs) → dédupliquée vers `static/js/app.js`, les 2 copies
supprimées (les onclick/@click des deux fragments appellent la même
définition)
### Tests
- `test_no_duplicate_global_functions` : garde-fou — 0 `function NAME`
globale définie 2+ fois entre templates et static/js (scanner naïf,
plafond `ponytail:` commenté : un faux positif se lit au nom signalé)
- Suite complète : **1092/1092** · ruff OK · node --check vert
### Notes
- Reste A38 : méthodes jumelles library/local_workspace (~9-21 noms
communs, corps divergents) → fusion `workspace-tree.js` reportée
(réconciliation sans E2E — même logique que A39/A20)
## v7.33.0 (2026-10-01) — Audit : A43 TERMINÉ + A38 phase 1 (CSRF unifié)
### Fixed
- **A43-1 — plus de `__CSRF_PLACEHOLDER__` servi** : ContextVar
`CSRF_TOKEN` posée par `CSRFMiddleware` **avant** `call_next` (même
mécanique que le nonce CSP), global `{{ csrf_token() }}` ajouté à
templating ; `base.html` rend `{"X-CSRF-Token":{{ csrf_token()|tojson }}}`
— vide si le cookie est absent sur cette 1ʳᵉ requête, mais
`htmx:configRequest` re-lit le cookie à chaque appel → jamais de jeton
factice. Test `test_csrf_server_rendered_no_placeholder` (pas de
placeholder + token == cookie)
- **A43-2 — palette : plus de re-parse par frappe** : `fetch('/api/search…')`
sans header (GET ∈ `SAFE_METHODS` → le CSRF ne s'applique pas) — le
`JSON.parse(document.body.getAttribute('hx-headers'))` par frappe disparaît
### Changed
- **A38 phase 1 — helper CSRF unique** : `window.getCsrf()` défini dans le
`<head>` de `base.html` (le plus tôt possible) ; **76 lectures brutes du
cookie → `getCsrf()`** dans 13 fichiers (47 formes `(…||[])[1]||''`,
25 déclarations `const X = match(…)` + leurs usages `X?X[1]:''` → `X`,
4 formes espacées) ; définitions dupliquées supprimées (`card_detail`,
`database_table`) ; les 3 variantes de `base.html` (IIFE + 2
`getCsrfToken`) unifiées sur `return getCsrf()` ; `welcome.html` garde sa
lecture locale (page autonome documentée)
### Notes
- A43 = **TERMINÉ** (4/4 : utcnow déprécié = 0 dans app/**.py, health
loggé, placeholder, palette)
- A38 reste : 12 fonctions dupliquées entre templates (wrappeur) + 21
méthodes jumelles library/local_workspace
- Suite complète : **1092/1092** · ruff OK · node --check vert
## v7.32.0 (2026-10-01) — Audit : A28 TERMINÉ (board, dernier lot)
### Changed
- **A28 lot 4** : `app/routers/board.py` (**2 101 lignes, 53 routes**)
devient le package `app/routers/board/` :
· 12 modules : `pages` 271 L (7), `page_api` 229 (5), `board_views` 223 (8),
`page_ops` 176 (3), `sharing` 175 (10), `synced` 144 (8),
`page_media` 122 (4), `import_` 85 (2), `wiki` 76 (2), `library` 66 (1),
`embed` 64 (2), `sync` 51 (1)
· `_common.py` (878 L) : les **23 helpers dont 4 async** + les 4
constantes (STATUS_COLORS, STATUS_LABELS, AI_KEYWORD_COLORS,
_REPO_REF_RE)
· `__init__.py` : `__all__` complet — importateurs **inchangés**
(api.py ×4 top-level, webhooks top-level, dashboard ×5 lazy, tests ×4)
- Preuve contractuelle : **`docs/openapi-v2.json` régénéré = identique
byte-à-byte**
### Fixed
- Constantes d'état oubliées dans `_common` à la 1ʳᵉ passe (F821 +
ImportError au chargement) → ré-insérées avec leurs valeurs exactes
(git show)
- Docstring du header copié → F404 (`from __future__` après un statement)
→ slice `[1:21]` ; helpers **async** non détectés par `def ` seul
### Notes
- **A28 TERMINÉ en 4 lots** : api_v2 (7.29.0), dashboard (7.30.0),
collections (7.31.0), board (7.32.0) — **0 changement d'URL** sur les 4
- Suite complète : **1091/1091** · ruff OK
## v7.31.0 (2026-10-01) — Audit : A28 lot 3 (collections → package 13 fichiers)
### Changed
- **A28 lot 3** : `app/routers/collections.py` (**2 622 lignes, 53
endpoints / 52 fonctions**) devient le package `app/routers/collections/` :
· 10 modules de routes : `crud` 337 L (6), `properties` 322 (8),
`linked` 286 (7), `structure` 267 (8), `dashboard_views` 214 (3),
`meta` 197 (5), `views` 187 (6), `pages` 184 (4), `data_api` 122 (2),
`boards` 61 (3)
· `_common.py` (220 L) : 8 helpers auth/permissions/validation
· `_renderers.py` (667 L) : **15 rendus HTML des vues** (_render_chart,
_render_map, …) + `CHART_MAX_GROUPS`
· `__init__.py` : ré-exports connus — `_validate_page_properties`
(automations), `_chart_values`/`_chart_aggregate`/`_fmt_number`/
`_render_chart` (tests)
- Preuve contractuelle : **`docs/openapi-v2.json` régénéré = identique
byte-à-byte**
### Fixed (pièges de la découpe)
- docstring d'origine gardée dans le header copié → **F404** (`from
__future__` après un statement) : slice `[1:30]`
- décorateurs **empilés** (`view_collection` ×2) : segment sans `def` →
skip du 2ᵉ décorateur (53 endpoints = 52 unités)
- `CHART_MAX_GROUPS` hors détection des helpers (F821 dans
`dashboard_views`) → import `._renderers` ajouté
- `test_csp_no_cdn_and_vendor` lisait `collections.py` → balayage du
package
### Notes
- Reste A28 : `board.py` 2 101 L (lot 4)
- Suite complète : **1091/1091** · ruff OK
## v7.30.0 (2026-10-01) — Audit : A28 lot 2 (dashboard → package 10 fichiers)
### Changed
- **A28 lot 2** : `app/routers/dashboard.py` (**2 735 lignes, 63 routes**)
devient le package `app/routers/dashboard/` :
· `local_workspace` 559 L (15 routes), `pages_html` 485 (6),
`account_settings` 439 (16), `workspace` 321 (9), `pages_api` 240 (6),
`workspaces` 131 (6), `public` 78 (1), `account_api` 77 (4)
· `_common.py` (774 L) : les **15 helpers top-level intercalés** dans
l'ancien fichier + état (`logger`, `_VERSION`, `WORKSPACE_COOKIE`)
· `__init__.py` : re-export complet (les 7 importateurs existants —
main, board ×3, my_tasks, web_clipper, wiki, sites, tests — n'ont
rien changé) + `__all__`
- Preuve contractuelle : **`docs/openapi-v2.json` régénéré = identique
byte-à-byte** (ordre d'enregistrement des routes préservé)
### Fixed (pièges de la découpe, rattrapés)
- segment décorateur sans sa fonction (`def`) → corps perdus en silence :
assert `def in seg` ajoutée + récupération depuis git
- collision `settings` : la section `settings.py` entrait en conflit avec
`from app.config import settings` du header (`hasattr` du fromlist de
Python → la section était ignorée) → renommée `account_settings`
- `WORKSPACE_COOKIE` utilisé dans `workspaces.py` sans import (F821) →
import `._common` ajouté
- script `__all__` tronquant la fin du fichier → `__init__.py` réécrit
### Notes
- Reste A28 : `collections.py` 2 622 L, `board.py` 2 101 L
- Suite complète : **1091/1091** · ruff OK
## v7.29.0 (2026-10-01) — Audit : A28 lot 1 (api_v2 → package 14 fichiers)
### Changed
- **A28 lot 1** : `app/routers/api_v2.py` (**2 110 lignes, 115 routes**)
devient le package `app/routers/api_v2/` — un module par concern :
· `collections` 566 L (23 routes), `engagement` 338 (21), `workspaces`
230 (9), `templates_io` 205 (9), `webhooks` 195 (8), `identity` 195 (7),
`views` 164 (8), `sharing` 160 (8), `properties` 151 (7),
`planning` 148 (7), `projects` 93 (4), `admin` 91 (4)
· `_common.py` : helpers partagés (`_hash`, `_v2_rate_check`)
· `__init__.py` : `router = APIRouter(prefix="/api/v2")` + agrégat
`include_router` (section routers sans prefix, tags `api-v2`)
- Preuve contractuelle : **`docs/openapi-v2.json` régénéré = identique
byte-à-byte** (0 changement de chemin, tag ni operation_id)
- Seul importateur (`app/main.py` → `from app.routers.api_v2 import
router`) fonctionne via le package ; en-tête d'imports copié par module,
émondé par `ruff --fix` (143 imports morts supprimés automatiquement)
### Notes
- Reste A28 : `dashboard.py` 2 735 L, `collections.py` 2 622 L,
`board.py` 2 101 L (même recette, lots suivants)
- Suite complète : **1091/1091** · ruff OK
## v7.28.0 (2026-10-01) — Audit : A42 TERMINÉ (client httpx partagé)
### Changed
- **`app/services/http_client.py`** : `async with shared_client(timeout=15)
as client:` remplace les **49 créations `async with httpx.AsyncClient(`**
réparties dans 14 fichiers (gitea ×21, providers ×11, calendar ×4,
automations ×3, …) — le pool de connexions est réutilisé au lieu d'être
recréé à chaque appel
- Cache **par (boucle d'event, kwargs)** en `WeakKeyDictionary` : un
`AsyncClient` n'est jamais partagé entre deux loops (le piège des tests :
« Event loop is closed ») — une boucle par test = un client propre,
collecté avec elle. Clé = kwargs triés, `repr()` pour les valeurs non
hashables (`headers=` dict)
- Context manager no-op à la sortie (pas de fermeture du client partagé) ;
`ponytail:` documenté : pas d'`aclose` explicite, plafond = pools non
fermés à la main (GC des sockets), upgrade = lifespan
- **Laissés délibérément** : `github_adapter` (transport MockTransport
injecté), `webhook_outbound` (client « own_client » fermé par la fonction)
### Tests
- `test_http_client_shared_and_loop_scoped` : réutilisation (mêmes kwargs),
cloisonnement par kwargs, cloisonnement par boucle (2× `asyncio.run`)
- Stub webhooks : patch étendu à la fabrique `http_client.httpx` + purge du
cache (les tests patchaient `webhook_outbound.httpx`, contourné par la
fabrique partagée)
- Suite complète : **1091/1091**
## v7.27.0 (2026-10-01) — Audit : A20 phase 2 (CDN retiré, connect-src fermé)
### Changed
- **Vendorisation** : chart.js 4.5.1 + leaflet 1.9 (js, css, 5 images
marker/layer) téléchargés vers `static/js/vendor/` ; les 3 URL CDN des
vues chart/map (`collections.py`) pointent en local → **la CSP n'a plus
aucun hôte tiers** dans `script-src` ni `style-src`
- **`connect-src` fermé** : `'self' ws://{host} wss://{host}` (Host de la
requête, caractères hors base URL filtrés) — le `https:` universel
(canal d'exfil JS) et les `ws:`/`wss:` tout-hôtes disparaissent.
Grep négatif : 0 fetch cross-origin côté front
- **Google Fonts** : entrées CSP mortes (0 référence dans le code) retirées
de `style-src`/`font-src`
- `img-src https:` **conservé volontairement** (unfurls YouTube/Vimeo… et
tuiles OSM inénumérables) — `ponytail:` commenté dans `security.py`
### Tests
- `test_csp_no_cdn_and_vendor` : CSP sans CDN/Google, connect-src exact
(`'self' ws://testserver wss://testserver`), 4 assets vendor servis (200),
source `collections.py` sans référence CDN
- `test_view_chart_renders` : assert sur le chemin vendor
- Suite complète : **1090/1090**
### Notes
- Reste A20 : `unsafe-eval` (Alpine x-data + htmx hx-on/hx-vars = eval)
→ build `@alpinejs/csp` + couverture E2E des vues d'abord (même logique
que la décision A39)
## v7.26.0 (2026-10-01) — Audit : A21 phase 2c (190 routes hors loop)
### Changed
- **A21 phase 2c** — **283 → 93 routes async** (**86 % des 667 routes** en
threadpool, avant : 61 %) en 4 passes :
· **Racine auth** : `get_current_user` (session.py) était `async def`
**sans aucun await** (cookie decode = synchrone) ; idem ses clones
(`agent._current_user_id/_workspace_id/_current_admin`,
`sso._require_admin` au corps 0 await) → `def` + **47 `await`
supprimés** (dont 3 via l'alias `gcu` — le piège : le grep littéral ne
les voyait pas, la suite les a attrapés)
· Re-scan : 19 routes devenues sans await → `def`
· **155 routes** dont les seuls awaits étaient `request.json()` /
événements → `Body(default={})` + `run_event_sync(...)` puis `def` :
- try/except `body = {}` → défaut `{}` (même tolérance, laissée intacte)
- try/except `raise HTTPException(400)` → `Body(...)` **requis**
(422 FastAPI — **aucun test ne couvrait le 400**, aucun call front
n'envoie de JSON invalide)
- forme conditionnelle `request.json() if content-type else {}`
(54 sites) → défaut `{}` : sans corps = `{}` dans les 2 cas
- Import `Body`/`run_event_sync` ajoutés aux routers convertis
### Notes
- **Reste async (93, justifié)** : `request.form`/`upload.read`/`file.read`
(corps réellement asynchrone), gitea/llm/oidc (réseau), `_json_body`
(9 — wrapper de validation), 2 JSON inline en argument d'appel,
1 fallback à logique (`capture_frontend_error`), 1 lecture conditionnelle
de taille (web_clipper)
- Suite complète : **1089/1089** · ruff OK
## v7.25.0 (2026-10-01) — Audit : A27 CLOSED (eslint 0/0)
### Fixed
- **Toasts settings jamais affichés** : `settings.js` appelait
`typeof toast === 'function'` — guard **toujours faux** (le `toast` global
n'existait pas) → les retours « Timezone saved » / « SP metadata URL
copiée » ne s'affichaient jamais. → `window.showToast` (global `base.html`)
- `_wsInitData = window._wsInitData` (auto-affectation d'un nom non déclaré,
sans effet observable car `window._wsInitData` existe depuis L4) supprimé
### Changed
- **A27 lint terminé : `eslint static/js` = 0 erreur / 0 warning** (285 → 0,
22 fichiers) :
· no-empty ×70 = `catch (x) {}` vides → `catch { /* volontaire */ }`
(binding optionnel ES2019 ; zéro changement de comportement)
· no-unused-vars ×171 = bindings de catch retirés + 24 lignes mortes
déterministes (assert sur texte exact avant suppression) +
`/* exported */` sur les 10 fonctions appelées depuis les attributs
HTML des templates (vérifiées par grep)
· no-undef ×44 = vrais globaux déclarés dans `eslint.config.mjs`
(getSvgIcon = inline base.html, TextDecoder = API, Prism = CDN)
### Notes
- `node --check` vert sur tous les fichiers ; suite complète **1089/1089**
- A27 reste ouvert uniquement pour : `base` 1 523 L structurel (inline par
nature, décision assumée) + ~500 L de petits blocs hors cibles
## v7.24.0 (2026-10-01) — Audit : A27 extraction TERMINÉE (bilan -85 %)
### Changed
- **A27 (phase 2c)** — `_database_table_scripts.html` : **1 314 L extraites**
vers `static/js/database_table.js`. Le Jinja du bloc était confiné à la
construction de l'objet de config (4 clés + `{% if collection_data %}`) →
config JSON `#db-config` **null-vs-objet** : `new DBInstance(container,
PAGE_COLLECTION_ID, DB_CONFIG)` remplace les 2 branches Jinja
- 2 tests adaptés (lisaient le template source → `database_table.js`) ;
`FlowDeckDB` plus dans le HTML → assert sur le JS
### Notes
- **BILAN A27 : 11 874 L extraites en 4 phases** (4 243 + 2 516 + 3 801 +
1 314), **inline 13 904 → 2 022 L (-85 %)**, 22 fichiers `static/js/*.js`,
`node --check` vert partout, eslint **0 erreur / 285 warnings**
- Reste : `base` 1 523 L structurel (`{% block %}`/`{% for %}` — reste
inline par nature, décision assumée), ~500 L de petits blocs hors cibles,
nettoyage des 285 warnings
- Suite complète : **1089/1089**
## v7.23.0 (2026-10-01) — Audit : A27 phase 2b (+3 801 L)
### Changed
- **A27 (phase 2b)** — 4 blocs interpolés extraits avec la recette config
JSON (identique à la 2a) :
· `local_workspace.html` → `local_workspace.js` (2 031 L, `lw-config` :
current_folder_id, workspace_id)
· `settings.html` → `settings.js` (1 093 L, `st-config` : avatar_url,
avatar_color, user full_name/login/email, is_admin, auth_method —
**2 routes rendent ce template**, les expressions `or ""` sont préservées
pour les valeurs Undefined, `is_admin` reste un booléen)
· `_page_editor_realtime.html` → `page_editor_realtime.js` (531 L,
`rt-config` : SELF id/login/full_name/color)
· `board.html` → `board.js` (146 L, `bd-config` : owner/repo/initial_view)
- BONUS sécurité : les valeurs passent par `|tojson` (échappement JSON
explicite) au lieu d'être interpolées dans des strings JS
- Tags : config JSON inline (nonce conservé) + `<script src>` avec
`?v={{ asset_version }}` ; loaders `XX = JSON.parse(#xx-config)` en tête
des fichiers extraits (try/catch → `{}`)
### Notes
- **Cumul A27 : 10 560 L extraites** (13 904 → **3 344 restantes**, -76 %)
- `node --check` vert sur les 4 fichiers ; eslint : **0 erreur, 279 warnings**
(12 fichiers, baseline mise à jour)
- Reste structurel : `base` 1 338 (`{% block %}`/`{% for %}` — reste inline
par nature) + `database_table` 1 323 (`if/else` sur collection_data)
- Suite complète : **1089/1089**
## v7.22.0 (2026-10-01) — Audit : A27 phase 2a (l'éditeur, 2 516 L)
### Changed
- **A27 (phase 2a)** — `_page_editor_scripts.html` : les **2 516 lignes** du
gros bloc interpolé partent vers `static/js/page_editor_scripts.js`
- Recette « config JSON » : les **8 interpolations Jinja** lisent maintenant
`PD = JSON.parse(document.getElementById('page-data'))` — le bloc JSON
`#page-data` **existait déjà** juste avant le script, même ordre
d'exécution, garde `__fdEditorScriptsLoaded` préservée
- Côté route (`view_page_root`) : `page_data` enrichi de `updated_at`,
`created_at`, `user_id`, `is_shared` (le dérivé est **hoisté** — une seule
expression sert le ctx ET le JSON) et `clip_icon` (macro `fd_icon` rendue
côté serveur). `workspace_key` reste vide comme avant (jamais défini dans
ce contexte → parité stricte)
- `node --check` vert ; template : 2 tags restants (JSON config + src)
### Fixed
- **8 tests adaptés** à l'extraction (ils lisaient le template source) :
`test_ai_writing` ×2 (+ helper `_read_js`), `test_pwa_offline`,
`test_v511` front_end_wired, `test_v55` ×3 (lightbox, previews, endpoints)
→ lisent `static/js/page_editor_scripts.js` ; `test_page_editor_renders_
page_is_shared` → **parsing du JSON `#page-data`** (`is_shared is True`,
la valeur sert toujours à la page)
### Notes
- Cumul A27 : **6 759 L extraites** (13 904 → 7 145 inline)
- Reste : local_workspace 2 031, base 1 523 (structurel {% for %}/{% block %}),
database_table 1 323 (if/else), settings 1 093, realtime 531, board 146
≈ 6 653 L + 120 warnings eslint à nettoyer
- Suite complète : **1089/1089**
## v7.21.0 (2026-10-01) — Audit : A27 phase 1 (4 243 L de JS extraites)
### Changed
- **A27 (phase 1)** — les 7 templates dont le JS **n'est pas interpolé Jinja**
sont extraits vers `static/js/*.js` : agent_panel_1/_2 (dont un bloc de
**1 788 lignes livré sur chaque page**), library (1 039), gitea_workspace
(626), _icon_picker_1/_2, _ctx_menu, import, workspaces →
**4 243 lignes, -30 % de JS inline** (13 904 → 9 661)
- Extraction **un fichier par bloc** : ordre et timing d'exécution identiques
(pas de defer/async, attributs d'origine conservés dont `data-cfasync`),
cache-busting `?v={{ asset_version }}` (source unique A40), CSP : les
scripts externes relèvent de `'self'` (pas de nonce requis)
### Lint
- ESLint **installé globalement** (`npm i -g eslint`) — `eslint.config.mjs`
existait déjà en flat config « sans dépendances » mais **aucun binaire**
n'était installé (d'où « 0 linté »)
- `eslint static/js` : **0 erreur, 120 warnings** sur 8 fichiers
(no-unused-vars 69, no-empty 36, no-undef 15) → baseline à nettoyer
- `node --check` vert sur les 9 fichiers extraits ; `eslint.config.mjs` couvre
déjà `static/js/**/*.js` donc les extraits sont lintés d'office
### Notes
- Reste A27 : les blocs interpolés Jinja (page_editor 2 517, local_workspace
2 031, base 1 523, database_table 1 323, settings 1 093… ≈ 9 661 L) →
extraction en 2 temps (config JSON injectée + script statique)
- Suite complète : **1089/1089**
## v7.20.0 (2026-10-01) — Audit : A32 TERMINÉ (routes Gitea + bug fd_icon)
### Fixed
- **Bug prod trouvé par les smokes** : `card_detail.html` appelait la macro
`fd_icon` **sans l'importer** → `UndefinedError` → **500 systématique** sur
`GET /api/issues/{o}/{r}/{id}?format=html` (seul rendu du template dans le
code) → `{% from '_icons.html' import fd_icon %}` ajouté
### Tests
- Les **6 dernières routes d'A32** (Gitea, stub de transport, zéro réseau) :
· stubs manuels sur `gitea_client.gitea` avec **état mutable partagé**
(le handler PATCH re-fetch l'issue via `get_issue` — un canevas figé
aurait masqué la mise à jour)
· `POST /issues` : carte **insérée sur le board** ; `PATCH` : colonne
recalculée **sans perdre la carte**
· `GET /issues` JSON + **HTML** (`?format=html` — le segment `/html` ne fixe
pas le paramètre `format`, il est lu dans la query) ; stub qui lève → 404
· `POST /checklists` + `POST /checklist-items` : lignes **vérifiées en
base**, 404 sans board
- `test_smoke_uncovered.py` : **52 tests** ; suite complète **1089/1089**
- **A32 complet** : plus aucun router « 0 test » (webhooks, notes,
sidebar_config, github_routes, library, api, dashboard, api_v2)
## v7.19.0 (2026-10-01) — Audit : A32 : dashboard bloqué (44/44)
### Tests
- +5 routes `dashboard.py` — `test_smoke_uncovered.py` : 49 tests :
· **Members** (POST/PUT/DELETE) : invitation de soi-même dans un workspace
dédié, rôle relu en base, membre supprimé (`COUNT=0`) ; **quirk documenté**
: les retours `(..., 400)` de ces routes sont sérialisés FastAPI en
tableau + 200 (`[{"error": "Invalid role"}, 400]`)
· `upload-folder` : **validations seules** (structure absente → 400,
JSON cassé → 400) — zéro fichier écrit sur disque, workspace dédié nettoyé
· `convert-to-database` : collection + propriété `title` + vue `table` +
page en `content_format='collection'` **vérifiés en base**, 404 page
inconnue, cleanup **dans l'ordre FK** (page avant collection)
- **Recoupement final** : scan des 44 routes strictement à 0 ref de
`dashboard.py` → **toutes exercées** (19 faux positifs résiduels =
paths en f-string dans les tests, rapprochés manuellement)
- Suite complète : **1086/1086**
## v7.18.0 (2026-10-01) — Audit : A32 phase 2g (dashboard +13)
### Tests
- +13 routes `dashboard.py` (cumul **43→56 sur 63**) —
`test_smoke_uncovered.py` : 46 tests :
· `/gitea-workspace` : page HTML (200 ou redirection propre)
· `workspace/projects` GET+POST : shape `{builtin, gitea, github}` avec
`github == []`, projet créé **retrouvé dans builtin**, quirk `error` sans
nom, nettoyage
· **Cycle items local-workspace** (5 routes) : création → renommage **relu en
base** → move → soft-delete (`deleted_at` **relu**) → restore
(`deleted_at IS NULL` **relu**), nettoyage
· **Cycle tags d'item** (5 routes) : POST (nom lowercasé), tags de l'item,
liste workspace, search (shape), suppression vérifiée. Utilisateur +
workspace **créés dans le test** : `/api/local-workspace/tags` a besoin
d'un workspace actif (fallback « premier workspace du user ») — on ne le
fait pas dépendre de l'utilisateur fixture partagé, tout est nettoyé
- Suite complète : **1083/1083**
## v7.17.0 (2026-10-01) — Audit : A32 phase 2f (dashboard +7, garde-fous A16)
### Tests
- +7 routes `dashboard.py` (cumul **36→43 sur 63**), centrées sur les
garde-fous A16 — `test_smoke_uncovered.py` : 42 tests :
· `GET /api/files/{ws}/{path}` : traversal encodé `%2e%2e%2f` →
**403 « Path traversal denied »** ; inexistant → 404 ; vrai fichier écrit
dans le data_dir de test → **200 + octets exacts** (nettoyé)
· `GET /api/pages/{id}/download` : page markdown → 404 « downloadable »
(pas de 500) ; page « file » avec chemin `../` qui sort de la racine →
**jamais 200** (404), et `file-content` → 404/415
· `GET /api/local-workspace/page-content/{id}` : contenu + format relus,
404 sur id inconnu
· `GET /api/avatar/{id}` : **302 + Location** avec `follow_redirects=False`
(AUCUNE requête réelle vers l'URL externe — règle « 0 réseau »), 404 sans
avatar
· `GET/POST /api/collections/{id}/table-data|pages` : 404 inconnu, shape,
ligne créée **retrouvée dans table-data**, nettoyage finally
- Suite complète : **1079/1079**
## v7.16.0 (2026-10-01) — Audit : A32 phase 2e (dashboard +9, comptes)
### Tests
- +9 routes `dashboard.py` (cumul **27→36 sur 63**) :
· `/accounts` + `/accounts/settings` : 200 HTML, **`password_hash` absent**
du rendu (whitelist A2 vérifiée côté page)
· `PUT /api/user/profile` : persistance **relue en base**, valeur d'origine
restaurée en `finally`
· `PUT /api/user/password` : **403 « current password is incorrect »** (A3 —
la session seule ne change pas le mdp) + quirk assumé et documenté :
longueur validée AVANT auth → 200 + message d'erreur
· `POST /api/user/token` : format `fd_` + 64 hex, ligne `user_tokens`
nettoyée en `finally`
· `DELETE /api/user/forge/{provider}` : `{"status": "ok"}`
· `PUT /api/settings/account` : full_name/email persistés + **400 sur mdp
court** (validateur), restauration en `finally`
· `POST /api/workspaces/{id}/select` : `Set-Cookie flowdeck_workspace`
vérifié ; `GET /api/local-workspace/breadcrumb` : shape liste
- `test_smoke_uncovered.py` : 38 tests ; suite complète **1075/1075**
## v7.15.0 (2026-10-01) — Audit : A32 phase 2d (dashboard +10 routes)
### Tests
- Scan strict `dashboard.py` : **44 routes à 0 référence** (sur 63) — 10
couvertes cette passe dans `test_smoke_uncovered.py` (32 tests au fichier) :
· **Tags CRUD** : création (`SmokeTag` → `smoketag` lowercasé), présence
dans la liste, changement de couleur relu, suppression puis absence
· **Vie d'une page** : GET `content` (contenu seedé relu) → PUT `rename`
(ok + **400 sur titre vide** + titre relu en base) → POST `trash`
(`parent_section='Trash'` + `deleted_at` **relus en base**)
· `sidebar/workspace-tree` : 200 HTML, fragment « No pages yet » sans cookie
· `settings/avatar-color` : couleur relue **sur l'utilisateur de la
session** (pas `LIMIT 1`), valeurs d'origine restaurées en `finally`
· `workspace/{id}/members` : shape `{"members": [...]}`
- Helper `_seed_page` : surcharge des colonnes par défaut (`content=`, …)
- Suite complète : **1069/1069**
## v7.14.0 (2026-10-01) — Audit : A32 phase 2c (api_v2 +5 routes)
### Tests
- Scan strict des 115 routes `api_v2.py` contre tous les tests (chaîne de
chemin littérale) → **5 routes à 0 référence**, toutes couvertes :
· `POST /properties/evaluate-formula` : 200 + shape, 400 sans `expression`
(le moteur renvoie `1 + 2` tel quel aujourd'hui — le smoke valide le câble
route/auth/parse, pas le moteur)
· `POST /properties/compute-rollup` : 400 `collection_id required`,
401 sans bearer
· `GET /admin/audit-logs` : portail admin vérifié — attendu **calculé depuis
`/users/me`** (le tout premier utilisateur d'un worker est admin, état non
contrôlable depuis le test), + token scope `admin` → 200 + `logs` liste
· `GET /webhooks/events` : catalogue non vide + wildcards `*`/`page.*`
· `POST /webhooks/verify-signature` : **valid=True** avec
`sign_payload(secret, payload)` (même helper que le serveur), False avec
une signature bidon
- `test_smoke_uncovered.py` : 27 tests au total
- Suite complète : **1064/1064**
## v7.13.0 (2026-10-01) — Audit : A32 phase 2b (api.py 16/22)
### Tests
- `api.py` passe de **3 à 16 routes couvertes** (22 `@router` au total) :
· `board-config` GET/POST : défauts à 5 colonnes sans board, création puis
relecture du roundtrip
· `col-mapping` POST/DELETE : 404 sans board, upsert `label` vérifié,
suppression vérifiée
· `card` POST : 404 sans board, `{"status": "ok"}` avec
· `collaborators` GET : **`gitea.get_collaborators` stubbé** (zéro réseau réel)
· `frontend-error(s)` : capture, JSON invalide → `ignored`, **dédup** d'une
erreur répétée (`count=2`), lecture qui purge (`cleared=true` puis 0)
· checklist mutations : PATCH item (checked/content relus EN BASE),
DELETE item, DELETE checklist (réapparition `COUNT=0`) — seed + cleanup
- Reste `api.py` : 6 routes Gitea (issues ×4 + créations checklists) →
stub de transport httpx. Reste global : `dashboard.py` 17/63,
`api_v2.py` 50/115
### Tests
- Suite complète : **1059/1059** (236 s) ; `test_smoke_uncovered.py` : 22 tests
## v7.12.0 (2026-10-01) — Audit : A32 phase 2a (library 10/10)
### Tests
- `library.py` passe de **1/10 à 8 routes couvertes** : les 5 listes
(recents/favorites/published/private/workspace) en un test de boucle,
`/private` avec une page seedée et retrouvée, `/children/{id}` avec un
parent/enfant seedés (titre retrouvé), `/repository` vide et clé
(aucun appel réseau — la clé n'est qu'une string de workspace)
- Test de non-régression 404 sur les 2 routes supprimées
### Removed
- **2 routes cassées supprimées** (découverte des smokes) :
`/api/library/local-workspace-children/{id}` renvoyait un 500 systématique
et `/api/library/local-workspace` un 500 dès qu'un workspace existait —
les deux lisaient `local_workspace_items`, **une table qui n'est créée nulle
part** dans le codebase (grep : 0 `CREATE TABLE`), avec **0 référence front**.
`library._format_size` devenu mort : supprimé aussi (une version vit dans
`dashboard.py`, inchangée)
- `local_workspace_items` : plus aucune occurrence dans `app/`
### Tests
- Suite complète : **1053/1053** (229 s) ; `test_smoke_uncovered.py` : 16 tests
## v7.11.0 (2026-10-01) — Audit : A32 phase 1 (routers à 0 test)
### Tests
- `tests/test_smoke_uncovered.py` — **10 smoke tests**, un par route des 4
routers qui n'avaient AUCUN test :
· `webhooks.py` (3/3) : réception sans secret → `{"status":"ok"}` ;
HMAC faux → 401 (secret piloté par monkeypatch) ; register sans secret →
400 **avant** tout appel réseau ; status avec `gitea.list_webhooks` stubbé
(zéro accès réseau réel)
· `notes.py` (2/2) : GET HTML + roundtrip POST→GET (upsert persisté,
échappement HTML vérifié `&lt;b&gt;`)
· `sidebar_config.py` (2/2) : GET défauts, PUT persisté relu depuis
`users.sidebar_config`, 400 sans `config`, remise en état en fin de test
· `github_routes.py` (2/2) : status `{"linked": False}`, disconnect ok
- Reste (phase 2) : quasi nuls — `library.py` 1/10, `api.py` 3/23,
`dashboard.py` 17/63, `api_v2.py` 50/115
- Suite complète : **1047/1047**
## v7.10.0 (2026-10-01) — Audit : A21 phase 2b (api_v2 bouclé)
### Changed
- **Helper** `run_event_sync(coro)` (`app/services/automations.py`) : exécute
une coroutine d'événement depuis un handler synchrone — `asyncio.run` sur une
boucle dédiée dans le **worker threadpool** : bloqué = le worker, jamais la
boucle d'event, et la réponse n'est envoyée qu'une fois l'événement terminé
(déterministe, équivalent sémantique de l'`await` d'avant). Note
`ponytail:` : clients httpx créés à chaque appel partout → aucun lien de
boucle ; sinon `run_coroutine_threadsafe` + boucle du lifespan
- **A21 (phase 2b)** — 15 routes `api_v2` dont les seuls awaits étaient
`request.json` / `_fire_event` / `fire_published` / `fire_unpublished` →
paramètre `Body(default={})` + `run_event_sync(...)` + conversion en `def`
- **`api_v2` : 111/115 routes hors event loop** — il ne reste que 4 routes
async, toutes avec de vrais awaits réseau : `import_csv_v2` (multipart),
`project_tree_v2` (gitea), `test_webhook_v2`, `retry_webhook_deliveries`
- Repo-wide : **403 routes synchrones (hors loop) / 260 async** (phase 2c)
### Tests
- Ciblé (public_api_v2 + v65 + webhooks_v2 + audit) : 90/90 — les webhooks
prouvent la détermination de `run_event_sync` ; suite complète **1037/1037**
en 228 s
## v7.9.0 (2026-10-01) — Audit : A21 phase 2a (api_v2 hors loop)
### Changed
- **A21 (phase 2a)** — dans `api_v2`, les 36 routes dont le **seul** `await`
était `body = await request.json()` passent à un paramètre FastAPI
`body: dict = Body(default={})` (parsing async fait par FastAPI avant
l'appel) puis sont converties en `def` → threadpool. Équivalences vérifiées
avant-engagement : corps absent → `{}` (identique au `try/except` d'avant),
JSON invalide → **422** (avant : traité silencieusement comme `{}`),
zéro `body[...] = ` dans le fichier (le défaut partagé n'est jamais muté)
- `api_v2` : **96/115 routes hors event loop** (60 en phase 1 + 36 ici) ;
il ne reste que **19 routes async** dans ce router (`fire_event`,
`request.form`, appels gitea/webhooks — phase 2b)
- Bug de transformation évité en cours de route : première version du script
supprimait 5 lignes au lieu de 4 (`slice` fermant d'un cran trop loin) —
fichier restauré depuis git puis script corrigé, 0 ligne perdue (diff
logique : +39/-183 = 36 signatures + import, 4 lignes de try/except × 35)
### Tests
- Suite complète **1037/1037** (242 s) ; ciblée sur `test_public_api_v2` +
`test_v65` + audit : 62/62 verts avant la passe complète
## v7.8.0 (2026-10-01) — Audit : A21 phase 1 (SQLite hors event loop)
### Changed
- **A21 (phase 1)** — **352 routes** `async def` sans aucun `await` converties
en `def` : FastAPI les exécute alors dans son threadpool — tout leur travail
SQLite (`get_conn()` + `conn.execute`) quitte l'event loop, **sans changer une
ligne de logique** (la conversion est sémantiquement neutre : vérifié corps
par corps — aucun `await`/`async with`/`async for`/`asyncio` dans les
fonctions converties). Répartition : api_v2 60, dashboard 40, collections 25,
board 23, workspace 19, wiki 17, permissions 14, api 14, + 35 autres fichiers
- **Reste (phase 2)** — les 311 routes qui ont de vrais `await`
(`request.json()`, `fire_event`, httpx) : enrouler les blocs DB dans
`await anyio.to_thread.run_sync(...)` ; aucun wrapper partagé livré pour
l'instant (rien ne l'appellerait)
### Perf
- Dernière suite : 233 s (écarts précédents mesurés : 235-359 s) — les
handlers SQLite ne saturent plus la boucle pendant les tests
## v7.7.0 (2026-10-01) — Audit : A20 (CSP — nonce, partie 1)
### Security
- **A20** — `script-src` : `'unsafe-inline'` remplacé par `'nonce-<aléatoire par
requête>'`. Le middleware CSP génère le nonce dans une `ContextVar` avant
`call_next` (visible des templates via `{{ csp_nonce() }}`) ; **38 tags
`<script>` inline** des templates, la constante de module `LOCAL_LOGIN_HTML`
(helper `_with_nonce()` au rendu) et **3 scripts Python** dans `collections.py`
le portent ; htmx reçoit le même nonce via `<meta name="htmx-config">`
(`inlineScriptNonce` — les scripts des réponses boostées restent valides)
- Les 74 handlers `onclick=` inline restent fonctionnels via
`script-src-attr 'unsafe-inline'` (détaché de `script-src` : le nonce les
aurait désactivés aussi)
- `https://cdn.jsdelivr.net` / `https://unpkg.com` ajoutés à `script-src` et
`style-src` : les vues chart/map de `collections` les utilisent et étaient
**bloquées par la CSP depuis toujours** (commentaire `ponytail:` → upgrade :
vendoriser ces libs puis retirer les hôtes)
- Reste d'A20 : `unsafe-eval` (Alpine `x-data` en string → build
`@alpinejs/csp`), externalisation du JS inline (A27), resserrer
`img-src`/`connect-src`
### Tests
- `test_csp_nonce_per_request` : page `base.html` (meta htmx-config + nonce du
header identique sur tous les scripts inline, nonce différent d'une requête à
l'autre) et page hors template (`/auth/login?provider=local`)
## v7.6.0 (2026-10-01) — Audit : A31 (dette migrations)
### Fixed
- **A31** — transaction par migration : `_apply_one()` fait `BEGIN` → `fn(conn)`
→ marque `schema_version` → `commit`, rollback complet à l'échec. Avant, le
DDL sortait en autocommit (isolation_level legacy) : un échec au milieu
laissait un schéma partiel commité SANS ligne de version, et la reprise
rejouait un DDL déjà appliqué
- **A31** — helper unique `columns(conn, table)` (valide l'identifiant,
`ValueError` sinon) : **25 copies** de
`{r[1] for r in conn.execute("PRAGMA table_info(...)")}` éliminées dans
`migrations.py`. `table_exists`/`column_exists` préconisés par l'audit non
livrés : aucune migration n'interroge `sqlite_master`, un contrôle unitaire
se lit dans le set
### Tests
- `test_migration_transaction_rolls_back` (DDL partiel annulé + pas de marque
de version, chemin nominal enregistré), `test_columns_helper_validates_table_name`
## v7.5.0 (2026-10-01) — Audit : A29, A42 (partiel)
### Changed
- **A29** — `services/publish.py` partagé : les 3 paires publish/unpublish
(sharing = front, board, v2) déléguent ; 404 partout (board faisait une
mise à jour aveugle), slugify titré unique (board : aléatoire ; v2 : slug
fourni conservé), événements centralisés, board gagne le contrôle de session.
Les bonus divergents disparaissent (`share_mode='anyone'` pour board,
`is_shared=1` pour v2) : le share dialog reste l'unique propriétaire de ces
drapeaux, dépublier ne révoque donc pas un partage manuel. Les listings
`/users/me` ×2 et collections ×3 restent : contrats versionnés distincts
- **A42** — 9 copies de `Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))` →
`settings.data_dir` (property : lecture à chaque accès, les tests
monkeypatchent l'env) ; cache Gitea : évacuation des entrées expirées à
chaque écriture (il ne pouvait que grandir) ; les 29 `Environment(...)`
étaient déjà couverts par A10. **Reste** : client httpx partagé (52 créations,
à faire avec un cache par event loop)
### Security
- **Byproduct A29** — `GET /api/users/me` (v1) et le contexte Jinja de
`/accounts` renvoyaient `SELECT *` sur `users` : **password_hash**,
`login_attempts` et `locked_until` exposés → colonnes whitelistées
(identiques à la liste v2)
### Tests
- `test_publish_service_shared_and_safe` (slug, 404, partage préservé),
`test_users_me_no_secret_columns`, `test_gitea_cache_evicts_expired`
## v7.4.0 (2026-10-01) — Audit : A30, A37, A39, A40, A41
### Changed
- **A30** — `require_scope()` est enfin câblé : 69 sites stricts de `api_v2.py`
passent par la factory (Bearer + scope en un appel, contrôle manuel supprimé ;
les 4 variants `admin|is_admin` restent manuels, ce sont d'autres contrôles) ;
12 top-level morts supprimés (`unsync_block`, `find_referring`, `_b64url`,
`strip_markdown`, `format_number`, … — 0 référence app ET tests)
- **A37** — CORS : plus de `allow_origins/methods/headers = ["*"]` → origines
dérivées de `settings.app_base_url` + localhost/origines d'extension
(`allow_origin_regex`), méthodes et entêtes minutés, `allow_credentials=True`
explicite ; test `test_cors_no_star`
- **A40** — version d'assets à source unique : `{{ asset_version }}` (global
Jinja lu au boot depuis le fichier VERSION) ; les littéraux `?v=5.1.1`,
`?v=2.4.8`, `?v=6.0.0` de base.html éliminés ; `sw.js` n'existe plus (audit
obsolète) ; vendors gardent `?v=` = version de la lib (correct)
- **A41** — 91 règles CSS mortes purgées d'`app.css` : **-10 274 octets**
(121 618 → 111 344) — scan : classes définies dans app.css et absentes de
templates, JS, autres CSS et code Python
### Notes
- **A39 (htmx)** — décision « rien » : 32 attributs `hx-*` réels, conversion =
refonte du view-switching sans tests E2E ; à reconsidérer avec un test
automatisé du view-switch
## v7.3.9 (2026-10-01) — Audit : A26, A33, A34, A35, A36, A43
### Fixed
- **A26** — champ `standalone` mort supprimé (0 lecteur) ; `sqlite:////data/…`
ne produit plus un chemin UNC sous Windows ; `.env.example` ne promet plus
PostgreSQL (non supporté) ; **raise au boot** si `APP_SECRET_KEY` vaut encore
la valeur par défaut (il signe les sessions)
- **A33** — rate limit : préfixes manquants ajoutés (`/scim/v2/`, `/workspace/`,
`/db/`, plus le non-GET sur `/s/` et `/f/` sans pénaliser la lecture) ; la
limite vient de `settings.rate_limit_requests` (60 annoncés, 100 codés en dur) ;
clé = `X-Forwarded-For` uniquement derrière un proxy local ; `_store` épuré
(croissance mémoire bornée)
- **A34** — helper `_spawn()` pour les 10 schedulers : exception loggée +
redémarrage après 10 s (ils mouraient en silence) ; 2 `logger.debug` de
scheduler passés en `warning`
- **A35** — OpenAPI régénéré : 439 → **511 chemins**, `info.version 7.3.9` ;
README à jour (était v6.7.0) ; compteur de `API_GUIDE_V6.md` à jour ; titre
dupliqué retiré du ROADMAP
- **A36** — 4 dépendances mortes purgées de `requirements.txt`
(`aiosqlite`, `slowapi`, `loguru`, `packaging` = 0 import)
- **A43** — 15 `datetime.utcnow()` dépréciés → `now(UTC).replace(tzinfo=None)`
(format ISO naïf identique, zéro changement de comportement)
### Notes
- Le drift Python (Docker/CI/README 3.12 vs venv local 3.13) reste ouvert :
l'alignement à 3.13 implique un rebuild d'image à valider
## v7.3.8 (2026-10-01) — Audit : A25 (exceptions muettes) + A21 partiel
### Fixed
- **A25** — 84 `except Exception: pass/…` deviennent `logger.exception(fn)`
(19 fichiers, 63 dans des handlers `async`) : les échecs du pipeline
d'événements/webhooks et des écritures sont enfin visibles dans les logs
- **A25 (critique)** — plus de `try` autour de `materialize_properties` dans
`create_collection_v2` et `apply_db_template_v2` : un échec annule la
transaction au lieu de commiter une collection sans schéma
- **A21 (partiel)** — `PRAGMA busy_timeout=5000` dans `get_conn()` (le seul
point d'entrée des connexions) ; le wrapper async + les 510 call sites
synchrones sur l'event loop restent à migrer
### Tests
- `test_collection_rollback_when_materialize_fails` → suite **1028/1028**
## v7.3.7 (2026-09-30) — Audit sécurité : A14 (fallback admin agent)
### Fixed
- **A14** — `_current_user_id` et `_current_admin` ne retombent plus sur la
row `admin` : 401 sans session (les 24 sites de `_current_user_id` +
`PATCH/POST /api/agent/providers`) — un anonymous ne pouvait plus orienter le
`ping()` du serveur vers un `api_base` interne
- `_check_api_base()` sur les 2 routes provider : scheme `http(s)` obligatoire,
identifiants dans l'URL refusés (400). Les hôtes privés restent acceptés —
le provider par défaut du produit est Ollama `http://localhost:11434/v1`
( commentaire `ponytail:` : fermeture possible via allowlist provider local)
- Test `test_agent_providers_require_admin_and_valid_api_base` → suite **1027/1027**
## v7.3.6 (2026-09-30) — Audit sécurité : A19 terminé (aucun préfixe cookie-auth exempt)
### Fixed
- **A19 (fin)** — les 46 appels non-GET restants des 5 derniers préfixes
(`/api/agent`, `/api/settings`, `/api/local-workspace`, `/api/gitea`,
`/api/workspace` + `/api/workspaces`) reçoivent `X-CSRF-Token` (expression
cookie en ligne, portée indifférente fonction/Alpine/attribut) ; les 5
préfixes sortent d'`EXCLUDED_PATHS`
- Vérification syntaxe : les `<script>` des 39 templates passent `node --check`
(interpolations Jinja neutralisées) — 0 échec avant/après
- Tests : `anon_csrf()` là où le 403 CSRF masquait le 401 attendu, paire
CSRF sur le TestClient jetable de `test_sessions_listed_and_revocable`
- suite **1026/1026** · `ruff check app tests` OK — la liste CSRF ne contient
plus que du Bearer, des callbacks `/auth/*`, des pages publiques et de l'infra
## v7.3.5 (2026-09-30) — Audit sécurité : A19 (partiel) — CSRF réduit aux vrais cas
### Fixed
- **A19 (partiel)** — 12 préfixes sortis de `EXCLUDED_PATHS` après scan des
appels non-GET du front (tous envoient déjà `X-CSRF-Token`) : `/db/`,
`/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`,
`/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`,
`/api/github`, `/api/admin`, `/api/onboarding` — les 2 fetch de
`welcome.html` équipés du header
- La liste ne garde que Bearer/webhooks/callbacks/pages publiques + les 5
préfixes dont le front n'est pas encore équipé (`/api/workspace`,
`/api/local-workspace`, `/api/settings`, `/api/gitea`, `/api/agent`)
- Helper `anon_csrf()` en test (anonyme + CSRF valide → on mesure le 401 de la
route, pas le 403 du middleware) → suite **1026/1026**
## v7.3.4 (2026-09-30) — Audit sécurité : A16 (ACL sur lectures)
### Fixed
- **A16** — `_load_page_or_404` (4 exports) et les 2 routes pièce jointe
(`/download`, `/file-content`) passent par session + `PermissionManager.can_view_page`
→ 401 sans session, 404 hors ACL ; la lecture legacy `board.py` était déjà
couverte par A7
- Test `test_exports_and_attachments_require_auth` → suite **1026/1026**
## v7.3.3 (2026-09-30) — Audit sécurité : A12–A24 (SSRF, auth legacy, uploads, perf)
### Fixed
- **A12** — unfurl OG : `follow_redirects` manuel + `_is_public_host` à chaque
saut → 400 vers loopback/link-local (ex. `169.254.169.254`)
- **A13** — automations : `Depends(_require_session)` sur le router entier
(CRUD, run, press-button) + action `webhook` validée avant POST
- **A15** — webhooks sortants : admin exigé + URL publique (SSRF scheduler)
- **A17** — router legacy `/api` : session ou Bearer (`/api/v1`) ; allowlist
explicite `/api/health`, `/api/frontend-error`
- **A22** — uploads locaux : session exigée, `validate_upload` branché (10 MB +
extensions), `FLOWDECK_DATA_DIR` remplace le `/data` codé en dur
- **A23** — N+1 : `GROUP BY` (compteurs de pages), `executemany` (cards de sync
+ duplicata de propriétés avec remap d'ids vérifié)
- **A24** — 2 routes silencieusement écrasées supprimées + test « aucun doublon
méthode+chemin » sur les 680 routes
### Tests
- +9 non-régressions dans `tests/test_audit_p0_fixes.py` → suite **1025/1025**
## v7.3.2 (2026-09-30) — Audit sécurité : A11 + A18
### Fixed
- **A11** — `GET /api/settings/avatar/{filename:path}` : `resolve()` +
`relative_to()` (le motif de `serve_uploaded_file`) → 403 hors `/data/avatars`
- **A18** — `GET /workspace/public/{id}` : 404 explicite pour les bases
`restricted`/`private` (`permission_type`) et `html.escape` sur nom, icône et
titres de lignes — ce f-string HTML ne passe pas par Jinja2, donc l'autoescape
A10 ne le couvrait pas
- Tests : `tests/test_audit_p0_fixes.py` (3 non-régressions) — suite **1019/1019**
## v7.3.1 (2026-09-30) — Audit sécurité P0 : A1–A10
> Corrections du bloc critique de l'audit du 2026-09-30 (ROADMAP) : plus aucune
> route cookie-auth n'accepte un anonymous, et Jinja2 échappe enfin sa sortie.
### Fixed
- **A1/A2** — deps `pyotp`/`webauthn`/`cbor2` installées, rebinding de
`app.config.settings` supprimé dans `test_v54.py` (isolation rétablie) ;
cycle v6.8→v7.3 committé + tag `v7.3.0`
- **A3** — `PUT /api/user/password` : 401 sans session + `current_password`
exigé ; helper `_require_user_id()` sur profile/password/token/forge ; `/api/user`
sorti de la liste CSRF exemptée
- **A4** — `POST /api/v1/token` et `POST /api/user/token` : 401 sans session,
chemin legacy `user_id=0` supprimé
- **A5** — CRUD membres d'espace : session + rôle admin de l'espace (ou admin
global), placeholder user créé en `is_admin=0`
- **A6** — `_require_view` → 404 / `_require_edit` → 401 sans session (fin du
legacy single-user sur les collections)
- **A7** — création ET lecture de page → 401 sans session (`PermissionManager`),
`/board/api/pages` sorti du CSRF exempt (+ header manquant côté local workspace)
- **A8** — seed admin sans mot de passe codé en dur : aléatoire au premier boot
(loggé une fois) ou `FLOWDECK_ADMIN_PASSWORD`
- **A9** — `.db`/fichiers de test désindexés + `.gitignore`/`.dockerignore`,
rotation de `APP_SECRET_KEY`
- **A10** — `app/templating.py` : un seul `ENV` avec
`autoescape=select_autoescape(["html"])`, 29 instantiations remplacées ;
re-tri des `|safe` (corps d'issue + commentaires échappés, `sidebar_config`
en `|tojson`)
### Tests
- Client de test connecté par défaut (`_TestSessionAuth` : session + CSRF
injectés hors cookie jar) + helper `anon()` sur les 40 tests d'anonymat
- Suite complète : **1016 passed / 0 failed** · `ruff check app tests` OK
## v7.3.0 (2026-09-29) — Wiki / Teamspaces + Polish (dernière version du cycle v7)
> Connaissance vérifiée et finition collaborative : teamspaces, badge ✅ avec
> expiration, guests sans compte, réactions, follows, analytics de page et
> trois nouveaux blocs rendus côté serveur. Design : `docs/V73_Wiki_Teamspaces_Polish.md`.
### Added
- **Teamspaces** — `app/services/wiki.py` + `app/routers/wiki.py` : `teamspaces`
(`workspace_id`, `private`, `UNIQUE(workspace_id, name)`) et
`teamspace_members` (rôles `owner`/`editor`/`commenter`/`viewer`) ; CRUD +
listing par workspace, ajout/retour de membres ; `private=1` → **404** (pas
403) pour les non-membres, comme les collections restricted ; un teamspace
public reste cantonné au workspace (pas de `viewer` implicite pour un compte
qui n'en est pas membre) ; `pages.teamspace_id` + `collections.teamspace_id`
- **Verified pages** — `page_verifications` (badge ✅, `verified_by`, `note`,
`expires_at` 90 j par défaut, re-vérifier remplace) ; l'index
`GET /api/v2/wiki/verified` exclut les badges expirés et les pages de
teamspaces privés ; sweep `POST /api/v2/wiki/verify-expiry-sweep` (admin)
notifie le vérificateur à J-7 (`page.verification_expiring`) ; la
vérification exige un rôle editor/owner/admin, sinon 403
- **Guests sans compte** — `guest_shares` (`token`, `role viewer|commenter`,
`expires_at`, `revoked`) ; accès par `GET /g/{token}` **sans session**,
enregistrement d'une vue, révocation idempotente ; page 404 HTML dédiée au
lieu d'une redirection vers `/workspaces`
- **Collab polish** — `comment_reactions` (agrégation par emoji + users,
toggle), `page_follows` (toggle + followers), `page_views` (compteurs
journaliers, séries sans trou via `view_stats`)
- **Wiki Home** — `GET /api/v2/wiki/home` (teamspaces + verified + recents)
- **Blocs** — `app/services/wiki_blocks.py` : `mermaid` (SVG inline si
`mmdc` est installé, sinon `<pre class="mermaid">` rendu côté client),
`equation_inline` (KaTeX, source sanitizée : `<`, `>`, `\` retirés pour
empêcher la fermeture anticipée du délimiteur ou l'injection de balises),
`progress` (agrégation directe sur `property_values_json` → barre %) ; les
trois sont rendues par `export.blocks_to_html` (donc présentes dans
l'export HTML/PDF) et exposées via `POST /api/v2/wiki/blocks/preview`
- **Migration 29** — `teamspaces`, `teamspace_members`, `page_verifications`,
`comment_reactions`, `page_follows`, `guest_shares`, `page_views` +
colonnes `teamspace_id` sur `pages`/`collections`
- **Sidebar teamspaces** — section `Teamspaces` dans `base.html` (état Alpine
`teamspaces`/`loadTeamspaces`/`openTeamspace`, section ouverte par défaut,
icône/label/ordre, fallback du panneau de personnalisation) →
`GET /api/v2/wiki/teamspaces` accepte désormais l'omission de `workspace_id`
(listing cross-workspace avec `workspace_name`), page HTML
`GET /wiki/teamspaces/{id}` (pages + collections du teamspace, rôle affiché) ;
entrée `teamspaces` dans `sidebar_config.DEFAULT_CONFIG`
- **Notif `page.updated` aux followers** — `wiki.notify_followers_of_page_update`
(une par 10 min, `actor_id` exclu) branchée dans `automations.fire_event` ;
payloads `actor_id` ajoutés dans `board.update_page` et `board.save_page_blocks` ;
commenter une page = suivre (auto-follow `wiki.ensure_follow`, défaut ON)
- **Charts avancés** — type `number` (KPI) avec agrégats `count|sum|avg|min|max`
dans `collections._render_chart` (+ `_chart_aggregate`/`_fmt_number`) ; le « 0 »
n'est plus forcé à 1 ; les dashboards multi-DB se rendent via
`GET /db/{collection_id}/dashboards/{dashboard_id}` (widgets `collection_id`,
≤ 40 widgets, ≤ 200 lignes/chart) ; `view_collection` choisit maintenant la
config de vue correspondant au `view_type` demandé
- **Unfurl `gitea:`/`github:`** — `POST /board/api/og/metadata` résout les refs
`gitea:owner/repo` / `github:owner/repo` via `GiteaClient.get_repo_info`
(ajouté) ou `GitHubAdapter` (token optionnel, sinon API publique) sans
télécharger la page ; champs bookmark `url/title/description/image/site_name`
conservés dans l'autosave du bloc
- **UI Settings → Audit** — `settings.html` : l'onglet `admin-audit` interroge
désormais `/api/v2/audit/logs` (sources `api`/`permissions`/`sso`, filtres
`actor`/`action`, pagination « Load more », export CSV)
- **SSO 21 casses** — dépendances `python3-saml==1.16.0` + `authlib==1.8.0`
(plus `xmlsec`/`isodate`/`joserfc`) installées → `test_v67_sso.py` **38/38**
- **Sécurité** — `sanitize_equation` retire désormais `<`/`>`/`\` (pas de
breakout KaTeX/markup), `revoke_guest` 404 fondé sur l'existence, pas le
rowcount
### Tests
- `tests/test_v73_wiki_polish.py` : **58 → 72 tests** (sidebar cross-workspace +
page teamspace owner/outsider, auto-follow par commentaire, notif
`page.updated` throttlée + acteur exclu, `ensure_follow` idempotent, regex +
unfurl gitea/github + endpoint `/board/api/og/metadata`, KPI + `_chart_values`
(0 conservé) + dashboards multi-DB + 404)
- `ruff check app tests` OK · suite complète `python -m pytest -n auto` :
**1016 passed** (était : 981 passed / 21 failed en SSO avant install des deps)
---
## v7.2.0 (2026-09-29) — Enterprise admin : SCIM 2.0, 2FA, Audit, gouvernance agents
> Le socle administration d'une instance auto-hébergée en équipe : provisionnement
> SCIM depuis l'IdP, TOTP + passkeys, journal d'audit unifié et garde-fous
> d'exécution pour les agents. Design : `docs/V72_Enterprise_SCIM_2FA.md`.
### Added
- **SCIM 2.0** — `app/routers/scim.py` : `GET/POST /scim/v2/Users`,
`GET/PUT/PATCH/DELETE /scim/v2/Users/{id}` (Bearer `scim_tokens`, schémas
core:2.0:User, `active` → `users.is_active` + révocation `user_sessions`,
`Operations` PATCH `active`/`userName`) ; tokens SHA-256 stockés, affichés
une seule fois, révocables (`/api/v2/scim/tokens`) ; exempté CSRF (clients
IdP sans cookie) et 404 `application/scim+json` au lieu d'une redirection
- **TOTP 2FA** — `app/services/two_factor.py` : secret chiffré Fernet au repos,
10 codes de secours à usage unique (SHA-256), Défi `pending` signé 5 min ;
`POST /auth/local-login` renvoie `2fa_required` sans créer de session, puis
`POST /auth/local-verify` l'échange contre une session ; setup/activate/
disable/status côté utilisateur
- **Passkeys WebAuthn** — `app/routers/webauthn.py` : enregistrement
(`register/begin|finish`, attestation vérifiée, COSE stocké) et connexion
**sans mot de passe** (`login/begin|finish`, anti-rejeu `sign_count`,
vérif. origine/RP) ; listing + suppression des clés
- **Domain claims** — `POST /api/v2/domain-claims` (normalisation lowercase,
jeton `.well-known/flowdeck-verify.txt`), `POST .../verify` (fetch HTTPS du
domaine + comparaison), `enforce_sso` qui bloque le login local par domaine
dans `auth.local_login` (les admins gardent l'accès local) ; jeton jamais
renvoyé par le listing
- **Audit unifié** — `app/routers/audit.py` : `GET /api/v2/audit/logs` fusionne
`api_audit_log` + `permission_audit_log` + `sso_login_history` en un schéma
commun, filtres `source`/`actor`/`action`, pagination, export
`?format=csv` (admin ou Bearer `read:admin`)
- **Gouvernance des agents** — `app/services/agent_policies.py` + `app/routers/governance.py` :
`agent_policies` (liste d'outils autorisés par workspace, `max_steps`,
`require_approval`) consultée par `AgentEngine` **avant** les ACL, file
`agent_approvals` pour les écritures, décision admin
(`/api/v2/agent-approvals/{id}/decide`) + événement
`agent.run.approval_requested`
- **Migration 28** — `scim_tokens`, `domain_claims`, `webauthn_credentials`,
`agent_policies`, `agent_approvals` + `users.totp_secret_enc`,
`users.totp_backup_hashes`
### Fixed
- Les 404 sur `/scim/v2` et `/auth/webauthn` renvoyaient une redirection 302
vers `/workspaces` (handler global) : ils retournent désormais du JSON, et
`/scim/v2` répond en `application/scim+json`
- `/scim/v2` et les routes 2FA/WebAuthn ajoutées à la liste d'exclusion CSRF
(authentification Bearer, pas de cookie de session)
### Tests
- `tests/test_v72_enterprise.py` : **52 tests** (migration 28, SCIM tokens/CRUD/
suspend/duplicate/404, 2FA setup-activate-verify-backup-chiffrement-désactivation,
challenges, domain claims, WebAuthn, audit multi-source + filtres + CSV +
pagination, politiques et gate d'approbation)
- `ruff check app tests` OK · `tests/test_agent.py` + `tests/test_app.py` :
**261 verts** (la gouvernance ne casse pas l'engine)
---
## v7.1.0 (2026-09-28) — Calendar sync + Meeting Notes
> Calendrier bidirectionnel Google/CalDAV + transcription → résumé IA qui
> déclenche les agents (pattern Notion 07/2026). Design : `docs/V71_Calendar_Meetings.md`.
### Added
- **Sync bidirectionnelle** — `app/services/calendar_sync.py` : `calendar_links`
(tokens Fernet, `collection_id`, `date_property`), pull (event → ligne datée +
`external_event_id`) + push, boucle 15 min dans le lifespan ; conflits
(édité des 2 côtés → last-write-wins + notif `calendar.conflict`) ; lignes
touchées par le pull jamais repoussées ; API CRUD + `POST .../sync`
(`app/routers/meetings.py`, session ou Bearer `write`, tokens jamais leakés)
- **Sans dépendance** — Google Calendar REST (401 → « relink »), CalDAV brut
(REPORT + parseur multistatus, PUT) ; I/O module-level monkeypatchables
- **Meeting Notes v2** — `app/services/meetings.py` : upload audio 100 MB
(mp3/wav/m4a/ogg/flac/aac), transcription `STT_COMMAND` ou transcript manuel,
résumé `ai_writing.summarize` (offline-capable) → trigger `meeting.summarized`
(branché automations v7.0) ; endpoints upload/texte/summarize
- **Free/busy** — `GET /db/{id}/calendar/freebusy` (busy + free weekdays,
récurrences expandues, 1..370 j)
- **Migration 27** — `calendar_links`, `meeting_transcripts`,
`collection_pages.external_event_id` + index
### Tests
- `tests/test_v71_calendar_meetings.py` : **15 tests** (migration, links,
pull/push/idempotence/conflit + notif, 502, CalDAV, freebusy, meetings ×5)
- `ruff check app tests` OK · suite **871 verts** (21 échecs `test_v67_sso.py`
pré-existants — `onelogin` absent de l'environnement)
---
## v7.0.0 (2026-09-28) — Automations v2 + Workers lite
> Du if-this-then-that aux chaînes multi-triggers + custom code sandboxé,
> parité Notion Automations + Workers. Design : `docs/V70_Automations_Workers.md`.
### Added
- **Automations multi-étapes** — `automation_steps` (trigger/condition/delay/action
ordonnés) : CRUD `GET/POST /workspace/automations/{id}/steps`,
`PUT/DELETE /workspace/automations/steps/{id}` (session, validation serveur),
`PUT .../mode` (`any` défaut / `all` fenêtre 5 min) ; conditions AND réutilisant
`match_condition_props` ; `form.submitted` déclenchable ; legacy sans steps intact
(matcher legacy ignore les automatisations à steps → pas de double run)
- **Nouvelles actions** — `slack` (incoming webhook, URL chiffrée Fernet au repos,
décryptée à l'exécution), `email` (`user:<id>` résolu ou reply-to créateur,
repli propre sans SMTP), `forge_issue` (Gitea via `GiteaClient` / GitHub API,
token `user_oauth_tokens`), `agent_trigger` (conversation + run `AgentEngine`),
`delay` (0..86400s validé, sleep plafonné 300s) ; interpolation `[[prop]]` /
`{{title}}` conservée ; backends module-level = monkeypatchables
- **Bouton DB natif** — type `button` (`PROPERTY_TYPES`), `button_automation_id`,
`POST /api/automations/press-button` (CSRF-exempt, 400 explicites)
- **Workers lite** — `app/services/workers.py` (lint AST : imports réseau/OS,
`open/exec/eval`, dunders ; builtins restreints ; thread + timeout 30s ;
budget journalier ; fork des partagés) + `app/routers/workers.py`
(CRUD `/api/v2/workers*` session/Bearer `write`, code masqué aux non-owners,
run/runs/fork/usage) ; crons branchés sur la boucle scheduler 60s
- **Migration 26** — `automation_steps`, `workers`, `worker_runs`,
`automations.trigger_mode`, `collection_properties.button_automation_id`
### Tests
- `tests/test_v70_automations_workers.py` : **31 tests** (migration, steps,
any/all, chaînes, delay, 4 nouvelles actions, secret chiffré, button,
legacy, workers ×11)
- `ruff check app tests` OK · suite **855 verts** (21 échecs `test_v67_sso.py`
pré-existants — `onelogin` absent de l'environnement ; 1 flaky parallèle
`test_env_config_fallback…` qui passe isolé)
---
## v6.9.0 (2026-09-28) — Recherche sémantique + Ask AI
> Retrouver (hybride lexical + vectoriel) et demander (RAG avec citations),
> parité Notion Enterprise Search + AI Q&A. Design : `docs/V69_Search_Ask_AI.md`.
### Added
- **Moteur sémantique** — `app/services/semantic_search.py` : chunking chevauchant
(1200 cars / overlap 150, récursif dans `children`), encodeur hashed-TF `hash-256`
(md5 % 256, L2, déterministe, zéro dépendance, `embed_texts()` pluggable),
cosinus pur Python, fusion RRF (k=60), job incrémental `index_pending()` (batch 50,
scheduler 5 min dans le lifespan) + `purge_orphans()`
- **Recherche hybride** — `GET /api/v2/search/hybrid` (`app/routers/search_ai.py`,
session ou Bearer `read`) : lexical FTS5/LIKE + vecteurs, filtre `workspace_id`,
scope membership, `PermissionManager` (pages `restricted` masquées),
`search_excluded` respecté, pagination + `X-Total-Count`
- **Ask AI** — `POST /api/v2/search/ask` : top-8 chunks autorisés (ACL **avant** prompt),
`LLMClient.complete()` si provider configuré sinon extractif offline avec
`[[fdpage:ID]]`, citations résolues (titres, « Deleted page » gérée), cache 10 min,
rate-limit 30/min, `api_audit_log`
- **Observabilité** — `GET /api/v2/search/index-status` (ressources, vecteurs, modèle)
- **Migration 25** — `semantic_embeddings`, `semantic_index_state`,
`pages.search_excluded`
### Tests
- `tests/test_v69_search_ask.py` : **24 tests** (migration, chunk/overlap, déterminisme,
cosinus, index idempotent, exclusion, purge, rappel partiel, hybride ×7, ask ×6,
index-status)
- `ruff check app tests` OK · suite **825 verts** (seuls 21 échecs `test_v67_sso.py`
pré-existants — `onelogin` absent de l'environnement)
---
## v6.8.0 (2026-09-28) — Sites & Forms publics
> Parité Notion Sites + Forms : publier un mini-site multi-pages et collecter
> des réponses anonymes dans une database. Design : `docs/V68_Sites_Forms.md`.
### Added
- **Sites multi-pages** — `app/routers/sites.py` : CRUD `GET/POST/PATCH/DELETE /api/v2/sites`
(session ou Bearer + scope `write`, pagination `X-Total-Count`, `api_audit_log`),
`GET/POST/DELETE /api/v2/sites/{id}/pages` (arbre ordonné, racine protégée),
`GET /api/v2/sites/{id}/stats` (vues jour + total) ; rendu public `GET /s/<slug>` +
`GET /s/<slug>/<page-slug>` (nav latérale, thèmes light/dark, blocs/synced/wiki résolus)
- **Gating & SEO** — mot de passe (`password_utils` salé, cookie signé 24h,
`GET|POST /s/<slug>/auth`), expiry (410), `noindex`, OG/Twitter cards,
`GET /s/<slug>/sitemap.xml`, domaine custom via header `Host`
- **Forms publics** — `PUT/GET /api/v2/collections/{id}/form` (`enabled`, `public_token f_*`,
`fields`, `required`, `success_message`, `notify_user_ids`) ; `GET /f/<token>`
(formulaire no-auth, `?embed=1` sans chrome) + `POST /f/<token>` (anonyme :
rate-limit 20/h/IP, honeypot, validation `validate_property_rule`) ;
chaque soumission = `collection_pages` + `form_responses` (ip_hash jour, pas d'IP),
notif in-app + trigger `form.submitted`
- **Migration 24** — tables `sites`, `site_pages`, `site_views`, `form_responses`,
colonne `collections.form_config_json`
- CSRF exempte `/s/` + `/f/` (soumissions anonymes cross-site)
### Tests
- `tests/test_v68_sites_forms.py` : **20 tests** — migration 24, CRUD, slug/conflit,
auth 401, isolation inter-users, pages add/remove, rendu home/subpage/blocs, 404,
vues comptées, password gate, expiry 410, sitemap, noindex, form config/submit JSON,
required 400, 404, honeypot, rate-limit 429, embed
- `ruff check app tests` OK · pas de régression (801 verts ; seuls 21 échecs
`test_v67_sso.py` pré-existants — dépendance `onelogin` absente de l'environnement)
---
## v6.7.0 (2026-09-24) — SSO / SAML + OIDC entreprise (Enterprise Auth)
> Dernière feature de la roadmap v6.0.0 : authentification fédérée via un IdP
> d'entreprise (SAML 2.0 ou OpenID Connect + PKCE), auto-provisioning des
> comptes, mapping des groupes de l'IdP vers les rôles workspace, mode
> « SSO only » et configuration admin dans Settings. Design :
> `docs/V6_SSO_SAML_Enterprise_Auth.md` (checklist §9 cochée).
### Added
- **SAML 2.0 SP** — `app/auth/providers/saml_provider.py` (python3-saml 1.16.0) :
login SP-initié `GET /auth/saml/login` (RelayState = id AuthnRequest + jeton CSRF
en DB), ACS `POST /auth/saml/callback` (signature, audience, destination,
InResponseTo, anti-replay `sso_requests`), SP metadata `GET /auth/saml/metadata`,
SLO `GET|POST /auth/saml/logout` (relais LogoutRequest/Response vers l'IdP) ;
`allowSingleLabelDomains` activé pour les hôts homelab/LAN
- **OIDC + PKCE** — `app/auth/providers/oidc_provider.py` (authlib 1.8.0) :
`GET /auth/oidc/login` (`state` + `code_verifier` stockés en DB),
`GET|POST /auth/oidc/callback` (code → token → userinfo ; ID token vérifié via
JWKS : aud/iss/nonce/exp), discovery + JWKS mis en cache 1 h,
`GET|POST /auth/oidc/logout` (`end_session_endpoint` si présent)
- **Provisioning** — `app/services/sso_provisioning.py` : création automatique de
l'utilisateur au 1er login (`auth_method=saml|oidc`, lien via email), rôle par
défaut + `default_workspace_id`, mapping groupes IdP → rôles workspace
(re-synchronisé à chaque login + `POST /api/v2/sso/sync`), mode **SSO only**
(login local refusé sauf administrateurs — design §7.1), secrets chiffrés
Fernet dans `sso_config` (champ secret vide = conserver l'existant), détection
de rejeu via `sso_requests` (TTL 15 min), `sso_login_history` (succès + échecs),
`safe_next_path` (pas de redirection externe)
- **Routeur** — `app/routers/sso.py` : parcours navigateur + API
`GET|POST|PUT|DELETE /api/v2/sso/config` (admin, header `X-CSRF-Token`,
`audit_log`), `GET /api/v2/sso/providers` (public, boutons de la page de login),
`GET /api/v2/sso/workspaces`, `POST /api/v2/sso/sync`, `GET /api/v2/sso/history` ;
CSRF exclut les préfixes `/auth/saml` et `/auth/oidc` (POST IdP cross-site)
- **Migration 23** — tables `sso_config`, `sso_login_history`, `sso_requests`
- **UI admin** — onglet « SSO / Enterprise » dans Settings : statut, config
SAML (Entity ID, SSO/SLO URL, certificat PEM, mapping attributs JSON, signature
des AuthnRequests) et OIDC (issuer, client id/secret, scope), provisioning
(auto-provision, SSO only, espace par défaut), tableau groupes → rôles,
URL des métadonnées SP copiable, bouton « Re-sync des groupes », désactivation ;
bouton SSO sur la page de login (nom dynamique, masqué si non configuré)
- **`PermissionManager`** (design §7.2) — `is_sso_only_workspace()`,
`get_sso_roles()`, `sync_sso_permissions()` (les grants SSO sont des lignes
`workspace_members` explicites ; le fallback « viewer » implicite n'en est pas un)
- **Help** — section « Enterprise SSO » + badge `.help-badge.sso` dans `/help`
- **`.env` (fallback bootstrap)** — `SSO_PROVIDER`, `SSO_NAME`, `SSO_ONLY`,
`SSO_AUTO_PROVISION`, `SSO_ENTITY_ID`, `SSO_SSO_URL`, `SSO_SLO_URL`,
`SSO_X509_CERTIFICATE`, `SSO_SIGN_REQUESTS`, `SSO_ISSUER_URL`, `SSO_CLIENT_ID`,
`SSO_CLIENT_SECRET`, `SSO_SCOPE`, `SSO_ATTRIBUTE_MAPPING`, `SSO_GROUPS_MAPPING`,
`SSO_DEFAULT_WORKSPACE_ID` (la config admin prime sur l'environnement ;
section ajoutée dans `.env.example`)
- **Dépendances** — `python3-saml==1.16.0`, `authlib==1.8.0`, `cryptography>=42.0`
### Changed
- `/logout` délègue la déconnexion à l'IdP (SLO) quand la session vient de SAML,
puis détruit la session locale ; sinon comportement existant
- OpenAPI régénéré : `docs/openapi-v2.json` → **439 chemins**, `info.version = 6.7.0`
(operation IDs uniques — les routes multi-méthodes sont enregistrées en deux
routes mono-méthode)
### Tests
- `tests/test_v67_sso.py` : **38 tests** — API de configuration admin
(validation, CSRF, secret jamais renvoyé, secret vide préservé, réinitialisation,
DELETE), métadonnées SP, login SAML complet contre un IdP mock signé (y compris
SLO), OIDC complet (discovery/token/userinfo/JWKS mockés), cas négatifs
(mauvais audience/issuer/destination/nonce/state, rejeu, signature altérée,
assertion expirée), auto-provisioning, group mapping (nom → rôle workspace),
sso_only, historique, rate limit, endpoint public `providers`, extension
`PermissionManager`
- `pytest -n auto` → **802 passed, 0 skipped** · `ruff check app tests` OK · `eslint static/js` 0 problème
---
## v6.6.0 (2026-09-24) — Agent phase 5 : API publique agent & skill marketplace
> Dernière phase du plan agent en 5 phases (« Plateforme ») : l'agent devient
> pilotable par des intégrations tierces via `/api/v2`, et les skills deviennent
> partageables (export/import portable + galerie de presets installables).
### Added
- **API publique agent** — `app/routers/api_v2_agent.py` (15 routes, Bearer + scopes `read`/`write`,
rate limit par token, idempotence, `api_audit_log`) :
- `GET/POST /api/v2/agents`, `GET/PUT/DELETE /api/v2/agents/{id}`
- `GET/POST /api/v2/agents/conversations`, `GET/DELETE .../{id}`, `GET .../{id}/actions`
- `POST /api/v2/agents/conversations/{id}/run` — **run synchrone JSON** (le flux SSE reste interne) :
`{status, final, error, reasoning[], actions[], events[], duration_ms}` (500 si `failed`)
- `POST /api/v2/agents/{id}/trigger` — déclenchement externe d'un agent custom
- propriété des conversations vérifiée par `user_id` → `404` pour un token tiers
- **Marketplace de skills** — `app/services/skill_gallery.py` (source unique pour l'interne et le v2) :
- `GET /api/v2/skills/{id}/export` → document portable `{format: "flowdeck-skill", version: 1, skill{…}}`
(aucun id/workspace/auteur local) ; `POST /api/v2/skills/import` (`409` sur collision, `overwrite: true` pour écraser)
- `GET /api/v2/skills/gallery` + `POST /api/v2/skills/gallery/{slug}/install` — **6 presets** :
rapport hebdo, CR de réunion, base CRM, OKR, analyse repo Gitea, résumé de document
- `GET/POST/DELETE /api/v2/skills`, `GET /api/v2/skills/{id}`, `POST /api/v2/skills/{id}/apply`
- routes jumelles session : `GET /api/agent/skills/gallery`, `POST .../gallery/{slug}/install`,
`POST /api/agent/skills/import`, `GET /api/agent/skills/{id}/export`, `DELETE /api/agent/skills/{id}`
(le CRUD skills n'avait **aucune** suppression)
- **UI** : section « Galerie » dans la palette `/` du panneau agent → installation + épinglage du chip
- **Webhooks de cycle de vie agent** — `agent.run.started` et `agent.run.failed` émis (seul
`agent.run.finished` l'était) via `_fire_agent_webhook()` ; les 3 sont déjà au catalogue → abonnement `agent.*`
### Changed
- **Docs** — `docs/API_GUIDE_V6.md` : nouveau §2.4 (tables d'endpoints + règles agent) ; nom d'événement
corrigé `agent.run.completed` → `agent.run.finished` ; `ROADMAP.md` : 5 phases agent toutes cochées + section v6.6.0
- **OpenAPI** — `docs/openapi-v2.json` régénéré : **427 chemins** (was 402), `info.version = 6.6.0`
- **Version** — 6.6.0 (`VERSION` + `app/main.py`)
### Tests
- `tests/test_v66_agent_api.py` : **15 tests** (auth/scopes, CRUD agents, idempotence, run synchrone,
ownership 404, trigger, export/import/validation, galerie + validité des outils des presets,
routes internes, cycle de vie webhooks started→finished et started→failed)
- `pytest -n auto` → **764 passed, 0 skipped** · `ruff check app tests` OK · `eslint static/js` 0 problème
## v6.5.1 (2026-09-24) — Tests webhooks_v2 complets + roadmap rattrapée
> Les 7 tests d'intégration Webhooks v2 (stubs `@pytest.mark.skip` depuis
> v5.15.0) sont désormais réellement câblés, et le ROADMAP rattrapé sur ce
> qui avait été livré entre-temps.
### Added
- **7 tests d'intégration webhooks** (`tests/test_webhooks_v2.py`) : retry échec→succès (journal `retrying→retrying→delivered`), échec après `MAX_ATTEMPTS` (4 POST + `failed` final), `fire_event` ignore les events inconnus, aucun abonné → aucun appel, fan-out wildcard (`page.*` reçoit, `collection.created` non), flow complet signé bout en bout (HMAC vérifié sur le body reçu + journal `delivered`), `retry_due_deliveries` (row `retrying` périmée → re-fire → `delivered`, original `superseded`)
- **Fixture `db` isolée** — SQLite temporaire par test (env + singleton `settings` restaurés, safe `-n auto`), tables webhook créées par `init_db()` ; `httpx.MockTransport` injecté via stub module + `RETRY_DELAYS=(0,0,0)` (zéro sleep réel)
### Changed
- **ROADMAP** — case « migration `sync.py` vers Bearer » cochée (livrée v6.4.0 : Bearer-first + repli session PWA) ; ligne v5.15.0 mise à jour ; résumé final → « Reste: SSO/SAML » ; **suite 749 verts, 0 skip**
### Tests
- `pytest -n auto` → **749 passed, 0 skipped** · `ruff check app tests` OK · `eslint static/js` 0 problème
## v6.5.0 (2026-09-24) — Synced blocks production (databases & vues)
> Les synced blocks passent en production et existent enfin dans les databases :
> chaque ligne de database gagne une page contenu (éditeur complet), la lecture
> résout la source côté serveur (zéro cache périmé) et la mise à jour d'une
> source réécrit réellement toutes les pages référentes.
### Added
- **Page contenu par ligne de database** — migration 22 (`pages.collection_row_id INTEGER REFERENCES collection_pages(id) ON DELETE CASCADE` + index) ; `app/services/row_pages.py` : `ensure_row_page()` (création lazy, réparation si trashée, workspace hérité de la page hôte, `collection_id` hérité pour les ACL) ; sync de titre bidirectionnelle ligne ↔ page
- **Endpoint `GET /db/pages/{row_id}/open/api`** — renvoie le `page_id` de contenu d'une ligne (garde `_require_view` collection) ; le peek de toute vue DB (table/board/gallery/list/calendar) passe par là
- **Résolution serveur à chaque lecture** — `resolve_content_json()` sur les 2 routes `/pages/{id}` (dashboard + board), `GET /api/pages/{id}/content`, `GET /api/local-workspace/page-content/{id}`, page publique `/p/{slug}`, `GET /api/v2/pages/{id}` et les rooms realtime (`load_room`)
- **Rendu public des synced blocks** — branche `synced` dans `_render_blocks_public` (avant : JSON brut en `<p>`) + état « Deleted synced block »
- **Éditeur** — état « Deleted synced block » quand la source a été supprimée (au lieu du cache fantôme / « Loading… » éternel)
- **API v2** — `content_page_id` sur `GET /api/v2/pages/{id}` pour une ligne (lecture sans création lazy)
### Fixed
- **Propagation des synced blocks** — `PUT /api/synced-blocks/{id}` réécrit le contenu stocké de chaque page référente via `sync_synced_blocks_in_page()` (fonction morte depuis v5.14.0), récursif dans les `children` (colonnes/toggles), broadcast WS seulement après réécriture DB
- **Suppression d'une source** — ids des pages référentes collectés avant la cascade FK, `_synced_deleted` marqué dans le stocké (`mark_synced_block_deleted`), broadcast dédié `manager._broadcast_synced_to()`
- **Ouverture des lignes de database** — le peek appelait `/pages/{rowId}` qui lit la table `pages` : avec des ids croisés il ouvrait une page au hasard (ligne id 5 « Ask AI » → page id 5 « Données ») ; désormais résolution via l'endpoint dédié
- **Listings** — les pages contenu (`parent_section='DbRow'`) sont exclues (`AND collection_row_id IS NULL`) de l'arbre sidebar, des racines workspace, de la liste des pages du board, des projets builtins, de l'arbre Library et du `page_count` des workspaces
### Tests
- `tests/test_v65_synced_db.py` — **17 tests** ; suite complète **742 verts** (`pytest -n auto`) ; `ruff check app tests` OK ; `eslint static/js` 0 problème
## v6.4.0 (2026-09-22) — Realtime editing (production)
> Le realtime passe en mode production : résolution de conflits au-delà du last-write-wins (merge à trois versions) et édition à grande échelle (broadcast non bloquant, coalescence des curseurs, corrections de fuites, observabilité).
### Added
- **Merge à 3-voix (diff3-lite)** — nouveau `app/services/realtime_merge.py` :
- `merge_text_3way(base, current, incoming)` : fusion de caractères. Les régions modifiées qui ne se chevauchent pas sont **toutes conservées** (les deux saisies survivent) ; ordre d'arrivée indifférent ; un chevauchement réel retombe en LWW et est signalé.
- `merge_block_3way(base, current, incoming)` : fusion **champ-par-champ**. Serveur et client modifiant des champs différents n'ont plus de conflit ; conflit limité au seul champ divergent (plus « tout le bloc perdu »).
- Fonctions pures, testées sans WebSocket ni base de données.
- **Protocole `base`** — chaque `update` envoyé par le client embarque la version du bloc dont dérive sa saisie ; le serveur calcule `merge_block_3way(base, current, incoming)`. Sans `base` (ancien client) → LWW historique, rétro-compatible.
- **Adoption du résultat fusionné** — l'`ack` renvoie le bloc fusionné + drapeau `conflict` ; le client met à jour sa `base`, adopte le résultat (hors bloc en cours d'édition) et affiche un toast en cas de conflit. Le serveur diffuse toujours le bloc final fusionné pour convergence de tous les pairs.
- **Broadcast non bloquant** — chaque connexion a une file sortante (`asyncio.Queue`) + une tâche `_writer` dédiée ; `_broadcast()` fait `put_nowait` et n'attend plus le socket → un client lent ne fige plus la room entière.
- **Coalescence des curseurs** — le writer réduit les messages `sel` empilés à la position la plus récente (seule la dernière compte), en préservant l'ordre des messages importants.
- **Anti-flood** — budget d'opérations par connexion (`OP_WINDOW_MAX=400` / fenêtre 10 s) ; au-delà, réponse `ack stale` sans application.
- **Observabilité** — `GET /api/realtime/stats` (authentifié) : rooms, connexions (dont totales), ops, merges, conflits, déconnexions lentes, + détail par page.
### Changed
- **Déconnexion des clients trop lents** — file sortante pleine (`MAX_OUT_QUEUE=512`) → fermeture 4413 + compteur `slow_disconnects`, pour qu'une room ne stagne jamais sur un pair mortel.
- `app/templates/_page_editor_realtime.html` — les updates incluent désormais `base` ; nouveau traitement de l'`ack` avec `merged`.
### Fixed
- **Fuite de rooms** — une tentative de connexion WS sur une page inexistante (4404) enregistrait une `Room` orpheline en mémoire pour toujours ; la room n'est plus enregistrée tant que la page n'est pas validée.
- **`room_state()` sur page inexistante** — retournait/plantait sur `None` ; retourne désormais `{"blocks": [], "title": "", "version": 0}` sans laisser d'entrée dans `_rooms`.
### Tests
- **26 nouveaux tests** `tests/test_realtime_v64.py` : merge purs (régions disjointes, ordre indépendant, chevauchement → conflit, suppression de champ, id préservé), protocole WS (ack fusionné, convergence de 2 clients sur le même bloc, rétro-compat LWW sans `base`, conflit scalaire), fuite 4404, coalescence des curseurs, endpoint stats, anti-flood.
- **14 tests** `tests/test_realtime.py` existants préservés (non-régression).
- `ruff check app tests` OK · `eslint static/js` 0 problème.
---
## v6.3.0 (2026-09-21) — API publique complète v2 (REST + scopes + OpenAPI)
> L'API publique `/api/v2` devient une surface REST complète façon Notion : CRUD sur tous les domaines (collections, pages, propriétés, vues, commentaires, notifications, favoris, tags, partage, sprints, templates, forges, admin…), auth Bearer + scopes hiérarchiques, pagination/filtres/tri, erreurs RFC 7807, idempotence, audit et webhooks. Wrappers sur les services existants — un seul chemin de code.
### Migrations (v20)
- `api_tokens` : colonnes `scopes TEXT DEFAULT 'read,write'` et `expires_at TIMESTAMP` (ALTER idempotents, rétro-compatibles : anciens tokens → `read,write`).
- Nouvelles tables `api_audit_log` (user, token, action, ressource, IP), `webhook_deliveries` (statut, http_code, durée, payload) et `idempotency_keys` (clé → réponse rejouée).
### Auth, scopes & conventions (`app/services/api_v2_helpers.py`)
- **Bearer unifié** `resolve_bearer_token()` / `get_bearer_user()` : hash sha256 sur `api_tokens`, support `extension_devices` (clipper) et legacy `user_tokens`, vérification `revoked` + `expires_at`, mise à jour `last_used_at`.
- **Scopes hiérarchiques** `read < write < admin` (un scope supérieur satisfait un besoin inférieur), dépendance `require_scope()` → 403 explicite ; `validate_scopes_input()` → 400 sur scope inconnu.
- **Pagination** `parse_pagination()` (défaut 30 / max 100) + header `X-Total-Count` ; **ISO-8601 UTC** via `to_iso8601()` ; parsing des colonnes `*_json`.
- **Erreurs RFC 7807** `application/problem+json` (`type/title/status/detail/instance`) pour tout `/api/v2`, via le handler unifié sur `StarletteHTTPException` (`app/main.py`).
- **Idempotence** `Idempotency-Key` sur les POST de création (`check_idempotency` / `store_idempotency`).
- **Audit** `audit_log()` sur toutes les mutations v2 (`api_audit_log`).
- **Rate limit par token** `check_v2_rate_limit()` (300 req/min, configurable `API_V2_RATE_LIMIT_PER_TOKEN`).
- **`public_api_insecure_ok`** : le token de dev `fd-public-key` n'est accepté que si `PUBLIC_API_INSECURE_OK=true` (dev/test) — refusé par défaut en production.
### Router `app/routers/api_v2.py` (`prefix /api/v2`, ~100 endpoints)
- **Tokens** : `POST /tokens` (name, scopes, expires_at, montré une fois), `GET /tokens` (prefix only, jamais le hash), `DELETE /tokens/{id}`, `POST /tokens/{id}/rotate`.
- **Users** : `GET/PATCH /users/me`, `GET /users/search`, préférences.
- **Workspaces & membres** : CRUD + `GET/POST/PATCH/DELETE .../members`.
- **Collections** : CRUD, `/linked`, `/task`, `/sources`.
- **Pages** : `GET /collections/{id}/pages` (filtres `filter[prop]`, `sort`, `fields`, `query`), CRUD, `/restore`, `/move`, `/sub-items`, `/dependencies`.
- **Propriétés** : CRUD, `/relation`, `evaluate-formula`, `compute-rollup`.
- **Vues & dashboards** : CRUD + `save-as`.
- **Comments / mentions / notifications / favoris / tags / recents**.
- **Partage & publication** : `pages/{id}/shares`, `publish` / dé-publish (`/p/<slug>`).
- **Historique** (`page_history` + `page_versions`) + restore.
- **Sprints** (CRUD, assign, burndown), **templates** (page + database, apply), **export/import** (MD/HTML/PDF, CSV).
- **Forges** : `GET /projects`, `/projects/{owner}/{repo}/tree`.
- **Recherche** FTS5 (`GET /search`, repli LIKE).
- **Admin** : users (list/patch/delete) + `GET /admin/audit-logs`.
- **Webhooks** : CRUD subscriptions + `POST /{id}/test` + `GET /{id}/deliveries` (CRUD simple d'abord).
### OpenAPI & config
- `docs_url="/docs"` + `redoc_url="/redoc"` activés ; `docs/openapi-v2.json` généré (402 chemins).
- `.env.example` : `PUBLIC_API_INSECURE_OK=false`, `API_V2_RATE_LIMIT_PER_TOKEN=300`.
- `VERSION` et `app/main.py` passés à **6.3.0**.
### Tests & robustesse
- `tests/test_public_api_v2.py` — **24 tests** (auth 401, token lifecycle, scopes read/write/admin, pagination + `X-Total-Count`, CRUD collections/pages/propriétés/vues, filtres, RFC7807, idempotence, search, notifications, tags, sharing, webhooks, workspaces, sprints, templates, admin).
- `tests/conftest.py` + fixtures locales : `PUBLIC_API_INSECURE_OK=true` (le token de dev reste testable sans impacter la prod).
- Suite complète **668 verte** (`pytest -n auto`) ; `ruff check app tests` OK.
## v6.2.1 (2026-09-20) — Web Clipper : polish & fix bloc bookmark
> Patch UX et correctif bloc bookmark pour l'extension v6.2.0.
- **Extension — bouton flottant** — rond transparent draggable (évite de masquer le contenu), toggle d'affichage persistant (chrome.storage), clic → popup de capture ; position restaurée au reload.
- **Refresh auto sidebar** — après `POST /api/v2/web-clipper/clip` le workspace sidebar est rafraîchi sans reload (polling + event `clipper:clipped`) pour que la page clippée apparaisse immédiatement.
- **Bloc `bookmark`** — le service `create_page_from_clip()` renvoie désormais un bloc `bookmark` fidèle au payload OG (URL d'origine + `embed_src` résolu via `embeds.py`), rendu correct en éditeur / page publique `/p/<slug>` / exports MD/HTML/PDF (préserve favicon + description).
- **Fix éditeur** `7f998fa` — SyntaxError `duplicate inner` dans `_page_editor_scripts.html` (variable `inner` redéclarée lors du drag & drop multi-sélection) corrigé ; autosave + WS realtime non bloqués.
- **Tests & build** — `flowdeck-clipper.zip` régénéré (`static/extension/`), `ruff`/`eslint` verts, `16 tests` `test_web_clipper.py` verts.
## v6.2.0 (2026-09-19) — Web Clipper : extension navigateur (capture article/selection/bookmark/screenshot)
> Capturez n'importe quelle page web en une page FlowDeck : article complet, sélection, bookmark ou screenshot — depuis une extension Manifest V3 (Chrome/Edge/Firefox) + API directe `POST /api/v2/web-clipper/clip`.
### Extension navigateur (`extension/` + `static/extension/`)
- **Manifest V3** — `manifest.json` (permissions `activeTab, storage, scripting, contextMenus`, `host_permissions <all_urls>`), `background.js` (service worker OAuth + clip → fetch Bearer), `content.js` (bouton flottant + menu contextuel sélection + `Ctrl+Shift+C`), `popup.html`/`popup.js` (sélection workspace, type de capture), `clipper.css`, icônes 16/32/48/128, bundle `flowdeck-clipper.zip` servi à `/static/extension/`.
- **Types de capture** — `article` (HTML complet → `sanitize_html` + `html_to_blocks`), `selection` (sélection HTML → Markdown), `bookmark` (URL + OG → carte bookmark v5.5.0), `screenshot` (base64 → upload image + page). Cap `10 MB` / `200 blocs`, garde SSRF inexistante (validation URL), sanitisation HTML côté serveur.
### Serveur (`app/routers/web_clipper.py` + `app/services/web_clipper.py`)
- **Endpoints** `prefix /api/v2/web-clipper` — `POST /clip` (crée page via `create_page_from_clip()` + `log_clip()`), `GET /status` (auth + compteurs devices/clips), `POST /auth/verify` (enregistre device `register_device()` → token `fd_…` montré une fois), `GET /devices`, `DELETE /devices/{id}` ; page HTML `GET /extensions` (téléchargement + liste devices/clips).
- **Auth triple** — session cookie `flowdeck_session` OU Bearer `api_tokens` (hash `sha256`) OU Bearer `extension_devices` OU legacy `user_tokens` (`_user_from_request()`), rate-limit `50 clips/heure/device` (`_check_rate_limit`, 429), payload normalisé (`url`, `title`, `content`, `content_type`, `selection_html`, `image_base64`, `tags`, `target_workspace_id`).
- **Tables migration 19** — `extension_devices` (`user_id, extension_name, device_id, device_name, token_hash, scopes, last_used_at, revoked, UNIQUE(user_id,extension_name,device_id)`) + `extension_clips` (`user_id, device_id, clip_type, source_url, target_page_id, target_workspace_id, title`) avec index `idx_ext_*`.
- **Settings UI** — onglet Extensions dans `app/templates/settings.html` (devices, clips count, revoke, token copy).
### Tests & wiring
- `tests/test_web_clipper.py` — **16 tests** (sanitize, blocks, article/bookmark/selection/screenshot, Bearer, rate-limit, devices, `/extensions`).
- Wiring `app/main.py:50,158` — `web_clipper_api_router` + `web_clipper_router` inclus ; `VERSION` bump `6.1.0 → 6.2.0`.
## v6.1.0 (2026-09-19) — Granular Permissions : page / collection / property ACL + groupes + audit
> Permissions fines héritables : chaque page / database / propriété peut être restreinte à des utilisateurs ou groupes explicites. L'héritage suit la chaîne page → collection → workspace (moindre privilège), avec bypass owner/admin et audit complet.
### Base de données (migration 18)
- Tables `user_groups` (workspace-scoped, UNIQUE(name)), `group_members` (N-ary), `page_permissions`, `collection_permissions`, `property_permissions` (user_id XOR group_id, CHECK, UNIQUE), `permission_audit_log`.
- Colonnes `pages.permission_type`, `collections.permission_type`, `collection_pages.permission_type` (`inherit|restricted|private`, défaut `inherit`).
- Index `idx_pp_page`, `idx_pp_user`, `idx_cp_collection`, `idx_propp_prop`, `idx_perm_audit_res`, `idx_gm_*`.
### PermissionManager (`app/services/permission_manager.py`)
- Résolution héritage + moindre privilège : grant explicite sur page > grant collection > rôle workspace ; `restricted`/`private` sans grant → 404.
- Méthodes page : `get_page_permission()`, `can_view/edit/comment/manag` ; collection : `get_collection_permission()`, `can_view/edit/manag` ; propriété : `can_view/edit_property()`, `get_visible_properties()`.
- Groupes : `create_group()`, `add/remove_user_from_group()`, `get_groups_for_workspace()`, `get_group_members()`, `is_workspace_admin()`, `user_group_ids()`.
- Helpers : `_explicit_grant_role()` (best rank user+groups), `_collection_role()`, `_property_*`, `_owns_workspace()`, `_is_admin()`.
- Cache 60 s (`_cached()`) + `invalidate()` appelé après chaque grant/revoke/type_change.
- `log_permission_change()` (audit, never-throw).
### API (`app/routers/permissions.py` — `prefix /api/v2`)
- Pages : `GET /pages/{id}/permissions` (grants + mine + type + can_manage), `GET /pages/{id}/permissions/mine`, `POST /pages/{id}/permissions` (user_id|group_id + role viewer/commenter/editor/owner), `POST /pages/{id}/permissions/batch`, `DELETE /pages/{id}/permissions/{perm_id}`, `POST /pages/{id}/permission-type`.
- Collections : `GET /collections/{id}/permissions`, `POST /collections/{id}/permissions`, `DELETE /collections/{id}/permissions/{perm_id}`, `POST /collections/{id}/permission-type`, `GET /collections/{id}/properties/visible` (split visible/hidden).
- Properties : `GET /collections/{cid}/properties/{pid}/permissions`, `POST …/permissions` (role viewer|editor), `DELETE …/permissions/{perm_id}` (extra_cols collection_id).
- Groupes : `GET /groups?workspace_id`, `POST /groups`, `PUT /groups/{id}`, `DELETE /groups/{id}`, `GET/POST /groups/{id}/members`, `DELETE /groups/{id}/members/{uid}` (workspace owner/admin only).
- Access pickers + audit : `GET /users?workspace_id&q`, `GET /audit/permissions?limit` (owner/admin, 500 max).
- Validation : 401 sans session, 403 sans can_manage, 400 rôle invalide / cible manquante, 404 user/group/property inconnu.
### Guards existants
- `board.py` : `GET /board/api/pages/{id}` (404 si !can_view_page), `PUT /board/api/pages/{id}` (404 si non-view, 403 si viewer/commenter, 423 si locked reste prioritaire).
- `collections.py` : `DELETE /db/api/{id}` (404 si !can_view_collection, 403 si !can_manage), `GET /db/pages/{id}/api` (404), `PUT/DELETE /db/pages/{id}/api` (403), `GET /db/{id}/properties/api` filtré par visible, `_require_view/_require_edit` helpers avec `_session_user()` (pas de fallback admin sur null session).
### UI (backend-ready)
- Endpoints prêts pour panneau Permissions de l'éditeur (icône 🔒), panneau collection, masquage colonnes via `visible`/`hidden`, et gestion des groupes en Settings. Les grants sont consommés par `can_view_property` / `get_visible_properties` côté vues.
### Tests
- `tests/test_v60_granular_permissions.py` — **21 tests** : inherit allow + edit 403, restricted hidden (owner 200 / member 404), grant viewer unlock + viewer cannot edit, editor can edit but not manage, revoke 404, private + admin override, mine + batch, permission-type via API, restricted collection hidden (API 404 + HTML 302), collection viewer 403 create/delete, owner delete, collection editor create pages, property visibility, property hidden, property grant 403, group grant inherits + listing shows group, group removal revokes, groups listing/members + non-owner 403, audit log (grant+type_change, member 403), endpoints 401, validation 404/400. Suite 21/21 verte.
## v6.0.0 (2026-09-18) — PWA : Progressive Web App, offline support
> FlowDeck devient une PWA installable et utilisable hors ligne : le shell est
> mis en cache par un service worker, les pages visitées restent disponibles,
> et les modifications faites sans réseau sont mises en file d'attente dans
> IndexedDB puis synchronisées au retour de la connexion, avec résolution de
> conflits côté serveur.
### PWA & Service Worker
- **`static/manifest.json`** + **`static/icons/*`** (72→512 + maskable + apple-touch) générés par `scripts/generate_pwa_icons.py`.
- **`static/sw.js`** — precache du shell (CSS/JS/fonts/icônes), stratégies cache-first (assets) / network-first (HTML, API GET), page offline inline, Background Sync (`sync-flowdeck`), purge des anciens caches.
- **`base.html` / `landing.html`** — meta PWA (manifest, theme-color, apple-mobile-web-app) + enregistrement du service worker. Routes `GET /manifest.json` et `GET /sw.js` servies par FastAPI.
### Offline client (`static/js/offline.js`)
- Base IndexedDB `flowdeck-offline` : `pages_offline`, `collections_offline`, `sync_queue`, `sync_meta`.
- `window.FlowOffline` : `enqueue`, `flush`, `delta`, `status`, `savePageOffline`, `getQueue`, `pendingCount`, `onChange`, `markDirtySidebar`.
- Sauvegarde optimiste hors ligne (hook de l'éditeur), file de mutations rejouée automatiquement (online, Background Sync, polling 30 s).
- Durcissement : max 100 mutations/batch, timeout 30 s (`AbortController`), rétention queue 30 jours.
### Serveur de synchronisation
- **`app/services/sync_engine.py`** — `get_delta` (pull des changements, détection des soft-deletes), `apply_batch` (mutations optimistes + contrôle de version `sync_version`).
- **`app/routers/sync.py`** — `GET /api/v2/sync/delta`, `POST /api/v2/sync/batch`, `GET /api/v2/sync/status` (auth session, CSRF-exempt via `/api/v2`).
- **Migrations** — table `offline_sync_queue` (+ index) et colonnes `sync_version` sur `pages`, `collection_pages`, `collections` avec triggers `AFTER UPDATE`.
- **Conflits** — edit-edit (last-write-wins + rapport), edit-delete (page orpheline recréée), create-create (renommage « copie offline »).
### UI
- Banner hors ligne avec compteur de modifications en attente, badge de synchronisation (spinner), toasts de fin de sync, icône ⟳ sur les pages non synchronisées.
- Section `/help` « Offline mode (PWA) ».
### Tests
- `tests/test_sync.py`, `tests/test_sync_migrations.py`, `tests/test_service_worker.py`, `tests/test_pwa_offline.py` ; E2E Playwright `e2e/pwa_offline.spec.js` (manifest, SW, queue offline, replay, navigation hors ligne).
## v5.12.0 (2026-09-14) — v5.11.0 Wiki-links & v5.12.0 Templates & lock
> Deux versions roadmap livrées ensemble : le graphe de connaissances
> Notion (liens `[[`, mentions de page et de date, renommage propagé) et
> les options de page (galerie de templates globale, verrouillage
> lecture-seule, pleine largeur, texte compact).
### v5.11.0 — Wiki-links & mentions de page
- **Service** `app/services/wiki_links.py` — tokens stockés dans le texte
des blocs : `[[fdpage:ID]]` (lien interne résolu à l'affichage) et
`[[fddate:YYYY-MM-DD]]` (chip de date). Le token ne contient que l'id →
**renommage propagé** automatiquement.
- **Endpoints** — `GET /board/api/wiki/pages?q=` (picker, recherche
substring + sous-séquence floue « mnt » → « Meeting notes ») et
`GET /board/api/wiki/titles?ids=` (labels courants en batch, icônes
incluses, « Deleted page » si supprimée).
- **Éditeur** — taper `[[` ouvre le picker de pages (recherche live,
navigation clavier ↑↓/Entrée/Échap, sélection à la souris), Enter
insère une **chip de page** atomique (icône + titre, clique = navigate).
Le menu `@` existant gagne deux sections : **Pages** (`@Nom` → lien
inline) et **Date** (`@today`, `@tomorrow`, `@YYYY-MM-DD` → chips).
Les chips sont relu en tokens bruts (`gtTok`) à chaque sync : le drag,
le split Entrée, l'undo/redo et l'autosave préservent les liens.
- **Backlinks** — le scanner v5.4.0 reconnaît désormais les tokens
`[[fdpage:ID]]` : « Lié depuis » fonctionne avec les wiki-links.
- **Page publique** — `_render_blocks_public` résout les chips (liens
absolus + dates lisibles, HTML échappé).
### v5.12.0 — Templates & verrouillage de page
- **Template picker global** — « + New page » (sidebar, footer, palette
Ctrl+K) ouvre la galerie : 5 templates built-in (`app/services/
block_templates.py` : Empty, Meeting notes, Weekly report, To-do list,
Project doc) + les templates personnels sauvegardés ; « Empty » retombe
sur la création classique (workspace item).
- **Save as template** — menu « … » de la page → capture le contenu blocs
courant comme template personnel (`page_global_templates`, visibles
uniquement par leur créateur, `created_by IS NULL` = partagé).
- **Bouton « Use template »** — `POST /board/api/page-templates/{id}/use`
(id 0 = built-in par clé) duplique le contenu des blocs dans une
nouvelle page du workspace de l'utilisateur et renvoie son id.
- **Page lock** — menu « … » → « 🔒 Lock page » : bannière sticky
« locked (read-only) », blocs et titre passés `contenteditable=false`,
handles/actions masquées. Serveur : `POST /board/api/pages/{id}/lock`,
garde `_ensure_page_editable` → **423** sur `PUT /api/pages/{id}` et
`POST /api/pages/{id}/blocks` pour tout non-privileged ; déverrouillage
réservé à la personne qui a verrouillé (`locked_by`) ou un admin (403
sinon).
- **Full-width / Small text** — toggles dans le menu « … », persistés par
page (`pages.full_width`, `pages.font_small`), options `POST
/board/api/pages/{id}/options`, rendu via classes CSS
(`.full-width` / `.small-text`).
- **Migrations 13** — `pages.is_locked`, `pages.locked_by`,
`pages.full_width`, `pages.font_small`, table `page_global_templates`.
- **Tests** — `tests/test_v511_v512_wiki_templates.py` : **15 tests**
(schéma, helpers tokens + échappement, picker flou, renommage propagé,
persistance des tokens + backlinks, chips en page publiée, lock 423 /
403 / admin / ré-ouverture, options, templates built-in + perso +
cloisonnement, shapes service, câblage front). Suite complète
**538 verte**, `ruff check` OK.
- **Version** — 5.12.0.
## v5.11.7 (2026-09-13) — v5.8.0 Calendrier & Rappels
> Calendrier complet (jour / semaine / mois) avec récurrences expandues
> côté serveur, rappels avant échéance (in-app + email), fuseaux horaires
> par utilisateur et par événement, notifications d'assignation et centre
> de notifications enrichi.
- **Moteur de récurrence** — `app/services/recurrence.py` : sous-ensemble
RRULE (daily/weekly/monthly, intervalle, count, until, byweekday lundi=0,
timezone IANA) ; expansion virtuelle par fenêtre, jamais persistée,
bornée (garde anti-explosion). `validate_rule()` pour le 400 en écriture.
- **Vues Jour / Semaine / Mois** — la vue calendar de
`_database_table_scripts.html` gagne un sélecteur de mode (persisté dans
`viewConfig.calendar_mode`), navigation ‹/› adaptée au mode, vue Jour en
agenda trié par heure, Semaine en 7 colonnes. Les événements viennent de
`GET /db/{id}/calendar/api` (expansion serveur des occurrences).
- **Récurrences dans l'UI** — double-clic sur un événement : popover Time /
Timezone / Repeat (Every + Ends never|after count|on date) / Remind.
Règle stockée dans `property_values_json` sous `__recurrence__` (clé méta
par id de propriété date), badge ↻ sur les chips et cellules date.
- **Rappels** — `app/services/reminders.py` : `scan_and_fire(now)`
déterministe (lead minutes/heures/jours, occurrence suivante
timezone-aware), `reminder_scheduler()` toutes les 60 s ; dédup via la
table `reminder_log (page_id, occurrence_date)` ; cible = personnes
assignées, repli premier admin ; notifie en in-app + email (pref
`reminders`). Config `reminders_enabled` /
`reminder_scan_interval_seconds`.
- **Fuseaux horaires** — colonne `users.timezone` ; réglable dans Settings →
Notifications (picker IANA via `GET/POST /api/notifications/timezone`,
liste `GET /db/timezones/api`) ; priorité de résolution par ligne :
`__timezone__` de l'événement → règle de récurrence → timezone de
l'utilisateur.
- **Notifications d'assignation** — `notify_assignment()` compare les
propriétés `person` avant/après sur `PUT /db/pages/{id}/api` : les
nouvellement assignés reçoivent une notif in-app + email (pref
`assignments`). Préférences étendues : `comments`, `mentions`,
`reminders`, `assignments`.
- **Template Meeting notes** — propriétés `Agenda` et `Notes` ajoutées
(migration 12 ; ne touche pas une version personnalisée du template).
- **Migrations 11 & 12** — `reminder_log`, `users.timezone`, enrichissement
du template seed.
- **Tests** — `tests/test_v58_calendar_reminders.py` : **20 tests**
(moteur pur, schéma, validation des méta-clés 400, calendar/api,
timezone user, scanner avec horloge injectée + dédup + récurrence +
cible assignée, notifications d'assignation, prefs, câblage front).
Suite complète **523 verte**, `ruff check` OK.
- **Version** — 5.11.7.
## v5.11.6 (2026-09-13) — v5.7.0 Database Avancée (Pt. 2)
> Complète la parité Notion des bases de données : personnes et propriétés
> automatiques, groupes de propriétés, vues sauvegardées par utilisateur,
> swimlanes Kanban + WIP limits, cartes configurables, calendar avec
> glisser-déposer et gallery avec couvertures.
- **Types propriété** — `person`, `created_time`, `created_by`,
`last_edited_time`, `last_edited_by` câblés de bout en bout.
`apply_auto_properties()` (property_types) remplit/rafraîchit les valeurs
automatiques à la création et à la mise à jour des lignes ; `created_*` est
figé, `last_edited_*` est rafraîchi. Nouvel endpoint
`GET /db/{collection_id}/members/api` (membres du workspace, repli tous
utilisateurs) alimente le sélecteur `person` (chips + avatars).
- **Groupes de propriétés** — colonne `collection_properties.group_name`,
en-tête de table avec sections pliables (`db-group-row` + chevron) et
`POST /db/{collection_id}/property-groups/api` pour réassigner les colonnes.
- **Vues sauvegardées par utilisateur** — `collection_views.created_by` ;
`GET /db/{id}/views/api` ne renvoie que les vues de l'utilisateur (les vues
partagées `NULL` restent visibles), `save-as`/`duplicate` attribuent le
propriétaire, `DELETE /db/views/{id}/api` et renommage.
- **Swimlanes Kanban** — `sub_group_by` : une rangée de colonnes par lane
(2e dimension de groupement).
- **WIP limits** — `wip_limits` par colonne, colonne surlignée + compteur
`WIP n/limit` au dépassement.
- **Cartes configurables** — `card_properties` (propriétés affichées),
`card_size` (compact/détaillé) et couverture `cover_mode`
(`none`/`icon`/`color`/`property` via `cover_property`).
- **Calendar drag & drop** — vue mois (`date_property` configurable),
navigation ‹/›/Today, glisser une carte sur un jour = reschedule immédiat.
- **Gallery avec couvertures** — vignettes image (propriété `files`/`url`),
icône, couleur ou cover de ligne ; tailles small/medium/large.
- **Composant multi-vues** — `_database_table_scripts.html` réécrit :
barre de vues (ajout/suppression/duplication/paramètres), vues
table/board/calendar/gallery/list, éditeurs de cellules par type
(select/status/date/number/checkbox/multi_select/person), cellules
read-only pour les types automatiques, popover de configuration.
- **Correctif** — `PUT /db/pages/{id}/api` fusionne désormais les valeurs
soumises avec l'existant (PATCH partiel) au lieu de remplacer toute la
ligne ; `cover_url` par ligne (`collection_pages.cover_url`).
- **Migration 10** — `collection_properties.group_name`,
`collection_views.created_by`/`updated_at`, `collection_pages.cover_url`.
- **Tests** — `tests/test_v57_db_advanced.py` : **12 tests**. Suite complète
**503 verte**, `ruff check` OK.
- **Version** — 5.11.6.
## v5.11.5 (2026-09-13) — v5.6.0 Import de données (Phase 5 — durcissement)
> Finalise la solution d'import : ré-import incrémental, lots, URL, dépôt forge,
> relations Notion et rapports exportables. **v5.6.0 est complet (Phases 0→5).**
- **Import incrémental / re-sync** — nouveau paramètre `mode` (`skip` par défaut,
`update` = mise à jour des pages et upsert des lignes par titre, `duplicate`).
Compteurs `pages_updated` / `rows_updated` dans le rapport.
- **Erreurs partielles** — l'import continue page par page en cas d'échec
(`status: "partial"`, liste `errors`), au lieu d'abandonner.
- **Import de dépôt forge** — `POST /api/import/forge-repo` : arborescence
Gitea/GitHub → pages (dossiers conservés, fichiers texte, code en blocs ;
`GitHubAdapter.list_repo_files`, `GiteaForgeAdapter.list_repo_files`).
- **Web clipper** — `POST /api/import/url` : fetch d'une page (garde SSRF :
loopback/privé/link-local refusés), conversion HTML → blocs + carte bookmark
(OG metadata).
- **Lot multi-fichiers** — `POST /api/import/run-batch` (rapport par fichier) et
file d'attente dans l'assistant `/import` (sélection multiple, statut par
fichier, barre de progression).
- **Relations Notion** — `POST /api/import/relations/resolve` : les colonnes
texte qui référencent les titres d'une autre collection deviennent des
propriétés `relation` (valeurs = ids de `collection_pages`). Auto-exécuté
après un import Notion (clé `relations` du rapport).
- **Rapport exportable** — `GET /api/import/jobs/{id}/report` (JSON) + bouton
« Télécharger le rapport » dans l'UI.
- **Correctif** — les lignes importées stockent désormais leurs valeurs par **id
de propriété** (au lieu du nom) : rendu correct dans les vues database et
compatibilité avec les relations/rollups.
- **UI** — assistant `/import` enrichi : multi-fichiers, mode de ré-import,
import URL, import forge (issues **et** fichiers), téléchargement du rapport.
- **Tests** — `tests/test_v56_import.py` porté à **43 tests** (incrémental ×4,
lot, URL ×2, forge-repo ×2, relations, rapport de job). Suite complète
**491 verte**, `ruff check` OK.
- **Version** — 5.11.5.
## v5.11.4 (2026-09-13) — v5.6.0 Import de données (Phases 3 & 4)
> Complète le chantier v5.6.0 : documents/bureautique (Phase 3) et
> signets/dev/divers (Phase 4). **v5.6.0 est désormais complet** (5 phases).
- **Phase 3 — Documents & bureautique**
- **Word `.docx`** (`python-docx`) — titres, listes, citations, tableaux GFM
et images inline → page ; couvre aussi les exports Google Docs Takeout.
- **PDF** (`pypdf`) — extraction texte par page + images → page ; avertissement
si pages sans couche texte (scan).
- **HTML fichiers/dossier** — détection élargie (archive contenant ≥ 1 HTML),
assets collectés comme pièces jointes.
- **Phase 4 — Signets, dev & divers**
- **Forge issues** — `POST /api/import/forge` (Gitea/GitHub) : issues → collection
(Number, State, Labels, Milestone, Assignee, dates, URL, Body) + collections
Labels et Milestones. `GitHubAdapter.list_issues/list_labels/list_milestones`
ajoutés ; token par utilisateur (`user_oauth_tokens`).
- **Signets** — Raindrop.io, Pocket (CSV/HTML), Readwise (highlights CSV/MD),
Shaarli (JSON) et signets Netscape HTML → collection (URL, description,
tags, date).
- **Calendrier `.ics`** — parseur RFC 5545 (VEVENT) → collection d'événements.
- **OPML** — flux RSS/outlines → collection (URL, dossier).
- **Standard Notes** — sauvegarde JSON → pages (notes chiffrées ignorées).
- **UI** — l'assistant `/import` liste désormais les 20 sources, accepte
`.docx/.pdf/.ics/.opml` et propose un panneau « import depuis une forge ».
- **Dépendances** — `python-docx`, `pypdf` ajoutés.
- **Tests** — `tests/test_v56_import.py` étendu à **32 tests** (signets ×5,
ics/opml/standard notes, docx, pdf, forge ×4). Suite complète verte,
`ruff check` OK.
- **Version** — 5.11.4.
## v5.11.3 (2026-09-13) — v5.6.0 Import de données (Phases 0, 1, 2)
> Démarre le chantier v5.6.0 « Import de données » avec le **socle unifié**
> (Phase 0) et les deux premières familles de sources : **Notes & Markdown**
> (Phase 1) et **Données & tableaux** (Phase 2).
- **Socle d'import unifié (Phase 0)** — package `app/services/importers/` :
modèle normalisé (`ImportResult`, `ImportPage`, `ImportAttachment`), interface
`Importer` (`detect()` / `parse()`) + registre auto-détection, pipeline commun
(hiérarchie `parent_id`, pièces jointes, réécriture des liens, collections +
lignes, rapport d'import), jobs d'arrière-plan (`app/services/importers/jobs.py`,
polling), déduplication idempotente (table `import_items`), migration 9
(`import_items` + `import_jobs`).
- **Assistant UI d'import** — page `/import` (template `import.html`) : choix de
source ou détection auto, glisser-déposer, **aperçu dry-run**, **mapping
colonnes → types** (override des types inférés), barre de progression, rapport.
Lien « Assistant d'import avancé » ajouté à la modale Import de l'éditeur.
- **API** — `GET /api/import/sources`, `POST /api/import/preview`,
`POST /api/import/run` (sync ou `async=true`), `GET /api/import/jobs[/{id}]`.
- **Phase 1 — Notes & Markdown** : Obsidian (vault `.zip`, frontmatter YAML,
`[[wikilinks]]`, `![[embeds]]`, pièces jointes, hiérarchie de dossiers),
Notion (export `.zip`, hiérarchie complète, databases `.csv` → collections,
images), Logseq & Roam (outliner, propriétés `key:: value`, pages journal,
`{{[[TODO]]}}`), HTML (Apple Notes, Bear, Ulysses, OneNote), Google Keep
(Takeout JSON), plus l'import Markdown/`.zip` générique.
- **Phase 2 — Données & tableaux** : CSV/TSV **typé** (inférence
texte/nombre/date/checkbox/email/url/select/multi_select + mapping manuel),
Excel `.xlsx` (une collection par feuille, openpyxl), JSON générique (tableau
d'objets → collection).
- **Correctif** — `_md_to_blocks` reconnaît désormais les cases à cocher
`- [ ]`/`- [x]` (elles étaient absorbées par la branche puces à puces).
- **Dépendances** — `openpyxl`, `beautifulsoup4`, `PyYAML` ajoutés.
- **Tests** — `tests/test_v56_import.py` : 18 tests (registre, dry-run, CSV typé,
dédup, TSV, Excel multi-feuilles, JSON, Obsidian, Notion, Logseq, Roam, HTML,
Google Keep, mapping, wizard, job async). Suite complète **463+ verte**,
`ruff check` OK.
## v5.11.2 (2026-09-12) — v5.5.0 Embeds & Média riche (complétion)
> Valide et complète le chantier v5.5.0 (parité embeds Notion). Une grande
> partie était ébauchée ; ce patch répare les chemins d'API cassés côté éditeur,
> finalise la résolution d'embed, le parsing OG, la lightbox publique et couvre
> le tout de 47 tests dédiés.
- **Embed universel** — `app/services/embeds.py` réécrit : détection
sous-domaine correcte, YouTube (watch/shorts/youtu.be), Vimeo, Figma,
Google Maps, Google Docs/Sheets/Slides, Loom, CodePen, Miro, Spotify,
SoundCloud, Twitch (parent réel), X/Twitter, Pinterest, Microsoft Office ;
helpers `resolve_embed()`, `inline_kind()`, `provider()`. Nouvel endpoint
`POST /board/api/embed/resolve`.
- **Résolution d'embed dans l'éditeur** — la saisie d'une URL appelle le
résolveur et met en cache `embed_src`/`embed_provider` (l'URL d'origine est
conservée) ; le rendu (éditeur, pages publiques, exports MD/HTML/PDF) préfère
`embed_src`. Le champ est persisté via la sérialisation des blocs.
- **Bookmark cards** — `app/services/og_fetcher.py` : parseur `<meta>` robuste
(ordre d'attributs libre), titre de repli, favicon, transport injectable pour
les tests ; `fetch_og_metadata` ne lève jamais sur erreur réseau.
- **Image lightbox** — navigation clavier ←/→ et Escape, multi-images, dans
l'éditeur **et** les pages publiques `/p/<slug>` (CSS `.fd-lightbox`).
- **Fix chemins API (bug v5.4.0/v5.5.0)** — l'éditeur appelait
`/api/pages/...` alors que les routes sont `/board/api/pages/...` : cover,
icon, versions, backlinks, import, move et OG metadata étaient donc en 404.
Tous corrigés, avec un test de régression statique.
- **Tests** — `tests/test_v55.py` : 47 tests (embeds par provider, resolve API,
parse OG + MockTransport, lightbox, préviews, cover upload/URL/suppression,
icône, page publique). Suite complète **444 verte**, `ruff check` OK.
- **Version** — 5.11.2.
## v5.11.1 (2026-09-11) — v5.2.0 Infrastructure & Polish (complétion)
> Finalise le chantier v5.2.0 : la plupart des briques étaient déjà livrées
> (design tokens/components, API tokens, sessions, onboarding, backups, projets +
> sync, GitHubAdapter, Docker multi-stage) ; ce patch sécurise l'isolation des
> tests, active les tests parallèles et rend le linting vert.
- **Tests parallèles (pytest-xdist)** — `tests/conftest.py` **mute** désormais le
singleton `app.config.settings` au lieu de le remplacer. Les modules qui
importaient `settings` au chargement (ex. `app/services/backup.py`) gardaient
sinon les valeurs par défaut, ce qui rendait les tests backup instables. Chaque
test a une base SQLite temporaire + un dossier de backup dédiés → tests
parallèles lancés explicitement (`pytest -n auto` en local et en CI). La config
de base ne force plus `-n` (évite l'échec `unrecognized arguments: -n` si
`pytest-xdist` n'est pas installé).
- **Backups réellement testés** — les 2 tests précédemment `skip` (flaky) sont
remplacés par des tests réels : snapshot daté, `prune_old_backups`, `backup_due`
et API admin `/api/settings/backups`.
- **OAuth (mock)** — tests d'intégration complets dans `tests/test_v52_infra.py` :
redirect authorize → callback (Gitea + GitHub), mode `link` sur un compte local,
rejet d'un `state` invalide, construction des URLs d'autorisation.
- **Schéma complet hors lifespan** — `init_db()` crée aussi
`webhook_subscriptions` (auparavant uniquement dans le lifespan FastAPI).
- **Linting** — `ruff check app tests` passe sans erreur (corrections E701/E702,
B904, B007, E741, F821, F841, W293, UP031, E731 + config FastAPI pour B008) ;
migration de `.eslintrc.json` vers `eslint.config.mjs` (flat config, ESLint v9+).
- **CI** — nouveau job `lint` (ruff + eslint) ; tests exécutés en parallèle avec
couverture ; déclencheurs élargis à `develop`.
- **Version** — 5.11.1. **397 tests** verts.
## v5.11.0 (2026-09-11) — FlowDeck Agent : UX chat améliorée
> Quatre améliorations majeures du panneau Agent (v4.10.0 → v4.14.0) pour
> une expérience plus proche de Notion AI.
- **Auto-titre à chaque requête** — Le titre de la conversation se met à jour
après chaque échange avec l'IA (pas seulement à la création). Il reflète
le sujet courant et rend l'historique facile à parcourir.
- **Aperçu au survol / appui long** — Dans l'onglet Historique, un survol
souris (desktop) ou un appui long (mobile) ouvre un pop-up avec le premier
message utilisateur et la dernière réponse de l'agent, sans quitter la liste.
- **Normalisation des blocs `write_blocks`** — L'outil tolère désormais les
erreurs fréquentes du LLM : `text`→`content`, `heading`→`heading_1`…`heading_4`,
`bullet/list`→`bulleted_list`, génération d'ID, types inconnus→paragraphe.
Le script PowerShell (ou tout code) s'écrit correctement dans un bloc `code`.
- **Accordéon style Notion** — Le raisonnement de l'agent s'affiche dans un
composant pliable : « Pensées » (chevron, contenu complet au clic) et
« Actions » (lignes simples, passé, non-cliquables). Le tout reste visible
en permanence sous la réponse, sans encombrer l'écran.
- **Tests** — `test_write_blocks_normalizes_llm_blocks`,
`test_engine_titles_conversation_on_each_request`,
`test_suggest_title_strips_composer_prefixes`.
## v5.9.0 (2026-09-10) — AI Writing Assist (éditeur)
> L'IA Notion dans l'éditeur : commandes slash `/ai`, autocomplétion en ligne
> et remplissage automatique des propriétés d'une base, au-dessus du moteur
> FlowDeck Agent (v4.10.0) et de l'endpoint headless `/api/agent/generate`.
- **Service `app/services/ai_writing.py`** — `AIWritingService` sans outils,
six actions (`write`, `summarize`, `translate`, `continue`, `autocomplete`,
`properties`). Prompt système strict (Markdown léger, aucun préambule) et
replis déterministes hors-ligne (aucun appel réseau sans clé API).
- **Commandes slash IA** — nouveau groupe « AI » dans le menu `/` de
l'éditeur : **Write with AI**, **Summarize**, **Translate**, **Continue
writing**. `Write` ouvre le composeur inline existant ; `Summarize` et
`Continue` insèrent le résultat sous le bloc ; `Translate` remplace le
contenu de la page dans la langue demandée.
- **Autocomplétion** — module `AIAC` : après ~900 ms d'inactivité pendant la
frappe dans un bloc texte, une suggestion courte est proposée dans une
pastille « Tab » ancrée au bloc ; `Tab` l'insère, `Escape` la rejette.
- **AI properties** — bouton ✨ sur chaque ligne de la table database :
`POST /api/agent/writing/properties` propose des valeurs (Status, Priority,
Résumé…) à partir du titre/contenu, appliquées et persistées via l'API
collection existante.
- **Endpoints** — `POST /api/agent/writing` (action + prompt/contexte/langue/
préfixe) et `POST /api/agent/writing/properties` (suggestions structurées),
provider/modèle par utilisateur via les clés LLM existantes.
- **Tests** — `tests/test_ai_writing.py` (+29) : service hors-ligne, coercition
des valeurs, parsing JSON, routes et câblage des templates. Version 5.9.0.
## v5.5.0 (2026-09-08) — Partage : réorganisation & indicateurs
> Vue complète du partage : sidebar « Shared » scindée en « Par moi » /
> « Avec moi », onglet Shared de la bibliothèque filtrable, fenêtre Share
> rechargée à l'ouverture (bug de refresh), et emoji 👥 sur les fichiers
> partagés du workspace local.
- **Correctif fenêtre Share** — ouverture de la boîte de dialogue
(`toggleShareOpen`) au lieu de laisser la liste vide après un refresh ;
les partages sont rechargés (`loadShares`) à chaque ouverture.
- **Sidebar Shared** — la section affiche deux sous-groupes : « Par moi »
(partages nominatifs créés + pages en « lien ») et « Avec moi » (partages
reçus), chacun avec son propre état vide (les pages déjà dans « Par moi »
sont exclues de « Avec moi » pour éviter les doublons).
- **Bibliothèque** — onglet Shared doté d'un filtre « Tous / Par moi /
Avec moi » ; l'endpoint `GET /api/library/shared` accepte `dir=made|received|all`
et renseigne `share_dir` sur chaque élément.
- **Indicateur de partage** — la barre du document affiche « 👥 Shared »
au lieu de « 🔒 Share » dès que la page est partagée (membre, lien ou
publiée) ; état recalculé à la volée après chaque action de partage.
- **Workspace local** — les fichiers partagés affichent l'emoji 👥 juste
avant leur nom dans l'arborescence (`is_shared` exposé par
`/api/local-workspace/tree`).
- **Tests** (tests/test_sharing.py +6) : direction made/received/all de la
bibliothèque, fallback dir invalide, et rendu `pageIsShared` sur la page.
Suite complète 325 verte (+3 PDF pré-existants).
## 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-<id>`, 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**
> : les blocs, le titre, les curseurs et la présence se synchronisent via une
> connexion WebSocket, avec fusion last-write-wins et repli sur polling.
- **Passerelle WebSocket** `WS /ws/pages/{page_id}` (app/services/realtime_server.py
+ app/routers/realtime.py) : salons en mémoire par page, authentifiés via le
cookie de session (refus 4401), page introuvable/supprimée → 4404.
- **Protocole de synchronisation** : `hello`/`sync`/`op`/`ack`/`title`/`sel`/
`ping`/`peer_join`/`peer_leave` ; chaque page porte une version incrémentée à
chaque opération appliquée ; un client périmé reçoit un `sync` complet.
- **Merge last-write-wins par bloc** : insert, update (remplacement de bloc),
delete, move — appliqués dans l'ordre d'arrivée ; une frappe en cours dans un
bloc focalisé n'est jamais écrasée par une mise à jour distante.
- **Client éditeur** (app/templates/_page_editor_realtime.html) : diff local →
ops (update/delete/move/insert), envoi dès que l'utilisateur tape (debounce),
application des ops distantes avec re-render discret + re-focus du bloc actif.
- **Présence & curseurs** : avatars colorés dans la barre du haut et curseurs
de chaque pair positionnés dans le document (calque dédié, mise à jour à
l'édition/au scroll).
- **Titre synchronisé** : diffusion du titre (debounce) sans écraser un titre
en cours d'édition.
- **Fallback polling** : si le WebSocket est indisponible, rafraîchissement
toutes les 10 s de l'état serveur (adopté uniquement si pas de brouillon
local) avec reconnexion automatique.
- **Persistance** : écriture debounce (~1 s) de `content` + `title` en base,
flush immédiat à la déconnexion du dernier client.
- **Sécurité** : `connect-src` CSP étendu à `ws:` ; aucune donnée sensible
transmise (les peers n'exposent que id/login/full_name/couleur).
- **Tests** (tests/test_realtime.py) : auth, page absente, hello→sync, merge
LWW à deux clients + persistance, présence join/leave, curseurs, titre,
resync des clients périmés, `apply_op`/`merge_ops` unitaires.
## v5.2.0 (2026-09-07) - Automations : un moteur de règles (if-this-then-that)
> Nouveau moteur de règles : brancher des actions sur des événements de pages
> (création, mise à jour, suppression, déplacement), sur un planning cron ou
> sur un bouton cliquable directement dans l'éditeur.
- **Moteur de règles** : trigger (événement / cron / bouton), conditions
combinables (eq, neq, contains, is_empty, is_not_empty, changed) et actions
(webhook, changer une propriété, créer une page, notification).
- **Événements couverts** : collection créée/modifiée/supprimée, page
créée/modifiée/supprimée/déplacée (API et Canvas).
- **Déclencheur cron** : `*/N * * * *`, minute fixe, `@hourly`, `@daily`, avec
un planificateur en arrière-plan qui exécute les règles dues chaque minute.
- **Bouton d'automation** : nouveau bloc de type `button` dans l'éditeur
(menu slash) qui déclenche une règle au clic.
- **Interface** : panneau Automations dans les paramètres (créer, modifier,
activer/désactiver, lancer, supprimer, historique des exécutions).
- **Historique des exécutions** : chaque déclenchement est tracé (statut,
détail, page/collection concernée).
## v5.1.11 (2026-09-07) — Éditeur : collage intelligent (découpe en blocs)
> Coller un texte dans un bloc le plaçait en entier dans ce bloc, même s'il
> contenait plusieurs paragraphes, des listes, des titres markdown, du code ou
> un tableau.
- **Découpe automatique au collage** : le contenu collé est analysé
(`paste2b`) et réparti en plusieurs blocs selon son format (markdown ou
texte simple).
- Pris en charge : titres `#`→`####`, listes à puces (`-`, `*`, `+`), listes
numérotées, cases à cocher `- [ ]` / `- [x]` (y compris `[X]`), citations
`>` , séparateurs `---`/`***`, blocs de code (fences ```), tableaux
markdown et paragraphes.
- Texte simple : les lignes courtes (≤ 55 caractères) deviennent chacune un
bloc ; les blocs de texte longs (paragraphes) restent groupés ; une ligne
vide crée une nouvelle section.
- Comportement au curseur : le texte avant le curseur reste dans le bloc
courant (qui est réutilisé comme premier bloc si vide), puis les blocs
analysés sont insérés, et le texte après le curseur conserve sa propre
place (refocus sur le dernier bloc inséré).
- Les blocs non textuels (code, tableau, image, formules…) reçoivent toujours
le collage en texte brut, sans découpe.
## v5.1.10 (2026-09-07) — Espace local : boîte de renommage à la taille du nom
> Renommer un fichier en cliquant sur son nom ouvrait une boîte d'édition qui
> s'étendait sur toute la largeur de la ligne, et le bouton « Open » restait
> collé à droite du panneau.
- La boîte de renommage inline (`inline-rename-input`) est maintenant **large
comme le nom du fichier** : la largeur est mesurée à l'ouverture
(`_fitNameWidth`) avec un minimum de 60 px et est bornée à l'espace
disponible pour les très longs noms (le texte défile alors dans la boîte).
- Le bouton **Open** est placé **juste à droite de la boîte d'édition** (marge
6 px) au lieu du bord droit : il est déplacé à l'intérieur de la cellule
`.name.file`, devenue `inline-flex` (avec `overflow:hidden` + `max-width`),
pour que le bouton ne passe jamais à la ligne même en colonne étroite.
- S'applique aux deux rendus de l'arbre : la vue principale (x-for) et
`renderChildren` (dossiers imbriqués / arbre statique).
## v5.1.9 (2026-09-07) — Validation robuste des modèles Nvidia + logo Agent noir & blanc
> La liste Nvidia « diminuée » de la v5.1.8 contenait encore des modèles en
> erreur 404/410. Deux causes réelles : (1) le payload de validation ne
> correspondait pas à celui utilisé au runtime et un `payload` partagé entre
> les requêtes concurrentes envoyait des noms de modèles croisés (race) ;
> (2) rien ne protégeait l'usage si un modèle devenait indisponible après
> validation.
### Validation des modèles (fournisseur bruité)
- `_validate_chat_models()` utilise désormais le **payload de production**
(`messages` + `temperature: 0.2`, sans `max_tokens`) : un modèle qui répond
200 à la validation répond aussi 200 à l'usage réel.
- **Race fixée** : le payload est reconstruit pour chaque modèle (plus de nom
de modèle croisé entre requêtes concurrentes) — c'est pourquoi la v5.1.8
retombait par intermittence sur la liste brute non validée.
- Un seul payload partagé retiré ; retente **une fois** sur timeout/5xx (les
modèles lents mais fonctionnels survivent) ; rejette définitivement les 4xx
(404 modèle inconnu, 410 retiré/déprécié) ; garde les 429 (routés donc
utilisables).
### Repli automatique au runtime (model not found / gone)
- `LLMClient._http_complete` : si le modèle choisi répond **404/410**, retente
une fois avec le modèle par défaut du provider et ajoute un `notice`
expliquant le basculement — visible dans le panneau Agent (nouvel événement
SSE « notice », bandeau `.fd-ap-notice`, réinitialisé à chaque conversation).
- Le modèle réellement utilisé est persisté dans l'historique de conversation.
### Panneau Agent — logo noir & blanc thémé
- Le rond bleu en bas à droite (FAB) et le logo du header du panneau (à gauche
de « How can I help you today? ») passent de l'accent bleu + émoji 🤖 à un
rond noir/blanc qui suit le thème clair/sombre, avec une étincelle ✨
monochrome (SVG `currentColor` : rond `--text-primary`, étincelle
`--bg-primary`).
## v5.1.8 (2026-09-07) — Multi-lignes réellement conservées + modèles IA validés (Nvidia)
> Deux correctifs : le bug multi-lignes (v5.1.7 corrigeait le symptôme au
> re-rendu, mais la vraie cause était la lecture en `textContent` qui élimine
> les `<br>`) et, côté Agent, seuls les modèles réellement utilisables du
> fournisseur Nvidia sont désormais proposés dans la configuration IA.
### Éditeur — multilignes conservées (vraie cause)
- `sync()` lisait `el.textContent`, qui **supprime** les `<br>` : les sauts de
ligne étaient perdus dès la sauvegarde du bloc. Ajout de `gt()` (basé sur
`innerText`, qui rend bien les `<br>` en `\n`) utilisé par `sync()`, les
tableaux (toggle/columns) et le calcul de position du curseur.
- La scission sur Entrée (et la limite bas/haut de bloc) utilisait désormais
`splitCaret()` : un marqueur temporaire est inséré au curseur puis
`gt()`/`innerText` découpe le contenu en « avant » / « après », exactement
comme un vrai clavier. Fini le décalage `before` qui perdait la fin d'un
bloc multi-lignes.
- `_resultText` (composer IA) lit par `gt()` pour préserver les `\n`.
### Agent — validation des modèles (fournisseur bruité)
- `GET {base}/models` de Nvidia liste tout le catalogue, mais la plupart des
modèles (embeddings, rerank, génération d'images…) répondent **404** sur
`/v1/chat/completions` — l'erreur observée dans la config IA.
- `fetch_provider_models()` filtre désormais pour `nvidia` : suppression des
modèles manifestement non-chat (nom), puis « probe » réelle de chaque
candidat (`POST /chat/completions`, payload minimal, pipeline limité à 6 en
parallèle, 6 s par appel) — seuls les modèles qui répondent sont listés.
Un 429 (limite de débit) est conservé (le modèle est routé, donc utilisable).
- Filet de sécurité : si toutes les validations échouent, on garde la liste
filtrée par nom plutôt que de vider le sélecteur.
- Tests : `tests/test_llm_config.py` (5 cas) — catalogue mock, 404 chat,
retour sans validation pour les autres providers, chute de sécurité, offline.
## v5.1.7 (2026-09-07) — Retours à la ligne conservés quand on change de bloc (Entrée)
> Un bloc multi-lignes (Shift+Entrée) perdait ses retours à la ligne dès qu'on
> appuyait sur Entrée pour créer un nouveau bloc.
### Cause
- Shift+Entrée insère des `<br>` ; `sync()` lit `el.textContent`, qui contient
alors des `\n`. `mdEsc()` (render des blocs) échappait le texte mais ne
convertissait pas les `\n`, et le HTML les replie en espace → les lignes
disparaissaient au re-rendu après la scission Entrée.
### Correctif
- `mdEsc()` convertit désormais les `\n` restants en `<br>` (après les
remplacements markdown inline) : les retours à la ligne sont conservés dans
tous les types de bloc (paragraphe, listes, to-do, toggle, callout, code…).
> Conforme au comportement Notion : le titre affiche « New Page » quand il est
> vide ; les aides de blocs (« Press 'space' for AI… », « Heading 1 »…) ne
> s'affichent que lorsque le bloc a le curseur.
### Titre « New Page » (document vide)
- Le placeholder du titre utilisait `:empty` avec le texte figé « New page » et
la couleur très sombre `--text-dim`. `:empty` casse dès qu'un `<br>` est
injecté dans le contenteditable.
- Ajout de `data-placeholder="New Page"` et d'une bascule de classe `.empty`
(même mécanisme que les blocs) : le placeholder s'affiche en gris pâle
(`--text-secondary`, opacité .75) dès que le titre est vide, y compris après
effacement.
- `save()` stocke désormais un titre vide (au lieu de forcer « New page ») : le
placeholder persiste après autosave ; la sidebar garde son fallback d'affichage.
- `duplicatePage()` retombe sur « New Page » si le titre est vide.
### Aides de blocs affichées seulement au focus
- Les placeholders des blocs (« Press 'space' for AI or '/' for commands »,
« Heading 1 », « List »…) s'affichaient en permanence quand le bloc était vide.
- Les règles passent sous `:focus-within` : l'aide n'apparaît que lorsque le bloc
est focalisé (curseur dans le bloc) et disparaît dès qu'on le quitte.
- Même comportement pour l'aide « Type code... » du bloc code.
> Les textes d'aide « Press 'space' for AI or '/' for commands », « Heading 1 »,
> « List », etc. restaient affichés pendant l'édition.
### Cause
- La classe `.empty` (qui pilote les placeholders) n'était basculée qu'au
`render()`. Pendant la frappe, l'événement `input` mettait à jour `dirty` et
`autoSave` mais **jamais** `.empty` : le texte d'aide persistait sur un bloc qui
avait pourtant du contenu.
### Correctif
- Le listener `input` des blocs (rendu `render()`) bascule désormais `.empty` en
direct selon le contenu : dès qu'un caractère est saisi, `.empty` est retiré et
le placeholder disparaît ; quand le bloc est vidé, `.empty` revient et le
placeholder réapparaît (mécanisme identique pour les titres « Heading 1… »,
listes, callout, etc.).
> Résout les derniers retours UI de l'éditeur v5.1.2/5.1.3.
### Placeholder « Press 'space' for AI » — jamais affiché
- **Cause réelle** : la classe `.empty` est basculée sur l'élément interne
`[data-bid]` (sélecteur `[data-placeholder]` au render), mais la règle CSS
ciblait l'élément **externe** `.block-content` — deux éléments différents, donc
le `::before` d'aide ne se rendait **jamais**.
- La règle cible désormais `.block-content [data-bid].empty::before` (l'élément
qui porte réellement `data-placeholder` + `.empty`).
### Flèches ↑/↓ du menu `/`
- La navigation dépendait de l'endroit où se trouvait le focus (`_in` vs bloc vs
ailleurs) : `onKd` n'est lié qu'à `#_blocksCt`, l'input vit dans `#_slashMenu`
(un voisin), d'où un comportement fragile/incohérent.
- Ajout d'un écouteur `keydown` **au niveau document** (actif quand le menu est
ouvert) : ↑/↓/Entrée/Échap fonctionnent quel que soit le focus, plus de double
déclenchement.
- La sélection `.selected` passe en `!important` pour toujours gagner sur
`.slash-item:hover`, et l'élément sélectionné est auto-défilé (`scrollIntoView`).
### Item « vocabulaire / vocabulaire » du menu `/` mal positionné
- Les icônes texte `'lightbulb'` (Callout) et `'image'` (Image) étaient injectées
en clair dans la pastille d'icône, provoquant un texte décalé/débordant.
- Remplacées par les emojis `💡` (Callout) et `🖼️` (Image).
### Composer « Edit with AI » — largeur
- Le composer AI utilisait une largeur fixe `420px` ; il s'aligne désormais sur la
largeur du bloc ciblé (min 280px, max 1200px).
> Résout les régressions du composer AI et de l'éditeur Notion-style v5.1.2.
### AI inline — erreur 400 DeepSeek (espace → AI)
- `fdAgent.generate` passait par l'engine à outils (`/conversations/{id}/run`) qui
envoie un schéma `tools`/`tool_choice` que DeepSeek rejette en **400 Bad Request**.
- Bascule de `generate` sur l'endpoint **`/api/agent/generate`** (sans outils, prompt
propre + contexte) : le composer AI, « Ask AI », la note de réunion et la synthèse
renvoient désormais un simple texte Markdown. Le bloc « 400 » disparaît.
### Bloc vide (placeholder)
- Le hint « Press 'space' for AI or '/' for commands » était rendu trop discret
(`--text-dim`). Il passe à `--text-secondary` avec opacité, plus lisible.
(Le texte n'apparaît que sur un paragraphe réellement vide — comportement Notion.)
### Slash menu — flèches ↑/↓
- La sélection n'était **pas visible** au démarrage (rien de surligné) → les flèches
semblaient inactives. `_rs()` est désormais appelé à l'ouverture et après filtrage,
et l'item sélectionné reçoit un fond accentué + liseré gauche clair. Testé en
jsdom : ↓/↑ déplacent la sélection, Entrée applique l'élément.
### Contour du bloc sélectionné
- Suppression des anneaux de focus (`outline`/`box-shadow`) sur tous les blocs
éditables (`[data-bid]`, `.block-content`, titre de page) quand sélectionné.
### Poignée de déplacement (⠿)
- La poignée 6 points restait invisible (seule `.block-column-wrapper:hover` la
montrait). Ajout de `.block-wrapper:hover`/`:focus-within .block-handle { opacity:1 }`
pour les blocs normaux.
### Tests / validation
- 271 tests pytest verts ; rendu édition 200 (hint/AIC/trigger présents) ;
route `/api/agent/generate` enregistrée ; JS validé (`node --check` + jsdom).
> Adapte le visuel des blocs à l'expérience Notion AI : hint sur bloc vide, menu
> `/` compact avec raccourcis + aperçu au survol, et composer AI inline
> (« Edit with AI » → « Brewing… » → résultat encadré → « Insert below »).
### Bloc vide & commandes (Images 1 & 3)
- **Hint de bloc vide** : sur un paragraphe vide, le placeholder devient
« Press 'space' for AI or '/' for commands ».
- **Menu slash refait** : compact (icône + nom + raccourci `#`/`##`/`###`… à droite),
groupes renommés (« Basic blocks », « Media », « Data »), footer **« Close menu (esc) »**,
champ **« Type to search »** en bas, et **aperçu au survol** d'un item (carte
« Our Values » / exemple de rendu de bloc), positionnée à côté du menu.
### Composer AI inline (Images 2, 4, 5, 6)
- **Espace sur un bloc vide** ouvre le composer AI inline (pill « **Edit with AI** »).
- **Entrée** → l'agent génère : indicateur « **Brewing…** » animé (bouton ■ pour
stopper), puis **résultat encadré** (markdown rendu : titres, listes, gras…)
avec barre d'actions (👍/👎) et bouton **« Insert below »**.
- **Insert below** insère le contenu comme vrais blocs (via `applyAIBlocks` → `md2b`).
### Tests / validation
- 271 tests pytest verts (aucune régression) ; éditeur rendu 200 (hint, menu, AIC présents) ;
JS validé (`node --check`).
> L'agent FlowDeck et l'insertion dans les pages affichent désormais le markdown
> rendu (titre, gras, tableaux, code…) au lieu des caractères bruts `##`, `**`…
### Éditeur de page (blocs)
- **Insertion agent → vrais blocs** : `applyAIBlocks()` (et le mode *replace* de
`fdApplyDocument`) convertit la réponse markdown en blocs réels via `md2b()` :
`## titre` → bloc `heading_2`, tableaux GFM → bloc `table`, ` ``` ` → bloc `code`,
listes `-`/`1.`, to-do, citations, séparateurs. Plus de `## ` affiché littéralement.
- **Rendu inline** : les blocs de texte affichent désormais le markdown inline
(gras `**`, italique `*`, code `` ` ``, lien `[t](url)`, barré `~~`) sans les
caractères, via `mdEsc()` dans `renderBlock` (paragraphes, titres, listes, to-do,
toggle, citation, callout, code).
- Les raccourcis clavier markdown existants (`# ` + espace → H1…) restent inchangés.
### Panneau FlowDeck Agent
- **`renderMarkdown()`** (agent_panel.html) : les réponses de l'agent s'affichent en
HTML rendu — titres H1–H4, tableaux (GFM), blocs de code, listes à puces/numérotées
(+ cases), citations, séparateurs, gras/italique/code/lien/barré. HTML échappé
avant rendu (sécurité face au contenu LLM). CSS dédié (`.fd-md-*`, `pre`, tableaux).
- Le bouton « Insérer dans la page ouverte » continue d'utiliser `fdApplyDocument`
(maintenant → blocs réels).
### Tests / validation
- 271 tests pytest verts (aucune régression). Rendu markdown vérifié fonctionnellement
en Node (titres, tableau, code, listes) ; éditeur et panneau rendus 200 avec les
nouveaux composants présents.
> Livre la **v5.3.0** du roadmap : databases inline dans les pages (slash `/database`),
> galerie de **templates prédéfinis** (CRM, Project tracker, …) et **validation des
> propriétés** (required / unique / min / max) côté serveur + UI.
### Added — Inline databases (slash `/database`)
- Commande slash **`/database`** (groupe DATA) dans l'éditeur : ouvre un **sélecteur de
templates**, crée une database inline liée à la page et insère un bloc
`embed_type:'collection'` rendu par `FlowDeckDB.renderInto`.
- Le bouton « Database » de la barre *Get Started* ouvre désormais le même sélecteur
(au lieu de créer une database vide directement).
### Added — Templates de database prédéfinis
- **6 templates intégrés** seedés (migration v4) : Project tracker, CRM / Contacts,
Task list, Content calendar, Meeting notes, Reading list — avec icône, description et
schéma de propriétés.
- `app/services/db_templates.py` : `materialize_properties()` (schéma → `collection_properties`)
et `create_from_template()` (collection + propriétés + vue par défaut).
- `POST /db/api` et `POST /db/inline/api` acceptent `"template": "<nom>"` et matérialisent
les propriétés (titre exclu — c'est le titre de ligne). Colonne `icon` ajoutée à
`database_templates` (migration v4).
- `apply_db_template` (`/workspace/templates/database/{id}/apply`) matérialise les propriétés.
### Added — Validation des propriétés (required / unique / min / max)
- Nouvelle colonne `collection_properties.validation_json` (migration v4) ; le type/format
et les règles de validation sont portés par `validate_property_rule()` dans
`property_types.py` (required, unique, min, max, min_length, max_length).
- **Serveur** : `create_page_api` et `update_page_api` (`/db/{c}/pages/api`,
`/db/pages/{id}/api`) valident les valeurs avant écriture → 400 + message clair. La règle
`unique` exclut la page en cours d'édition. Les valeurs sont résolues par id **ou** par nom.
- **API propriétés** : `POST/PUT /db/{c}/properties/api` acceptent `validation` (persisté).
- **UI** (`_database_table_scripts.html`) : la modale « Add property » expose Required,
Unique, Min, Max ; en cas de 400 à l'édition d'une cellule, la cellule est marquée en rouge
et un toast affiche le message (valeur restaurée).
### Tests
- **271 tests** (+9 dans `tests/test_db_advanced.py`) : templates seedés, création de
collection/inline depuis template (propriétés matérialisées), apply template, validation
required/unique/min-max (create + update, exclusion de la ligne courante), persistance de
la config de validation, payload collection.
> Livre deux chantiers du roadmap v5 : la **migration au système de schéma versionné**
> (v5.2.0, priorité n°1 — débloque tout le reste) et la **Command Palette + recherche
> full-text** (v5.0.0).
### Added — Command palette (v5.0.0)
- **Palette de commandes `Ctrl+K` / `Ctrl+P`** dans `base.html` : modale centrée
autofocusée, navigation clavier (↑/↓/Enter/Esc), surlignage fuzzy des correspondances,
recherche universelle et **quick actions** (Nouvelle page, Library, My Tasks, Trash,
Settings, Help). Le bouton Search de la sidebar et `openQuickFind()` ouvrent désormais
la palette (fini le toast « Quick Find — Ctrl+K »).
- **Endpoint `GET /api/search?q=`** (`app/routers/search.py`) + service
`app/services/search.py` : recherche unifiée des **pages** (titre + contenu) et des
**databases** (nom + description), scopée aux workspaces accessibles de l'utilisateur.
- **Index full-text FTS5** (`pages_fts`, migration v3) avec triggers de synchronisation
(insert/update/delete) sur la table `pages` ; repli automatique sur `LIKE` si FTS5
absent. Exclusions automatiques des pages supprimées (corbeille).
### Added — Migrations versionnées (v5.2.0)
- **Runner léger `app/migrations.py`** (alternative à Alembic pour ce projet SQLite) :
table `schema_version` enregistrant la version appliquée ; le schéma de base existant
(`init_db`) devient la **baseline v1**, et chaque changement futur est une étape
versionnée appliquée une seule fois, dans l'ordre.
- **Migration v2 — indexes manquants** : `users(email)`, `user_oauth_tokens(user_id,
provider)`, `collections(workspace_id)`, `pages(workspace_id)`, `pages(deleted_at)`.
- **Migration v3 — FTS5** : `pages_fts` + triggers `pages_fts_ai/ad/au` + backfill.
- `init_db()` termine désormais par `apply_migrations(conn)` : nouveau schéma et bases
existantes convergent sans rejouer les migrations déjà appliquées.
### Tests
- **267 tests** (8 nouveaux dans `tests/test_search_migrations.py`) : suivi de la version
`schema_version`, indexes créés, table/triggers FTS5, synchronisation de l'index,
recherche pages+collections, exclusions de la corbeille, requête vide et sans résultat.
### Corrections
- **Test de connexion multi-provider** : le modèle configuré globalement (ex. `deepseek-v4-flash`)
n'est plus envoyé lorsqu'on teste un **autre** provider (ex. nvidia) — c'était la cause d'un
HTTP 404 « model not found ». Chaque provider utilise désormais son modèle par défaut tant que
le modèle global ne le concerne pas.
- **Presets NVIDIA actualisés** : les anciens modèles (`nvidia/llama-3.1-70b-instruct`,
`nvidia/nemotron-4-340b-instruct`) étaient retirés de la plateforme ou soumis à abonnement ;
liste remplacée par des modèles disponibles (`nvidia/nemotron-3-super-120b-a12b`,
`meta/llama-3.1-70b-instruct`, `deepseek-ai/deepseek-v4-pro`, `z-ai/glm-5.2`, …).
> Note : un 404 persistant après cette correction vient de NVIDIA lui-même (code
> « Function … : Not found for account ») — cause fréquente : le droit « Public API Endpoints »
> pas activé sur le compte/clé pour le modèle choisi.
## v4.15.4 (2026-09-06) — Correctifs listes agent (@ et /)
### Corrections
- **Menus « @ » vides** : le template Alpine du menu de mentions avait deux racines
(Alpine n'en accepte qu'une) — seuls les titres de sections s'affichaient, sans éléments.
Les éléments sont maintenant bien rendus sous chaque section.
- **Sélection clavier visible dans le menu « / »** : le changement de focus (↑/↓) n'était pas
visible faute de style `.focus` ; ajout d'un surlignage (fond + barre d'accent à gauche).
## v4.15.3 (2026-09-06) — Agent : mentions @ par espace courant + skills composés en arrière-plan
> La commande « @ » propose en priorité les **fichiers de l'espace de travail ouvert**, puis les
> autres contextes (collections, pages de collections, autres documents). La navigation clavier
> (↑/↓) positionne maintenant visuellement la sélection dans la fenêtre (défilement), et les
> fichiers identiques ne sont plus affichés plusieurs fois (déduplication par titre).
### Améliorations
- **Mentions @ contextuelles** : nouvelle API `/api/agent/mentions` par **sections** :
*Fichiers de l'espace courant* (« `workspace_id` transmis par le frontend »), *Collections / bases*,
*Pages de collections*, *Autres documents*.
- **Navigation clavier corrigée** : ↑/↓ font défiler la liste ouverte pour garder la sélection
visible à l'écran (aussi appliqué au menu `/`). Entrée/Tab sélectionne toujours l'élément focalisé.
- **Déduplication** : deux fichiers du même type et même titre (ex. « Sans titre », « Aussi Vide »)
n'apparaissent qu'une seule fois dans les propositions.
- **Skills composés en arrière-plan** : sélectionner un skill n'injecte plus son texte dans la boîte
d'édition. À l'envoi la requête est assemblée : `contexte1 + contexte2 + skill-1 + skill-2 + texte`.
Les skills intégrés (templace) et enregistrés (skill_ids) sont tous deux pris en compte ; les
contextes @ continuent d'être résolus en contenu côté serveur.
## v4.15.2 (2026-09-06) — Agent : skills multi-épinglés en bulles + chips de contexte cliquables
> Les skills s'épinglent désormais au contexte **comme les mentions @** (bulles « / » purples dans
> l'éditeur du chat), on peut en associer **plusieurs** à un même post, et chaque bulle de contexte
> référencée s'ouvre d'un clic comme document courant.
### Améliorations
- **Skills en bulles** : sélectionner un skill (menu `/`, intégré *ou* enregistré) ajoute une vraie
bulle dans le composer, comportement identique aux mentions `@`. La bulle d'un skill intégré greffe
son modèle de demande dans le texte ; plusieurs skills peuvent coexister.
- **Plusieurs skills par post** : l'API `/run` accepte `skill_ids` (tableau) et l'engine injecte tous
leurs prompts dans les instructions système (`skill_id` reste supporté pour la rétro-compatibilité).
- **Chips de contexte cliquables** : cliquer sur une bulle de contexte (`@ document / page / base`)
ouvre l'élément comme document courant — `document:<id>`/`page:<id>` → `/pages/<id>`,
`collection:<id>` → `/db/<id>`. Le bouton « ✕ » retire toujours la bulle (sans ouvrir).
- Envoi possible avec uniquement des skills (sans texte) : « Applique le(s) skill(s) sélectionné(s)… ».
### Correctifs
- L'écriture du chat (`send`) renvoie désormais tous les `skill_id` épinglés via `skill_ids`.
## v4.15.1 (2026-09-06) — Agent : réparation des conversations réelles (protocole tool_calls)
> Le chatbot ne « répondait » plus : avec un fournisseur réel, dès qu'un **outil** était appelé la passe
> suivante était refusée par l'API (message assistant sans ses `tool_calls`) et l'agent retombait
> silencieusement sur le **mock hors-ligne**, qui se contentait de répéter la question.
### Correctifs
- **Protocole tool-calling conforme** (`agent_engine.py`) : le message assistant qui annonce un appel
d'outil transporte désormais ses `tool_calls` (avec `id`), et chaque résultat d'outil répond avec le
`tool_call_id` correspondant (y compris en cas de refus/permission). Le run se poursuit alors
correctement et se termine par une vraie réponse du modèle.
- **Plus de repli silencieux** (`llm_client.py`) : quand un fournisseur **réel** configuré échoue,
l'erreur remonte (événement SSE `error`) au lieu de produire silencieusement un écho hors-ligne.
Le mock n'est utilisé que si le fournisseur est réellement non configuré (`offline` / pas de clé).
- Les `tool_calls` analysés conservent leur `id` et les arguments bruts (`arguments_raw`).
- Test de régression `test_engine_tool_protocol_messages`.
## v4.15.0 (2026-09-06) — Agent : actions sous chaque réponse, mentions @ & skills /
> Le panneau FlowDeck Agent gagne l'expérience « type Notion AI » : chaque réponse de l'agent expose
> des actions rapides (copier, insérer dans la page, feedback 👍/👎), on peut **épingler au contexte**
> plusieurs documents / pages / bases via la commande **@** (ou le bouton **+**), et la commande **/**
> propose des **skills** de travail réutilisables en plus des commandes admin existantes.
### Sous chaque réponse de l'agent
- **⧉ / ➕ / 👍 / 👎** : boutons **à icônes seules** (infobulle au survol) — copier la réponse,
insérer dans la page ouverte, feedback 👍/👎 enregistré en base.
- Le bandeau statique « Contexte : … » du haut du panneau est supprimé.
### Contexte épinglé — commande « @ » / bouton « + » (style Notion AI)
- Le composeur est maintenant un éditeur **contenteditable** : les références sélectionnées
s'insèrent **inline** comme des pastilles cliquables (icône + nom) directement dans le message.
- Au-dessus du composeur, une **barre de contexte groupée** rassemble les fichiers épinglés
(rangée documents, icônes 📄) et les **skills** (rangée séparée, icône ✨), chacun retirable.
- **@** ouvre le sélecteur (icône + titre + chemin) listant documents, pages et bases ; l'élément
choisi devient un jeton inline **et** une puce de contexte. **+** épingle un élément au contexte
sans l'insérer inline. Les puces persistent jusqu'au retrait (et sont vidées en nouveau chat).
- **Contexte d'ouverture** : ouvrir l'agent sur un document (`/pages/:id`) ou une base (`/db/:id`)
ajoute automatiquement ce contexte comme **mention** (puce épinglée) — plus de bandeau statique.
- À l'envoi, chaque référence est transmise comme **mention** ; le backend résout son **contenu réel**.
### Skills — commande « / »
- Le menu **/** liste les **skills intégrés** (research, resume, brainstorm, plan, meeting-note,
livrable…), les **skills enregistrés** (table `agent_skills`) et les commandes admin classiques.
- Un skill intégré pré-remplit le composeur d'un modèle à compléter ; un skill en base s'épingle en
chip et est appliqué via `skill_id` à chaque envoi.
- Navigation clavier (↑/↓/Entrée/Échap) dans les menus ; aucun envoi parasite pour « / » seul.
### Infra & API
- Nouvelle table `agent_feedback` (rating, conversation/message, extrait, commentaire).
- `GET /api/agent/mentions?q=` : recherche des éléments mentionnables du workspace.
- `POST /api/agent/feedback` : enregistre un retour 👍/👎.
- `VERSION` → 4.15.0 ; `app/main.py` version → 4.15.0.
## v4.14.0 (2026-09-06) — Agent : création de documents dans un espace + nouveaux outils & titre auto
> L'agent sait enfin **créer, lire, modifier et supprimer des documents** (pages éditeur) **dans un
> espace de travail** — la demande « crée un document « Projet » dans le workspace « TEST WS 1 » »
> ne se termine plus par un simple `search_workspace` vide. Il liste aussi les espaces, et **renomme
> automatiquement les conversations** avec un titre descriptif au lieu de « Nouvelle conversation ».
### Outils (nouveaux / corrigés)
- **`create_document`** : crée une page éditeur (`pages`) dans un espace (par `workspace_id` ou
`workspace_name`), avec contenu Markdown optionnel — snapshot d'undo.
- **`read_document`** : lit un document (titre + contenu + métadonnées).
- **`read_workspaces`** : liste les espaces accessibles (rôle, nb de documents/collections).
- **`delete_document`** : suppression → corbeille (`deleted_at`), annulable (`softdelete`).
- **`write_blocks`** étendu : renommage de titre et/ou écriture des blocs ; undo restaure titre + contenu.
- **`search_workspace`** étendu : recherche désormais aussi les **documents** et les **espaces de travail**.
### Contexte & planification
- Contexte de l'agent enrichi : sections « Documents » et « Espaces de travail » (avec ids) pour que
le LLM cible le bon espace. Guide mis à jour. Mock hors-ligne : détection
« créer un document dans un espace » / « lister les espaces ».
### Conversations
- Auto-titre : après la première exécution d'une conversation encore nommée « Nouvelle conversation »,
l'agent génère un titre descriptif (déterministe, hors-ligne sûr) ; l'historique devient navigable.
### UX
- Indication claire dans le composeur du panneau : **Entrée ↵ = envoyer · Maj+Entrée = nouvelle ligne**
(le comportement existait déjà, il est maintenant visible + placeholder mis à jour).
### Infra
- `VERSION` → 4.14.0 ; `app/main.py` version → 4.14.0.
## v4.13.2 (2026-09-06) — Saisie libre « écrire un texte » → proposition à appliquer au document
> Quand un **document est ouvert** et qu'on tape directement une demande de **rédaction/contenu**
> dans le chat (« Écrire un texte sur… », « Rédige… », « Résume… », « Traduis… »), la requête est
> maintenant routée vers la **génération de texte** (sans outils) et affichée comme **proposition
> à appliquer ou rejeter** — au lieu d'un `search_workspace` puis d'un simple « Objectif traité ».
> « Traduire » remplace le contenu de la page ; les autres demandes l'insèrent en fin de page.
### Fix
- Envoi libre avec contexte document : détection d'intention de contenu (écrire/rédiger/générer/
résumer/traduire/améliorer/note de réunion/texte sur/série…) → `POST /api/agent/generate` +
aperçu « ✓ Appliquer au document / ✕ Rejeter ».
- Mock : « écrire » (infinitif) reconnu comme verbe de rédaction.
### Infra
- `VERSION` → 4.13.2 ; `app/main.py` version → 4.13.2.
## v4.13.1 (2026-09-06) — Corrections actions contenu Agent & aperçu à approuver
> Corrige les actions de contenu sur document (« Résumer », « Traduire cette page », « Proposer
> des améliorations », « Créer une note de réunion ») qui partaient en recherche (`search_workspace`)
> sans retour utile, et ajoute un flux **aperçu des changements → Appliquer / Rejeter**.
### Fix
- **Le contenu du document n'est plus interprété comme des intentions d'outil** : le planificateur
(mock) n'analyse que l'objectif de l'utilisateur, jamais le texte injecté dans `# Contexte`. Un
document contenant « recherche », « collection », etc. ne déclenche plus `search_workspace` /
`create_collection` intempestifs.
- **Ask AI** n'emprunte plus le canal outil : il utilise la génération de texte directe.
### Added
- **Endpoint `POST /api/agent/generate`** : génération de texte **sans outils** (l'IA répond à
partir du contexte du document fourni, sans chercher/créer dans le workspace).
- **Aperçu & approbation** : les actions de contenu sur document affichent la **proposition** de
l'agent dans le panneau avec boutons **« ✓ Appliquer au document » / « ✕ Rejeter »**. Appliquer
insère la proposition (mode `insert`) ou **remplace** le contenu de la page (mode `replace` pour
« Traduire cette page ») via `window.fdApplyDocument()` ; rejeter la conserve sans modification.
En mode hors-ligne, l'action explique que la modification nécessite un modèle connecté (pas
d'application accidentelle).
### Infra
- `VERSION` → 4.13.1 ; `app/main.py` version → 4.13.1.
## v4.13.0 (2026-09-06) — Agent : panneau « Notion AI », ouverture fiable (Ctrl+J) & contexte universel
> Refonte complète du panneau Agent (style Notion AI) : ouverture/fermeture **fiable** (API
> `window.fdAgent.open/close/toggle`, bouton flottant + sidebar « Agents » + raccourci `Ctrl/Cmd+J`),
> **contexte détecté et épinglé à l'ouverture** (document en cours, bibliothèque, My Tasks,
> espaces, base de données, board, corbeille…) et sélecteur **fournisseur + modèle** intégré à la
> zone de saisie.
### Added
- **Ouverture robuste** : `window.fdAgent.open()/close()/toggle()` appliquent l'état directement
(classe `.open` + attribut) ; FAB, entrée « Agents » du sidebar, icône Chat et `fdAgent.ask`
ouvrent explicitement le panneau (fini le toggle silencieux). Raccourci **`Ctrl/Cmd+J`** global.
- **Contexte universel `window.FlowDeckCtx`** : détection par priorité —
(1) marqueur d'écran (`window.fdViewContext` / `data-fd-context`), (2) **document ouvert**
(éditeur : titre + contenu des blocs), (3) **route** de l'application (Accueil, Bibliothèque,
Corbeille, My Tasks, Espaces, Espace local/Gitea, Base `/db`, Board, Notes, Paramètres…).
Le contexte est **épinglé à l'ouverture** du panneau, affiché en chip (« Contexte : … ») et
**rafraîchi** automatiquement si l'utilisateur ouvre un autre document avant d'envoyer.
- **Fournisseur & modèle affichés** : sous-titre du panneau `Provider · Modèle` (ex. `OpenAI ·
gpt-4o`) dès qu'un modèle réel fonctionnel est sélectionné ; « hors-ligne (mock) » n'apparaît
que lorsqu'aucun fournisseur actif n'existe. Sélecteurs provider → modèle regroupés dans une
pastille « Auto · <model> » au-dessus de la zone de saisie.
- **Actions rapides contextuelles** (état vide) en français selon la section : Résumer / Traduire /
Améliorer un document, Créer une note de réunion, Analyser une base, Mes tâches / planifier,
ou guides « Comment utiliser FlowDeck ? » et création de collection/board.
### Changed
- **Panneau redessiné façon Notion AI** : largeur 470 px, en-tête « How can I help you today? »,
icône avatar, ligne de contexte, historique, grande `textarea` arrondie auto-redimensionnable
(jusqu'à ~220 px) avec toolbar (`+`-équivalent, contexte, modèle, flèche d'envoi) et sélection
de modèle en popover.
### Infra
- `VERSION` → 4.13.0 ; `app/main.py` version → 4.13.0.
## v4.12.1 (2026-09-05) — Corrections configuration Agent & IA + AI Meeting Note
> Correctifs UI sur la section **Settings → Agent & IA** et remplacement de l'ancien bouton
> « AI meeting note » (qui insérait du texte) par une vraie **boîte de réunion interactive**.
### Fix
- **Champs « Clé API »** : le `x-text` embarquait le SVG de l'icône œil dans l'attribut HTML, ce qui
cassait l'attribut et laissait du texte résiduel (`sk-…': '👁'" style="top:50%;">`). Les boutons
d'affichage/masquage utilisent désormais deux `<span x-show>` avec l'icône en contenu.
- **Champs « URL API »** : pré-remplis avec l'URL réellement utilisée par l'application (base par
défaut du fournisseur, ex. `https://api.openai.com/v1`) quand aucune URL personnalisée n'est
stockée — exposée via `base_url` dans `/api/agent/providers` (fournisseur par défaut + au
changement de provider).
### Changed
- **AI Meeting Note** : le bouton « Get started » ne génère plus du texte via l'agent. Il crée un
bloc **`meeting`** interactif dans la page (page renommée « Meeting @date » si vide) :
- Étape 1 — consentement (moi-même / message audio auto) + « Start transcribing » ;
- Étape 2 — transcription **live** via Web Speech API (fr-FR, horodatée), boutons Pause / Stop,
fallback « sans audio » (transcript à saisir/coller) si le navigateur ne supporte pas ;
- Étape 3 — onglets Notes / Transcript / Summary, bouton « Générer le résumé » (helper headless
`window.fdAgent.generate` sur le FlowDeck Agent) produisant Résumé / Décisions / Action items,
copie presse-papiers et « Nouvelle transcription ».
- Persistance du bloc et de son état dans la page (champ `meeting` ajouté aux blocs sauvegardés).
### Infra
- `VERSION` → 4.12.1 ; `app/main.py` version → 4.12.1.
## v4.12.0 (2026-09-05) — Agent & IA : refonte des réglages, fournisseurs activés/fonctionnels dans le panneau, contexte document & guide
> Refonte de la section **Settings → Agent & IA** pour la rendre plus claire, et aligne le panneau
> Agent sur la réalité de la configuration : **seuls les fournisseurs configurés ET dont le test de
> connexion a réussi** sont proposés (avant, tous les fournisseurs connus apparaissaient, même non
> configurés). Le panneau sait aussi sur quel **document** l'utilisateur travaille et peut guider
> sur l'utilisation de l'application quand aucun document n'est ouvert.
### Added
- **Vérification des fournisseurs** : colonnes `verified` / `verified_at` / `last_error` sur
`user_llm_keys` et `llm_config` (migration automatique au démarrage). Un fournisseur est
« fonctionnel » après un test de connexion réussi ou un chargement de modèles réussi.
- **Endpoint non-admin** `POST /api/agent/keys/{provider}/test` : teste la clé enregistrée de
l'utilisateur (sans mock) et met à jour l'état `verified` ; l'endpoint admin `/providers/test`
marque le fournisseur par défaut de l'espace.
- **`/api/agent/providers` enrichi** : chaque provider expose `configured`, `verified`,
`functional`, `last_error` et `source` (user/global/open) ; `default_provider` + `default_verified`
en tête de réponse.
- **Panneau Agent** : les listes fournisseur/modèle ne contiennent plus que les providers
**activés et fonctionnels** (+ mode hors-ligne). Bandeau « Aucun fournisseur activé » avec lien
vers les réglages et rappel des clés « à tester » le cas échéant.
- **Contexte document** : le panneau joint automatiquement la page ouverte (titre + contenu des
blocs) à chaque requête — l'agent peut interagir avec le document courant.
- **Guide d'utilisation** : le prompt système intègre un guide FlowDeck concis pour répondre aux
questions « comment faire… ? » (pages, collections/vues, Gitea, corbeille, réglages…) lorsqu'aucun
document n'est fourni.
### Changed
- **Settings → Agent & IA refondu** : carte de statut (prêt / N fournisseurs actifs / mode
hors-ligne), cartes de fournisseurs avec badges d'état, erreur du dernier test, boutons
« Enregistrer & tester », « Tester », « Modèles », « Supprimer » et encart d'aide.
- Le bouton **Save configuration** admin gagne un rappel « Tester la connexion » pour activer le
fournisseur pour tous ; changer de clé réinitialise la vérification.
### Fix
- Saisie clé : une clé vide conserve désormais l'existante aussi pour la config globale (au lieu de
l'effacer), et un changement de clé invalide l'ancien statut `verified`.
### Infra
- `VERSION` → 4.12.0 ; `app/main.py` version → 4.12.0.
## v4.11.1 (2026-09-05) — Correction & intégration des points d'entrée IA
> Aligne les entrées AI de l'interface sur le FlowDeck Agent (alignement Guide Notion AI) et corrige
> les trois boutons qui ne fonctionnaient pas : **Ask AI**, **AI meeting note** (toolbar « Get
> started » de l'éditeur de page) et le **bouton rond flottant FlowDeck Agent** (coin bas-droit).
### Fix
- **Bouton flottant Agent 🤖 (coin bas-droit)** : le chat restait inerte tant qu'aucune conversation
n'existait (`send()` retournait silencieusement sans `currentConv`). Ajout d'une création de
conversation **lazy** (`ensureConversation()`) effectuée avant chaque envoi — la 1ère saisie crée
la conversation puis l'exécute (mode offline ou LLM réel).
- **Mode hors-ligne plus utile** : le mock LLM (`_draft_reply`) génère désormais un **vrai contenu
structuré** pour les demandes de rédaction de page (« Ask AI », titre détecté via « intitulée »)
et les **AI meeting notes** (squelette Résumé / Décisions / Action items), au lieu de se contenter
de renvoyer le prompt tel quel — les boutons produisent donc du contenu même sans clé API.
- **« Ask AI » & « AI meeting note »** : ne faisaient qu'insérer du texte placeholder
(« Ask AI to write... » / « AI Meeting Note ») sans aucun appel IA. Ils déclenchent désormais une
**génération inline réelle** via le FlowDeck Agent :
- méthodes `askAI()` / `meetingNote()` / `aiRun()` / `applyAIBlocks()` sur l'éditeur de page ;
- un prompt contextuel est envoyé au panneau agent (exposition d'`window.fdAgent.ask`) ;
- la réponse (`event final` du flux SSE) est **insérée automatiquement dans la page** sous forme
de blocs édités (hook `window.__fdInsertAI`).
- Un état `aiBusy` évite les doubles envois pendant la génération.
### Infra
- `VERSION` → 4.11.1.
## v4.11.0 (2026-09-05) — Agent IA : clés API par utilisateur & chargement dynamique des modèles
> Suite de v4.10.1 : migre la config LLM d'un **global admin** vers des **credentials par
> utilisateur**. Chacun peut enregistrer plusieurs provider + clé API dans son profil, les modèles
> disponibles sont **chargés automatiquement depuis le fournisseur**, et le provider/modèle actif
> d'une conversation se pilote **directement dans le chat** via des commandes slash.
### Added
- **Clés par utilisateur** : table `user_llm_keys` (UNIQUE(user_id, provider)) + service
`app/services/llm_config.py` : `get_user_llm_key`, `list_user_llm_keys` (masqué),
`upsert_user_llm_key`, `delete_user_llm_key`. Une clé vide conserve l'existante.
- **Chargement dynamique des modèles** : `fetch_provider_models()` (httpx) — GET `{base}/models`
pour OpenAI-compatible (Bearer), headers `x-api-key`/`anthropic-version` (Anthropic),
`x-goog-api-key` (Gemini) ; stockés dans `models_json` et fusionnés dans `GET /providers`.
- **Endpoints API** (tout utilisateur authentifié) :
- `GET /api/agent/keys`, `PUT/DELETE /api/agent/keys/{provider}`,
- `POST /api/agent/keys/{provider}/models` (fallback sur la clé stockée, pas de fuite de clé),
- `PATCH /api/agent/conversations/{id}` (title / provider / model — commande slash).
- **`POST /run`** : utilise la clé API de l'utilisateur pour le provider de la conversation
(sinon config globale / `.env` / offline mock).
- **UI Settings → Agent & IA** (visible pour tous, plus seulement admin) : carte par provider
(clé API masquée, URL API, « Charger les modèles » en live, modèle par défaut,
Enregistrer / Supprimer). La config globale reste admin sous « Configuration globale ».
- **Commandes slash dans le panel agent** : `/help`, `/providers`, `/keys`,
`/provider <nom>`, `/model <nom>` — menu de suggestions live, appliquées via `PATCH`
et sans appel LLM.
### Infra
- `VERSION` → 4.11.0 ; migration idempotente (création table `user_llm_keys` + index).
### Tests
- **239 tests verts** (+7 : CRUD clés + masquage des secrets, isolation par utilisateur,
provider invalide, fetch modèles (clé stockée / body gagnant / erreur), PATCH conversation,
`/run` utilise la clé de l'utilisateur).
> Comble le trou UX de v4.10.0 : l'utilisateur pouvait discuter avec l'agent mais **ne pouvait
> configurer ni provider ni modèle**. Ajout d'un sélecteur provider/modèle dans le panneau, d'une
> config runtime persistée en base (table `llm_config`) et d'un écran admin avec test de connexion.
### Added
- **Sélecteur provider/modèle dans le panneau agent** (`agent_panel.html`) : deux `<select>`
(provider + modèles connus par provider) dans l'en-tête du chat, branchés sur `POST /run` et
**persistés par conversation** (colonnes `provider`/`model` sur `agent_conversations`,
migrées automatiquement). Bouton ⚙️ pointant vers la config admin.
- **Endpoints API** :
- `GET /api/agent/providers` enrichi : liste complète des providers avec modèles connus,
`requires_key`, `api_base` et `has_api_key`.
- `PATCH /api/agent/providers` (admin) : persiste provider/modèle/clé/base en runtime.
- `POST /api/agent/providers/test` (admin) : test de connexion **sans fallback mock**
(méthode `LLMClient.ping()`) ; le provider `offline` répond sans réseau.
- **Table `llm_config`** (1 ligne, id=1) + service `app/services/llm_config.py` :
précédence **base (runtime) > `.env`**, créée seulement au premier enregistrement.
- **Écran admin** : nouvelle section *Agent & IA* dans Settings (onglet Admin) : provider,
modèle, clé API (masquée / réaffichable), URL API optionnelle, boutons
**Save configuration** et **Test connection** avec retour d'état inline.
### Fixed
- `LLMClient.__init__` : le modèle par défaut suit maintenant la config runtime
(DB > `.env`) au lieu d'être toujours `gpt-4o` ; `PROVIDER_MODELS` documente les modèles
connus par provider.
### Infra
- `VERSION` → 4.10.1 ; migration idempotente des colonnes `agent_conversations.provider/model`.
### Tests
- **232 tests verts** (+6 : providers enrichi, PATCH config (admin + 403 non-admin),
test connexion offline, test connexion clé manquante, persistance provider/modèle par conversation).
## v4.10.0 (2026-09-05) — FlowDeck Agent : agent IA natif conversationnel
> Implémentation complète du pilier « Agent IA » : un agent ReAct intégré qui **agit** sur le
> workspace (pas seulement répond) — panneau de chat, boucle raisonnement→action, streaming SSE,
> 18 outils avec snapshots rollback, permissions par rôle, contexte automatique, custom agents,
> déclencheurs planifiés, skills réutilisables et multi-LLM (offline mock + 8 providers).
### Added
- **Interface conversationnelle** : panneau agent dans le sidebar (🤖 Agents) piloté par Alpine.js +
streaming SSE ; FAB ouvrant le chat ; historique de conversations.
- **Boucle ReAct** (`app/services/agent_engine.py`) : raisonnement → action → observation, max 12
itérations, budget de tokens, timeout, journal d'audit + fonction `undo_action()` (rollback).
- **Streaming SSE** : endpoint `/api/agent/conversations/{id}/run` qui émet `reasoning`, `action`
et `final` en temps réel.
- **18 outils** (`app/services/tool_registry.py`) : `search_workspace`, `read_collection`,
`read_page`, `create_collection`, `create_view`, `add_property`, `create_page`, `update_page`,
`write_blocks`, `add_relation`, `create_sub_item`, `add_dependency`, `apply_template`,
`delete_page`, `delete_collection`, `read_gitea_issues`, `sync_gitea`, `create_gitea_issue` —
chacun avec `ToolResult` + snapshot d'undo réversible.
- **Permissions** (`app/services/permission_manager.py`) : même ACL que l'utilisateur par rôle
workspace (viewer/editor/admin/owner) + gating des outils.
- **Contexte automatique** (`app/services/context_builder.py`) : snapshot du workspace actif,
collections, pages, mentions `@`, fichiers uploadés injectés dans le prompt système.
- **Multi-LLM** (`app/services/llm_client.py`) : OpenAI, Anthropic, Google, Ollama, DeepSeek,
QwenCloud, NVIDIA, OpenRouter + **offline mock** (planner déterministe, aucune clé requise).
- **Custom agents, skills, déclencheurs** : CRUD agents + conversations, `agent_*` tables
(`agents`, `agent_conversations`, `agent_messages`, `agent_actions`, `agent_skills`,
`agent_triggers`), skills (`/api/agent/skills`, apply), scheduler de fond pour triggers planifiés.
### Infra
- `VERSION` → 4.10.0 ; config LLM (`LLM_PROVIDER/MODEL/API_KEY/API_BASE`, `AGENT_MAX_*`) ;
exclusions CSRF `/api/agent` ; routeur agent + tâche scheduler dans le lifespan de `app/main.py`.
### Tests
- **226 tests verts** (+18 v4.10.0 : schéma DB, mock LLM, permissions, outils+undo, engine ReAct +
audit + rollback, CRUD agents/conversations, streaming SSE, skills, tools, providers).
## v4.9.0 (2026-09-04) — Collaboration : commentaires inline, mentions @, notifications
> Implémentation complète du pilier « Collaboration » — basée sur la revue des docs Notion
> (`docs/Guide_Complet_Notion_sharing_collaborartion.md`) : commentaires avec ancres inline,
> mentions `@` avec autocomplétion, centre de notifications in-app et emails SMTP.
### Added
- **Commentaires inline sur les pages** : sélection d'un texte dans l'éditeur → bouton flottant
« 💬 Comment » → commentaire ancré (`block_id` + offsets). Table `comments` étendue
(`target_type`, `target_id`, `anchor_block_id`, `anchor_start`, `anchor_end`) et **migrée**
de son FK `collection_pages` vers un schéma générique (aucune perte de données, idempotente).
- **Panneau de commentaires** dans l'éditeur : liste, résolution (✔/↪), suppression, compteur
dans la topbar.
- **Mentions `@`** : autocomplétion en temps réel (recherche d'utilisateurs), insertion de
`@login` dans un bloc ou un commentaire → notification ciblée à l'utilisateur mentionné.
- **Centre de notifications in-app** : cloche dans la topbar (badge non-lus, polling 30s),
panneau déroulant, « mark as read » / « mark all read ». Table `notifications`.
- **Emails de notification** : service SMTP (`app/services/mailer.py`) + templates HTML ;
préférences email par utilisateur (comments/mentions) réglables dans Settings → Notifications.
Sans SMTP configuré, seules les notifications in-app sont émises (aucune erreur).
- **API** : `GET/POST /api/notifications`, `/api/notifications/read|read-all|prefs`,
`/api/notifications/users/search`, `GET/POST /api/pages/{id}/comments`,
`PUT/DELETE /api/comments/{id}`, `POST /api/pages/{id}/mentions`.
- **Config SMTP** (`SMTP_HOST/PORT/USER/PASSWORD/FROM/USE_TLS`, `APP_BASE_URL`) dans `.env.example`.
### Infra
- `VERSION` → 4.9.0 ; `app/main.py` (log + `version=`) ; router `notifications` + `collaboration`
enregistrés ; exclusions CSRF (`/api/notifications`, `/api/comments`) ; le `RateLimitMiddleware`
respecte désormais `settings.rate_limit_enabled`.
### Tests
- **208 tests verts** (+9 v4.9.0 : table notifications, commentaires inline + mentions, centre de
notifications, préférences, recherche utilisateurs, résolution/suppression, mentions de page).
### Fixed
- **Bouton « + Row » (bas du tableau) inopérant** — le code passait la nouvelle ligne en argument `deleteCount` de `Array.splice` au lieu d'utiliser `splice(index, 0, ligne)` : aucune ligne n'était jamais insérée. Corrigé ; les insertions de lignes (menu contextuel aussi) fonctionnent.
- Coloration/handles alignés sur l'en-tête.
### Added
- **Bouton « + » à droite du tableau** (sur toute la hauteur) → ajoute une colonne à la droite (équivalent d'un « Add column » comme « Add Row »).
- **Redimensionnement des colonnes à la souris** : au survol d'une bordure de colonne (en-tête) le curseur devient `col-resize` ; glisser ajuste la largeur. Les largeurs sont persistées dans le bloc (`colsW`) → conservées au rechargement, à l'ajout/suppression et dans l'auto-save.
### Infra
- `VERSION` → 4.8.1 ; cache CSS `app.css?v=4.8.1`.
## v4.8.0 (2026-09-03) — Bloc Tableau simple (mode édition Notion)
> Insertion via `/table`, cellules éditables au clic, ajout/suppression/modification de lignes et colonnes comme dans Notion.
### Added
- **Bloc `table` dans l'éditeur de page.** Nouvelles commandes slash `/table` (menu « BASIC ») → tableau 3×3 avec en-tête ; première ligne stylée en en-tête, cellules `contenteditable` éditées au clic, auto-sauvegarde (format de bloc conservé : `{type:'table', rows:[...]}`).
- **Gestion des colonnes** : bouton de poignée (⋮⋮) au-dessus de chaque colonne → menu contextuel Insert left / Insert right / Duplicate / Clear contents / Delete column / Delete table.
- **Gestion des lignes** : bouton « + Row » sous le tableau (clic = ajouter une ligne) + menu contextuel (clic droit sur une cellule de corps) → Insert above / Insert below / Duplicate / Clear row / Delete row / Delete table.
- **Import Markdown (GFM)** : `md2b` détecte les tableaux pipe (`| a | b |` + ligne de séparateurs) et produit un bloc `table` éditable au lieu de paragraphes aplatis (pages `.md`/markdown).
- **Roundtrip Markdown** : `blocksToMarkdown` (JS) et l'export Python reconstruisent un tableau pipe valide depuis les blocs.
- **Export** : les pages contenant un bloc `table` sortent correctement en Markdown / HTML / PDF (géré côté serveur).
- UX : cliquer sous un tableau en fin de page ajoute un paragraphe pour continuer la rédaction.
### Infra
- `VERSION` → 4.8.0 ; `app/main.py` (log + `version=`) ; cache CSS `app.css?v=4.8.0` ; ROADMAP renuméroté (v4.8.0 Bloc Tableau, Collaboration → v4.9.0, Agent → v4.10.0).
### Tests
- Suite pytest verte (199) ; vérification syntaxe JS du template via `node --check`.
## v4.7.3 (2026-09-03) — Fix export PDF/HTML : tableaux + émojis
### Fixed
- **Tableaux Markdown rendus comme du texte brut** dans les exports HTML/PDF (les lignes `| a | b |` ressortaient en paragraphes). Ajout d'un parseur GFM de tableaux (`_md_to_blocks` → bloc `table`) et d'un rendu en vrai `<table>` (en-tête `<th>`, corps `<td>`, alignement gauche/centre/droite, bordures fines via `.ftable`). Le format Markdown reconstruit un tableau pipe valide.
- **Émojis en « carrés noirs » dans le PDF.** Cause : le moteur `xhtml2pdf` (reportlab) n'embarque que des polices de base sans glyphes Unicode/émojis. Le moteur PDF passe à **WeasyPrint** (vraies tables CSS + émojis couleur via Pango + `fonts-noto-color-emoji`, installés dans l'image Docker). Repli automatique sur `xhtml2pdf` si WeasyPrint n'a pas ses libs natives (ex. poste de dev Windows) — le contenu s'exporte quand même.
### Infra
- `Dockerfile` : ajout des libs WeasyPrint (pango/harfbuzz/gdk-pixbuf/shared-mime-info) + polices `fonts-dejavu-core` et `fonts-noto-color-emoji`.
- `requirements.txt` : + `weasyprint==69.0` (xhtml2pdf conservé en repli).
### Tests
- **199 tests** (4 nouveaux) : parse table → `<table>` HTML réel + alignements ; standalone HTML embarque `.ftable` ; roundtrip table → markdown pipe ; PDF d'une page avec table = PDF valide.
- Vérifié en réel sur `README.md` : PDF avec tableau structuré + émojis en couleur (pixels colorés confirmés), HTML avec tableau bordé.
## v4.7.2 (2026-09-03) — Fix export : le contenu des documents était absent
### Fixed
- **Export Markdown / HTML / PDF : seuls le nom de page et le titre étaient inclus, jamais le contenu.** Cause : le service d'export ne lisait que les pages en `content_format='blocks'`. Or les documents peuvent être stockés autrement :
- `content_format='file'` (fichiers `.md`/code uploadés) : le `content` de la page n'est que des métadonnées JSON — le vrai texte est un fichier sur disque (`/data/uploads/workspace_*`). L'export n'allait jamais le lire → uniquement le titre.
- `content_format='markdown'` : le HTML/PDF se contentait d'envelopper chaque ligne en `<p>` (titres et listes aplatis).
- Le service résout désormais la vraie source pour les 3 formats : lit le fichier sur disque pour les pages `file`, rend les pages `markdown`/fichiers `.md` en blocs (headings, listes, code…), et traite les fichiers texte non-markdown comme bloc de code. Les fichiers binaires (PDF/images) sont laissés de côté (rien d'exploitable).
- Chemin des uploads rendu configurable pour les tests via `FLOWDECK_DATA_DIR` (défaut `/data`, identique à `dashboard.py`).
### Tests
- **195 tests au total** (5 nouveaux v4.7.2) : page markdown → corps exporté ; page markdown → HTML avec headings/listes ; fichier `.md` uploadé → contenu réel dans Markdown/HTML/PDF ; fichier code → exporté ; fichier binaire → non déversé.
- Vérifié en réel sur `/data` (page `README.md` et arborescence « Base de Connaissances » → fichiers `.md`) : le corps complet est maintenant présent dans les 3 formats.
## v4.7.1 (2026-09-03) — Fix UI éditeur : menus Share / More invisibles
### Fixed
- **Boutons « Share » et « ⋯ » (More) sans réaction visible dans l'éditeur de page** — les popovers (share-dialog, more-menu, activity-popover, move-dialog), enfants de `.page-editor-wrapper` (conteneur à `overflow-y:auto`), étaient ancrés en `absolute; top:100%` → rendus SOUS le viewport, donc invisibles. Repositionnés en `position:fixed` sous la topbar (scoped `.page-editor-wrapper > …` pour ne pas casser les `.more-menu` des pages library/local_workspace) + variantes mobile dans le media query < 768px.
- **Sous-menu Export se refermait** — l'itém « ↗ Export » faisait `moreOpen=false` avant d'ouvrir `exportOpen` : les 4 formats restaient inaccessibles au clic. Devient un toggle (`exportOpen = !exportOpen`), le menu More reste ouvert.
- **Cache CSS** — version du query string `app.css` bumpée (`v=3.0.0` → `v=4.7.1`) pour invalider le cache navigateur.
### Vérifié
- Playwright headless (desktop 1280×800) : Share, More, sous-menu Export, popover Activity et menu More après scroll = tous visibles dans le viewport ; téléchargement Markdown + toast « Exported as Markdown » OK ; zéro pageerror.
- 190/190 tests pytest passent.
## v4.7.0 (2026-09-03) — Export
### Added
- **Export Markdown** — conversion serveur complète de tous les blocs (headings, listes, to-do, quote, code, math, toggle, colonnes, callout, image), images incluses
- **Export Markdown récursif** — les sous-pages sont incluses automatiquement
- **Export PDF** — via `xhtml2pdf` (pur Python, aucun lib système), rendu print-friendly
- **Export HTML** — document autonome self-contained (styles inline)
- **Export Site** — site statique multi-pages en `.zip` (index.html + une page HTML par sous-page)
- **4 endpoints serveur** : `/api/export/markdown|html|pdf|site/{page_id}`
- **UI** : menu « More › Export » dans l'éditeur (Markdown, HTML, PDF, Site .zip)
### Améliorations
- Nouveau service `app/services/export.py` (conversion blocs → Markdown / HTML / PDF réutilisable)
- Nouveau routeur `app/routers/export.py`
- Téléchargements via `Content-Disposition` (avec nom de fichier encodé UTF-8)
### Tests
- **190 tests au total** (6 nouveaux tests v4.7.0) : Markdown, sous-pages, HTML, PDF, Site zip, 404
## v4.6.0 (2026-09-02) — Content Blocks Enrichis
### Added
- **Callout blocks** — boîtes colorées avec sélecteur d'emoji/icône cliquable
- **Table of contents** — bloc auto-généré depuis les headings, avec ancres de navigation
- **Math equations** — bloc LaTeX/KaTeX avec aperçu en temps réel (édition inline)
- **`table_of_contents` et `math`** — nouveaux types dans le slash menu (éditeur)
- **Multi-colonnes** — bloc `columns` avec layout flexible, bouton "+ Add column" / retrait
- **Toggle lists enrichis** — contenu expandable/collapsible avec blocs enfants imbriqués
### Améliorations
- Intégration **KaTeX 0.16.11 self-hosté** (`/static/js/katex.min.js`, `/static/css/katex.min.css`, `/static/fonts/`) — aucun CDN externe
- Rendu des nouveaux blocs dans les **pages publiques** (`/p/<slug>`): TOC ancré, KaTeX, colonnes flex, toggle `<details>`
- Export Markdown étendu aux nouveaux types de blocs
- Correction d'un bug pré-existant : `public_page.html` n'importait pas la macro `fd_icon` → erreur de rendu des pages publiées
### Tests
- **183 tests au total** (5 nouveaux tests v4.6.0) couvrant le rendu public TOC/math/colonnes/toggle et la persistance `children`
## v2.7.1 (2026-07-13) — Private Pages Gitea
### Added
- **Table `gitea_private_pages`**: pages FlowDeck locales liées à un projet Gitea (user_id, owner, repo)
- **API CRUD**: GET/POST/PUT/DELETE `/api/gitea/projects/{owner}/{repo}/private-pages`
- **UI**: liste des pages privées + éditeur titre/contenu dans le workspace Gitea
- **Sidebar**: lien "Private Pages" dans l'arborescence du workspace
- **Persistance**: les pages survivent au changement de workspace / session
### Fixed
- `get_user_repos(limit=100)` — `bruno/flowdeck` était tronqué à la page 2 (30 repos par défaut)
## v2.7.0 (2026-07-13) — Intégration Gitea Phase 1
### Added
- **OAuth2 Provider**: Gitea comme fournisseur OAuth2 pour FlowDeck (Client ID/Secret dans `.env`)
- **Redirect URI dynamique**: basé sur le header `Host` — fonctionne depuis `localhost` et `192.168.x.x`
- **Workspace listing**: 2 sections — My Workspaces + Gitea Projects (groupés par organisation)
- **Tabs par org**: All | user | org1 | org2 avec compteurs de projets
- **Barre de recherche**: filtrage live par nom/description dans Gitea Projects
- **Lazy tree**: arborescence chargée un dossier à la fois (API Gitea `GET /repos/{o}/{r}/contents`)
- **File viewer**: lecture fichiers Gitea avec décodage base64
- **Éditeur + commit**: modifier un fichier et commiter dans Gitea avec message personnalisé
- **Delete fichier**: suppression avec confirmation + commit
- **Arborescence dans le sidebar**: comme un workspace local, avec lazy-load des sous-dossiers
- **Settings › Integrations**: connecter/déconnecter Gitea (`GET /api/gitea/status`, `DELETE /api/gitea/disconnect`)
- **Fallback token admin**: opérations GET utilisent le token serveur si pas de token OAuth utilisateur
- **Write ops protégées**: commit et delete exigent un token OAuth utilisateur
- **Routes API**: `/api/gitea/orgs`, `/api/gitea/projects`, `/api/gitea/projects/{o}/{r}/tree`, `/api/gitea/projects/{o}/{r}/file`, `/api/gitea/projects/{o}/{r}/labels`
### Fixed
- Bouton "Register with Gitea" ne crée plus de session admin fantôme (fallback retiré)
- `_sidebar_data` gère le cookie `gitea:owner:repo` pour l'arborescence
## v2.6.0 (2026-07-13) — My Account & Auth Locale
### Added
- **Section My Account**: modifier username, full name, email, mot de passe
- **Toggle mot de passe**: bouton voir/cacher dans les formulaires login/register
- **Label "Email or username"**: login accepte les deux formats
- **Refresh cookie session**: après modification du compte, le cookie est mis à jour
- **Register avec Gitea**: boutons dynamiques "Login/Register with Gitea" selon l'onglet actif
### Fixed
- Session cookie non rafraîchi après `PUT /api/settings/account` (création nouvelle session)
- Input login `type="email"` → `type="text"` (accepte usernames non-email)
## v2.5.0 (2026-07-13) — Administration Avancée
### Added
- **Premier compte = admin automatique**: `is_admin=1` pour le premier utilisateur créé
- **Panneau Admin dans Settings**: visible uniquement pour les admins
- **Users & Roles**: créer/modifier/supprimer utilisateurs, changer rôles
- **Table `login_history`**: audit des connexions (IP, user_agent, timestamp)
- **Statistiques par utilisateur**: workspaces, pages, dernière connexion
- **API `/api/admin/*`**: users, stats, audit — protégées par `admin_required`
- **CSRF fix**: token CSRF dans toutes les requêtes admin (`adminFetch`)
## v2.4.0 (2026-07-13) — Tags Personnalisés par Utilisateur
### Added
- **Tags isolés par `user_id`**: chaque utilisateur voit uniquement ses propres tags
- **`UNIQUE(name, user_id)`**: remplace `UNIQUE(name)` dans la table `tags`
- **Migration**: tags existants assignés au premier utilisateur
- **Filtrage transparent**: l'API retourne les tags filtrés par `user_id` (session cookie)
### Fixed
- Affichage des tags dans le menu contextuel workspace (sync Alpine.js)
- Création de tag avec couleur persistée
- Fermeture auto du menu contextuel après ajout de tag
- Filtrage par tags: `toggleTagFilter()` appelle `doFilter()`
- Pastilles tags: fond = couleur du tag, sans rond séparé
## v2.3.0 (2026-07-12) — Multi-Vues Workspace & Preview Panel
### Added
- **Sélecteur de vue**: Tree, List, Details, Title, Content — 5 modes d'affichage
- **Vue List**: liste compacte avec Name, Type, Size, Modified
- **Vue Details**: table complète avec Path, Author, Tags
- **Vue Title**: grille de cartes avec icônes et métadonnées
- **Vue Content**: liste avec aperçu inline (taille ou nombre d'items)
- **Preview Panel**: panneau latéral droit avec contenu du fichier sélectionné
- Header avec icône + titre + bouton fermer
- Métadonnées (type, taille, date)
- Tags affichés en chips
- Corps avec rendu du contenu (blocks JSON → HTML, texte brut)
- Actions: Open, Rename, Delete
- Animation slide-in/out
- **Tri par date**: options Oldest/Newest dans le selecteur de tri
## v2.2.0 (2026-07-12) — Tags & Recherche Avancée
### Added
- **Tags**: système complet de tags avec tables `tags` + `page_tags`, CRUD API
- **API Tags**: `GET/POST/DELETE /api/local-workspace/items/{id}/tags`, `GET /api/local-workspace/tags`
- **Vue Table enrichie**: colonnes Name, Path, Size, Modified, Type, Author, Tags
- **Filtrage par tags**: barre de chips avec compteurs, clic pour filtrer
- **Ajout/retrait tags inline**: bouton + dans la table, chips avec × pour retirer
- **Tree endpoint enrichi**: chaque nœud retourne `size`, `size_display`, `created_at`, `updated_at`, `author`, `tags`
- **Filtre "Dossiers"**: nouveau chip Folders dans la barre de filtres
- **Auto-switch table**: quand recherche/filtre actif, passe en vue table automatiquement
## v2.1.1 (2026-07-12) — Fix Arborescence Workspace
### Fixed
- **Arborescence**: création fichier/dossier dans un sous-dossier met à jour l'arbre sans rechargement
- **parentFolder**: corrigé le bug où `parentFolder` était nullifié avant l'insertion dans l'arbre JS
- **displayTree**: nouvelle référence array après chaque mutation pour forcer le re-render Alpine.js
- **doSort/doFilter**: gèrent maintenant le cas où un filtre/tri est actif après création
## v2.1.0 (2026-07-10) — Intégrations Avancées
### Added
- **API publique**: `/api/v1` (collections, pages, my-tasks) avec token auth
- **Token generation**: `POST /api/v1/token`
- **Webhooks sortants**: `GET/POST/DELETE /workspace/webhooks` + dispatcher
- **PWA**: `GET /manifest.json`
- **CSRF**: `/api/v1` exempté
### Tests
- 73/73 passent (+6 tests v2.1)
## v2.0.0 (2026-07-10) — Multi-User Workspaces & Editor Complete
### Added
- **Workspaces**: tables `workspaces` + `workspace_members`, CRUD API, roles admin/editor/commenter/viewer
- **Comments**: table `comments` avec thread (parent_id), resolved
- **Page History**: table `page_history`, snapshot JSON
- **Favorites**: table `favorites`, add/remove/list
- **Templates**: `database_templates` + `page_templates`, apply API
- **CSV Import/Export**: `POST .../import/csv`, `GET .../export/csv`
- **Public Sharing**: `GET /workspace/public/{id}` (read-only, no auth)
### Tests
- 67/67 passent (+7 tests v2.0)
## v1.9.0 (2026-07-10) — My Tasks Dashboard
### Added
- **My Tasks**: `GET /my-tasks` (HTML), `GET /my-tasks/api` (JSON)
- Agrégation cross-collection, vues All/Today/Overdue/Next 7 days
## v1.8.0 (2026-07-10) — Sub-items & Dependencies
### Added
- **Sub-items**: parent_id auto-référence, GET/POST sub-items
- **Status aggregate**: `GET .../status-aggregate`
- **Dependencies**: `POST .../dependencies`, `POST .../check-deps`
## v1.7.0 (2026-07-10) — Vues Améliorées
### Added
- **View config**: `PUT /db/views/{id}/config` (group_by, card_size, cover, visible)
- **Save view as**: `POST /db/{id}/views/save-as`
- **List views**: `GET /db/{id}/views/api`
## v1.6.0 (2026-07-10) — Vues Manquantes
### Added
- **Calendar**: grid mensuel, navigation mois
- **Gallery**: cartes visuelles, card_size configurable
- **List**: vue compacte avec preview
- **Timeline**: barres horizontales Gantt
- **Table**: rendu SSR avec colonnes properties
- **View tabs**: navigation Table/Board/Calendar/Gallery/List/Timeline
## v1.5.0 (2026-07-10) — Relations & Rollups
### Added
- **FormulaEngine**: 19 fonctions (prop, if, concat, round, now, today, dateAdd, replace, ...)
- **RollupEngine**: 12 agrégations (count, sum, avg, min, max, range, unique, percent_checked)
- **Relation**: lien bidirectionnel, API create/link
## v1.4.0 (2026-07-10) — Propriétés Avancées
### Added
- **Table `collection_properties`**: 21 types Notion
- **Service `property_types.py`**: validation, formatage, auto-values
## v1.3.0 (2026-07-10) — Database Concept
### Added
- **Tables**: `collections`, `collection_pages`, `collection_views`
- **Router `/db`**: CRUD collections + pages (16 endpoints)
- **GiteaBoardCompat**: adaptateur boards legacy → Collections
## v1.0.0 (2026-07-08) — Production
### Added
- FastAPI lifespan, 28 tests, CI/CD Gitea Actions, CSS 31KB, 10 templates
- 5 vues: Kanban, Table, Status Overview, Team Load, Detailed Board
- Filtres cumulables AND, tri multi-critères
- Card detail modal, checklists, commentaires Gitea
- Propriétés custom, AI keywords, sync API