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
5357 lines
314 KiB
Markdown
5357 lines
314 KiB
Markdown
# 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 `"`/`<` → 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é `"`.
|
||
- 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 `"`/`<` — les boutons Share/Star/Settings/…
|
||
s'affichent en texte brut). Reproductible : `curl /workspaces` →
|
||
`"topbar-btn"`. 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é `<b>`)
|
||
· `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
|