- 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) et 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 de marge, refresh_token
conservé si absent de la réponse), api_get() = path absolu refusé + validation
SSRF + borne 20 000 car.
- Tokens chiffrés Fernet en réutilisant calendar_sync._encrypt_tokens (zéro
dépendance) dans la table connector_tokens (migration 33, PK (kind, user_id)).
- 4 routes /api/agent/connectors/oauth/{kind}/… : status, authorize (cookies
d'état HttpOnly 10 min, retour same-origin validé), callback (GET safe, 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 : google/ms365 en natifs avec badge connecté/non connecté ;
connector_fetch et Tester passent par l'API du fournisseur avec le token de
l'utilisateur (user_id transmis par l'outil LLM).
- Menu + : « Se connecter » / « Déconnecter » sur la fiche, toast au retour du
flux (URL nettoyée par history.replaceState). État dans la fiche du menu
plutôt qu'une page dédiée.
- Tests : tests/test_v756_oauth_connectors.py (13), 0 appel réseau réel
(_post_form / _api_get monkeypatchés) — state forgé refusé sans échange,
tokens chiffrés en base, refresh, URL absolue refusée, 401/404, câblage menu ;
test_v755 adapté (5 natifs). Suite complète 1319 verts (-n auto), ruff 0,
eslint 0 erreur (19 warnings préexistants hors fichiers touchés).
278 KiB
Changelog - FlowDeck
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_tokenconservé si le fournisseur n'en renvoie pas) ;api_get()→ lecture bornée,pathabsolu refusé + validation SSRF ;- tokens chiffrés Fernet via
_encrypt_tokensdecalendar_sync(réutilisation, aucune nouvelle dépendance) dans la tableconnector_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,statecomparé 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_fetchetTesterpassent par l'API Graph/Google avec le token de l'utilisateur (outil LLM =user_iddé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_getmonkeypatchés).test_v755adapté : 5 natifs (gitea, github, web, google, ms365).- Suite complète : 1319 passed / 0 failed (
-n auto),ruff0,eslint0 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 deconfig_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 (seulhas_secretl'est) ; probe(): appel de contrôle (_get, timeout 10 s, re-vérification de l'hôte après redirection) qui persistestatus/detail;connector_fetch(): lecture pour l'LLM — API Gitea/GitHub (native), recherche web (native) ou GET du connecteur personnalisé, borné à 20 000 car.
- 3 connecteurs natifs (
- 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) — argsconnector(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, outilconnector_fetch(schéma, succès, désactivé, inconnu), câblage du menu.test_v751/test_v753/test_v754adaptés : 1 seule section « bientôt » (plugins).- Suite complète : 1306 passed / 0 failed (
-n auto),ruff0,eslint0 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, tableagent_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_enabledajouté au whitelist), état persisté et reflété à l'ouverture/à la création de la conversation. agent_conversations.memory_enabled(migration 31, défaut1) + configAGENT_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_v753adaptés : 2 sections « bientôt » restantes (connecteurs, plugins).- Suite complète : 1293 passed / 0 failed (
-n auto),ruff0,eslint0 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/useavec{key}dans le body pour un intégré,POST …/{id}/usepour un personnel) ; - Insérer dans le document ouvert —
GET …/blocks(nouvelle route) →ensureBlockIds()(global côté éditeur) → concaténation dansE.blocks+autoSave/render, désactivé si aucun document éditable n'est ouvert ; - Enregistrer le document ouvert comme canevas
(
POST /board/api/page-templatesavecpage_id).
- Créer une page à partir du canevas (
GET /board/api/page-templates/{template_id}/blocks— blocs d'un canevas ( intégré viatemplate_id=0&key=, sinon id) ; 404 clé/id inconnu, 500 si le JSON est corrompu. OpenAPI : 516 chemins,info.version7.53.0.- Canevas « Design System » intégré (
design_systemdansapp/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(routerprefix="/board"), commebase.htmlle fait déjà.
Tests
tests/test_v753_canvases.py— 5 tests : canevasdesign_systemlisté, route blocks intégré (sansid, 404 clé inconnue,emptypar 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 «keydans le body ».test_v751adapté (3 sections désactivées).- Suite complète : 1285 passed / 0 failed (
-n auto),ruff0,eslint0 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) viaFD_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.version7.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é
(
_pinDbSkillne 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 puisoverwrite→ 1 seule ligne), aller-retour export → import, câblage des sections + alias, markup rendu dans la page.tests/test_v751_plus_menu.pyadapté : 4 sections « bientôt » au lieu de 5.- Suite complète : 1280 passed / 0 failed (
-n auto),ruff check app testspropre,eslint static/js/agent_panel_2.js0 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 parContextBuilder._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, jetonfolder:côté front et côté backend.- Suite complète : 1273 passed / 0 failed (
-n auto),ruff check app testspropre,eslint static/js0 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-bodygardait unpadding: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 pasdatabase_table.js:window.FlowDeckDBétait undefined dans l'iframe et le blocembed/collectionne s'hydrat jamais. Inclus désormais (même traitement quepage_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-peekn'existait que dans le markup de_database_table.html; sans lui,openPageInSidePeekétait un no-op. Le panneau est créé à la demande pardatabase_table.js(ensureDbPeek, commemt-peekde My Tasks) : markup unique, le partial ne contient plus le panneau statique. - Barre de scroll sous le tableau des bases —
.database-table-containerplafonnait à 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) etmy_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ésfdWirePeekResize) 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-opendu calendrier (et du Kanban) ne contiennent que leur<svg>: cliquer l'icône donnaite.target=<svg>sans dataset. Le handler résout désormaist.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.openPeekByIden Library,_wsData.openSidePeeken workspace local), repli nouvel onglet ailleurs. - Peek d'une page de base (
content_format='collection') —?embed=1forç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-onlyré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=1de l'iframe ;dataset.pageporte 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ésolutionclosest('[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 collectionrendu 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-firstsur 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.htmlrend 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-containeravaitpadding: 0 24pxtandis 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: 0conservé — 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 pardata-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 aucunx-data: bindings inertes,openPageInSidePeekpassait parcontainer.__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 uniquewindow.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./trashutilisait la classe.lib-containerdont la règle vivait dans le<style>de library.html : rendue hors Library elle était pleine largeur — la règle est remontée dansapp.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 lesrcrestait vide et que le panneau affichait un document blanc. L'iframe est désormais un élément permanent, hors du template, dont lesrcest 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:blankavant de charger le nouveau document, etclosePeek()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,
resolveRowContentPageretombait surreturn 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 viatoast()) : sous le build CSP d'Alpine, aucun composant n'est initialisé etwindow.showToastreste 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ésormaisabout:blank.
Known issues (préexistants, hors périmètre)
- Sous le build CSP d'Alpine (
alpine.csp.min.js, v3.17.4), les expressionsx-datainline 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, carwindow.showToast(base.html) litel.__x, propriété du build de développement.refreshSidebarFromEventet 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✕ouEscape, 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.Esurvit 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
h1700 + compteur à droite, onglets et outils de part et d'autre, lignes endiv(et non<table>), hauteur 38 px, survolvar(--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-onlyde Library (même SVG, même libellé « Open », même révélationopacity), 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 :
fdLoadignorait 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,loadingrestait àtruedé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 queloadingne peut plus se bloquer.- Le compteur de scripts de la zone swappée écoutait
loaden capture surdocument, 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 parbase.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 :
collectionsportetask_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/taskcré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_workspacene cadre plus My Tasks (il reste lu par la sidebar). Le payload/my-tasks/apiexposetasks,sources,sources_total,max_sourcesetfilters; plus deworkspace_id(le contract précédent est modifié, pas additionsnel). PUT /db/{id}/toggle-task/apireste rétrocompatible mais délègue à la conversion complète : il ne pose plusis_task=1seul, 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 chargestatic/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.pyla 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 deNew. 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-appvide et l'écran devenait blanc. Le clic est désormais marquédata-no-navsur les onglets, etshouldInterceptignore tout événement déjà traité (defaultPrevented) — plus aucun composant ne peut plus être intercepté par la navigation SPA. -
/dbn'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 dansapp.css: le repli#fffrendait 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 ;/dbrecentre son en-tête et sa section « Autres workspaces ». -
My Tasks « non connectée » n'avait aucune feuille de style —
_render_shellconstruisait 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 perdaitmy_tasks.csset 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à dansdatabase_table.js).rowIcon()la remplace désormais par📄dans le tableau et le kanban. -
Cellule « Tâche » collée au bord gauche —
.mt-td-titleest un flex et ne recevait pas lepaddingdes 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é dansdatabase_table.js). -
My Tasks vide après navigation — le script se gardait d'un drapeau global (
window.__fdMyTasksLoaded) : la navigation interne deapp.jsremplace.main-wrapperpar 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 surhtmx:afterSwapetpopstate(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_timezonesupposait 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_idNULL) — une base née d'une page (page convertie en base, base inline, base liée, modèle importé) n'a pas decollections.workspace_id: elle n'appartient qu'à travers sa page hôte. Le filtrec.workspace_id = ?et leJOIN workspacesdelist_task_sourcesne la trouvaient jamais, donc/dbl'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é parlive_collection_ids,list_task_sourceset la page/db. -
Fuite de bases entre utilisateurs sur
/db— la section « Autres workspaces » faisaitJOIN workspacessans filtre de propriétaire : elle exposait les bases de tous les utilisateurs de l'instance. Le périmètre est maintenantuser_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_donene 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
collectionsdu 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_tasksfaisaitSELECT * 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_idet ne comprenait pas le cookiegitea:owner:repoécrit par la sidebar : il retombait alors surowned[0]et affichait les tâches d'un autre workspace que celui affiché. Résolution mutualisée avecdashboard._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/dbet par My Tasks avec unworkspace_idou unparent_page_idfantô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 unIntegrityErrordès que la base avait une page hôte :pages.collection_idetcollection_data_sources.source_collection_idréférencentcollectionsenNO 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 enCASCADE. 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-notede 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 desdaysjours suivants, triés, avectoday/synced/provider/url. Alias publicsdate_prop_idetrow_datedansapp/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 nouveauwindow.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 fichierVERSION(?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 (styleinline 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 dedays, 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 endpointslocal-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 legacyapp/routers/workspace.py(relique v2.0.0) n'avait aucun garde :GET /workspace/collections/{id}/export/csvrenvoyait 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épendanceDepends(_require_session); la seule route publique (/workspace/public/{collection_id}, page de partage) est déplacée dans unpublic_routersans dépendance et reste accessible. password_hashexposé —GET /auth/userrenvoyait la ligneuserscomplète, sel et hachage PBKDF2 compris, et le cookie de session (signé mais non chiffré) embarquait la même donnée en base64. Nouveauapp.auth.session.public_user()appliqué danscreate_session,refresh_sessionet/auth/user.- OAuth Gitea cassé —
gitea_oauth_client_id/_secretvalent les placeholderstest-id/test-secretdansapp/config.py, valeurs non vides donc considérées comme « configurées » :GET /auth/loginredirigeait vers Gitea avec unclient_idinvalide (échec d'authentification garanti).app/auth/providerstraite désormais les placeholders comme « provider non configuré » → page « non configuré » avec lien vers le login local. - Synced blocks —
GET /board/api/synced-blockslevaitAttributeError: '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 depuisNewWSbasculait versallo(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ésormaislocal_ws_id= ID de l'espace actif (le miroir Gitea a été séparé dansgitea_mirror_ws_idpour ne plus écraser cette valeur), etbase.htmlprivilégielocal_ws_idavantlocal_workspaces[0]. GET /api/v2/agents/conversations→ 422 — la route paramétrée/agents/{agent_id}était enregistrée avant et capturait le segmentconversations. 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-blocksalors que les routes sont sous/board/:static/js/page_editor_scripts.jsutilise 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_idet repointépage_idverspages(id), mais le handler lisait encore l'ancien schéma (jointurecollection_pages). Requête et insertion alignées,page_iddé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.StrictUndefinedest inutilisable ici (40+ templates optionnelles, p. ex.embed_mode), doncENV.undefinedvaut désormaisLoggingUndefined: rendu identique, mais unWARNINGjournalise le nom de la variable manquante. .env.example:GITEA_OAUTH_CLIENT_ID/GITEA_OAUTH_CLIENT_SECRETdocumentés (laisses vides = provider Gitea considéré comme non configuré).
Tests
tests/test_anomalies_v746.py— 34 tests : absence depassword_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.pypasse 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— GitHubrepositories | code | issues, fonctionnel sans jeton (10 requêtes/min) et à 30 avecGITHUB_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_clientpour le pool de connexions, aucune dépendance ajoutée, URLs du moteur filtrées des résultats,transportinjectable 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.
- locaux :
- 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_urlest protégé par SSRF : schémashttp(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 vers169.254.169.254. Il réutilise les garde-fous déjà éprouvés parimporters/url_fetchetog_fetcherplutôt qu'un contrôle maison.search_codene touche que l'API publique GitHub (lecture seule) ; le message d'erreur 403 renvoie explicitement versGITHUB_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 : classesflowdeck-modal-overlay/.flowdeck-modaldéjà présentes dansbase.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 danstrash.html— le composant est monté danstrashData()(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 handlerdialogdu test échoue maintenant si unconfirm()/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 faisaitcsrf?.[1]→ 2ᵉ caractère → 403 CSRF systématique, avalé parif (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 filtrentparent_section != 'Trash'.deleted_atest 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,deleteetPOST /api/pages/{id}/trashne 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é surdeleted_at IS NOT NULL.
Tests
tests/test_trash_api.py(5 tests) : auth 401, soft-delete sans marqueurTrash, 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/opacityde 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. » (aucunx-cloaksur elle). Fix :x-cloaksur le nœud racine Alpine.app-layout(base.html) — tout l'app (sidebar + zone + bandeau) reste masqué tant queappState()n'est pas monté, puis Alpine retire l'attribut. Vérifié par le probe (3 pages × plein chargement) : 0 frame de contenu brut,x-cloakretiré etdisplay:flexà la fin (donc pas de page blanche).
Changed
fd-navigatingsupprimée : classe posée/retirée à chaquefdLoadmais 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-ignoreabsent),.app-layoutsansx-cloaket 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, sourceslib-loading/lib-empty/lib-table), 80 éléments sur 732 ms sur /settings, 7 sur /workspaces, 0 sur /trash. Fix :opacity: 0+pointer-events: noneposés dans le handlerhtmx:afterSwap(même task que le swap → aucun paint intermédiaire possible) et retirés aprèsAlpine.initTreesur 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êtrex-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-wrappers'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-ignoresur toute la zone swapée auhtmx:afterSwap(uniquement quand le nœud.main-wrappera été remplacé — swaps sidebar/vues inchangés), démontage unique quand tous les<script src>ont exécuté (load/error + filet 4 s) viaAlpine.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 (constde haut niveau re-déclaré) → panneaux Alpine non montés. Garde de fichier posé surlocal_workspace.js,board.js,settings.js,database_table.js,page_editor_realtime.js(pattern déjà utilisé parpage_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.cssré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) ete2e/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 lisaitopaqueredirect(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.networkFirstrejoue désormais la requête enredirect:'follow'et sert une 302 synthétique vers l'URL finale (Chromium refuse une responseredirectedservie à une navigation →net::ERR_FAILED). Bonus :timeoutFetchannule réellement la requête (l'AbortControllercréée dans lesetTimeoutn'était pas branchée surfetch). - Drag & drop de fichiers/dossiers en échec silencieux (403 CSRF) :
_doUpload()(POST/api/local-workspace/upload[-folder]) ettoggleFavorite()du local workspace n'envoyaient pasX-CSRF-Token— les 2 derniers appels mutants du front oubliés par A19 (exemption CSRF retirée, balayage complet desfetchmutants refait). - Gate E2E :
e2e/regression_logout_dnd.spec.jsenregistre 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/↑↓ viaPOST/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 · webhookvisible (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 viagetCsrf(), gardes staleness onglet+requête) → rendu deanswer_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/searchne renvoie quepages/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, 0eval/new Function, parseur d'expressions maison) servi partout :base.html,import.html,welcome.html+ entréesw.js(cache bump v8).- CSP :
script-src 'self' 'nonce-…'—unsafe-evalsupprimé (il ne servait plus qu'Alpine standard). htmx :allowEval: falsedé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_previewsert 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_actionsdu 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_iconest une macro →Markup, etMarkup.__radd__/__add__échappe ses argumentsstr→ tous les segments littéraux sortent entité-és ; le|safede_header:141est 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:/workspacesdoit contenirclass="topbar-btn"parsé et ZÉRIE entité". - Gate éditeur : l'assertion
.star-btnré-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|safedevenaient 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"→ appelgiteaWorkspace(),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 surm.content). - page_editor (les 12 sites
window.Edu topbarright_actions) → déléguésappState: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 templatespage_editor.html+page_editor_collection.html+ garde Jinja : les quotes insérées doivent être\'(lesetest 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 suriconHtml()). - Gate éditeur (
csp_preview) : création collection →/pages/{id}, déléguésappStateliés +editorStatelié + 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_actionsdu 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:141EST 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 quereport = null(lex-showparent ne masque pas, il initialise quand même) → pageerror « Cannot read property … 'relations' » — gardereport && 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.Edansright_actions), gitea_workspace (new Date), agent_panel (sitex-textmarkdown + 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_previewde plus :settings(composant lié, overlay visible, 0 erreur) etlocal workspace(recherche focalisée viaAlpine.nextTick, chips filtre en SVG viabindSvg, 0 erreur). 3 surfaces vertes sous build CSP : library, settings, local workspace.
Changed
- settings :
window.history.back()/new Date(…)→ méthodeshistoryBack/fmtLastLogin/fmtAuditDate;?.→ ternaires. - local workspace :
x-data="_wsInitData"→ registrewsInitData()(le build CSP ne résout que le registre) ; 14x-html→x-init+Alpine.effect(bindSvg/bindFileIcon/bindNodeIcon/bindChildren/bindPreview) ;$nextTick+$refsarrow →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ésglobalThisau boot → l'objet mis surwindowavant 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 copieObject.assignaurait 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
wsPreviewdéléguant vers_wsInitData(Alpine.reactivepour la réactivité ; wrapper = objet unique, les magics$nextTickne sont redéfinissables qu'une fois — deuxième montage du même objet = « Cannot redefine property »). .envlocal :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 dealpine.min.jspar interception Playwright ; tout échec du parseur maison =pageerror(filet). Première surface verte : library (composant lié, icônes SVG rendues viaAlpine.effect, recherche ouverte + focalisée, 0 erreur console/page).
Changed
- Composants
x-data="fn()"→ registreAlpine.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:inittoujours joint à temps. - base.html (shell) migré :
x-effect document.*→syncSidebarClass(),$nextTick(arrow)→initSidebarSort(),window.FlowDeck.*→fdCreatePage/fdCreateFolder/fdGwRefresh,Object.keys/Math.min/window.innerWidthdansx-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 (toggleSearchavecAlpine.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: falsedans 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-evalreste 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-htmlréactifs (icônes SVGgetSvgIcon/_fileIcon, markdown agent, preview) — interdits par le build CSP (innerHTML) : nécessitent de reposer les icônes sur des composantsAlpine.data. - Portée : 0 variable globale/
documentaccessible dans les expressions du build CSP (scope = données du composant + magics) → chaque site devient une méthode de composant enregistrée viaAlpine.data. Plan phase 3 : migrer composant par composant (library → settings → local_workspace → gitea → base) avec gate E2E dédiée, puis retirerunsafe-eval. En attendant :unsafe-evalconservé (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-headerrendue, 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 bruitFailed to load resource401/403 est filtré) · Service Workers bloqués dans le smoke :/sw.jssert sa page « hors ligne » sur les navigations redirigées (redirect:'manual') — bruit PWA hors sujet,pwa_offline.spec.jscouvre 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 litview_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.cssimportait encore Google Fonts (@importraté par le grep de v7.27) → violationstyle-srcsur chaque page + police tombée en fallback. Inter auto-hébergé : 2 faces variables (100-900, latin + latin-ext) dansstatic/fonts/,@importsupprimé
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-slimbuilder + runtime), CI Gitea (python-version: '3.13'×2),README.md×2, rufftarget-version = "py313"(0 nouvelle remarque ruff). Restait 3.12 aux 6 endroits alors queuv.lock=requires-python >=3.13et 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 buildvert surpython:3.13-slim(imageflowdeck:a35-py313)- dans le conteneur :
python -V= 3.13.16,import app.mainOK (version 7.35.0) → wheelsrequirements.txtconstruits 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 dansboard_fragment+detailed_board, fragments de vues mutuellement exclusifs) → dédupliquée versstatic/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 — 0function NAMEglobale définie 2+ fois entre templates et static/js (scanner naïf, plafondponytail: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.jsreporté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 : ContextVarCSRF_TOKENposée parCSRFMiddlewareavantcall_next(même mécanique que le nonce CSP), global{{ csrf_token() }}ajouté à templating ;base.htmlrend{"X-CSRF-Token":{{ csrf_token()|tojson }}}— vide si le cookie est absent sur cette 1ʳᵉ requête, maishtmx:configRequestre-lit le cookie à chaque appel → jamais de jeton factice. Testtest_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) — leJSON.parse(document.body.getAttribute('hx-headers'))par frappe disparaît
Changed
- A38 phase 1 — helper CSRF unique :
window.getCsrf()défini dans le<head>debase.html(le plus tôt possible) ; 76 lectures brutes du cookie →getCsrf()dans 13 fichiers (47 formes(…||[])[1]||'', 25 déclarationsconst X = match(…)+ leurs usagesX?X[1]:''→X, 4 formes espacées) ; définitions dupliquées supprimées (card_detail,database_table) ; les 3 variantes debase.html(IIFE + 2getCsrfToken) unifiées surreturn getCsrf();welcome.htmlgarde 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 packageapp/routers/board/: · 12 modules :pages271 L (7),page_api229 (5),board_views223 (8),page_ops176 (3),sharing175 (10),synced144 (8),page_media122 (4),import_85 (2),wiki76 (2),library66 (1),embed64 (2),sync51 (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.jsonré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 pardefseul
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 packageapp/routers/collections/: · 10 modules de routes :crud337 L (6),properties322 (8),linked286 (7),structure267 (8),dashboard_views214 (3),meta197 (5),views187 (6),pages184 (4),data_api122 (2),boards61 (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.jsonré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 sansdef→ skip du 2ᵉ décorateur (53 endpoints = 52 unités) CHART_MAX_GROUPShors détection des helpers (F821 dansdashboard_views) → import._renderersajoutétest_csp_no_cdn_and_vendorlisaitcollections.py→ balayage du package
Notes
- Reste A28 :
board.py2 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 packageapp/routers/dashboard/: ·local_workspace559 L (15 routes),pages_html485 (6),account_settings439 (16),workspace321 (9),pages_api240 (6),workspaces131 (6),public78 (1),account_api77 (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.jsonré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 : assertdef in segajoutée + récupération depuis git - collision
settings: la sectionsettings.pyentrait en conflit avecfrom app.config import settingsdu header (hasattrdu fromlist de Python → la section était ignorée) → renomméeaccount_settings WORKSPACE_COOKIEutilisé dansworkspaces.pysans import (F821) → import._commonajouté- script
__all__tronquant la fin du fichier →__init__.pyréécrit
Notes
- Reste A28 :
collections.py2 622 L,board.py2 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 packageapp/routers/api_v2/— un module par concern : ·collections566 L (23 routes),engagement338 (21),workspaces230 (9),templates_io205 (9),webhooks195 (8),identity195 (7),views164 (8),sharing160 (8),properties151 (7),planning148 (7),projects93 (4),admin91 (4) ·_common.py: helpers partagés (_hash,_v2_rate_check) ·__init__.py:router = APIRouter(prefix="/api/v2")+ agrégatinclude_router(section routers sans prefix, tagsapi-v2) - Preuve contractuelle :
docs/openapi-v2.jsonré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é parruff --fix(143 imports morts supprimés automatiquement)
Notes
- Reste A28 :
dashboard.py2 735 L,collections.py2 622 L,board.py2 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éationsasync 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: unAsyncClientn'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'acloseexplicite, 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 patchaientwebhook_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 dansscript-srcnistyle-src connect-srcfermé :'self' ws://{host} wss://{host}(Host de la requête, caractères hors base URL filtrés) — lehttps:universel (canal d'exfil JS) et lesws:/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é danssecurity.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), sourcecollections.pysans référence CDNtest_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) étaitasync defsans aucun await (cookie decode = synchrone) ; idem ses clones (agent._current_user_id/_workspace_id/_current_admin,sso._require_adminau corps 0 await) →def+ 47awaitsupprimés (dont 3 via l'aliasgcu— 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 étaientrequest.json()/ événements →Body(default={})+run_event_sync(...)puisdef:- 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
- try/except
- Import
Body/run_event_syncajouté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.jsappelaittypeof toast === 'function'— guard toujours faux (letoastglobal n'existait pas) → les retours « Timezone saved » / « SP metadata URL copiée » ne s'affichaient jamais. →window.showToast(globalbase.html) _wsInitData = window._wsInitData(auto-affectation d'un nom non déclaré, sans effet observable carwindow._wsInitDataexiste 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 danseslint.config.mjs(getSvgIcon = inline base.html, TextDecoder = API, Prism = CDN)
Notes
node --checkvert sur tous les fichiers ; suite complète 1089/1089- A27 reste ouvert uniquement pour :
base1 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 versstatic/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-confignull-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) ;FlowDeckDBplus 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 --checkvert partout, eslint 0 erreur / 285 warnings - Reste :
base1 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 expressionsor ""sont préservées pour les valeurs Undefined,is_adminreste 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 }}; loadersXX = 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 --checkvert sur les 4 fichiers ; eslint : 0 erreur, 279 warnings (12 fichiers, baseline mise à jour)- Reste structurel :
base1 338 ({% block %}/{% for %}— reste inline par nature) +database_table1 323 (if/elsesur 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 versstatic/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-dataexistait déjà juste avant le script, même ordre d'exécution, garde__fdEditorScriptsLoadedpréservée - Côté route (
view_page_root) :page_dataenrichi deupdated_at,created_at,user_id,is_shared(le dérivé est hoisté — une seule expression sert le ctx ET le JSON) etclip_icon(macrofd_iconrendue côté serveur).workspace_keyreste vide comme avant (jamais défini dans ce contexte → parité stricte) node --checkvert ; 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_v511front_end_wired,test_v55×3 (lightbox, previews, endpoints) → lisentstatic/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.mjsexistait 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 à nettoyernode --checkvert sur les 9 fichiers extraits ;eslint.config.mjscouvre déjàstatic/js/**/*.jsdonc 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.htmlappelait la macrofd_iconsans l'importer →UndefinedError→ 500 systématique surGET /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.giteaavec état mutable partagé (le handler PATCH re-fetch l'issue viaget_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 /issuesJSON + HTML (?format=html— le segment/htmlne fixe pas le paramètreformat, 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+ vuetable+ page encontent_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/projectsGET+POST : shape{builtin, gitea, github}avecgithub == [], projet créé retrouvé dans builtin, quirkerrorsans nom, nettoyage · Cycle items local-workspace (5 routes) : création → renommage relu en base → move → soft-delete (deleted_atrelu) → restore (deleted_at IS NULLrelu), 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/tagsa 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), etfile-content→ 404/415 ·GET /api/local-workspace/page-content/{id}: contenu + format relus, 404 sur id inconnu ·GET /api/avatar/{id}: 302 + Location avecfollow_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_hashabsent du rendu (whitelist A2 vérifiée côté page) ·PUT /api/user/profile: persistance relue en base, valeur d'origine restaurée enfinally·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: formatfd_+ 64 hex, ligneuser_tokensnettoyée enfinally·DELETE /api/user/forge/{provider}:{"status": "ok"}·PUT /api/settings/account: full_name/email persistés + 400 sur mdp court (validateur), restauration enfinally·POST /api/workspaces/{id}/select:Set-Cookie flowdeck_workspacevé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 danstest_smoke_uncovered.py(32 tests au fichier) : · Tags CRUD : création (SmokeTag→smoketaglowercasé), présence dans la liste, changement de couleur relu, suppression puis absence · Vie d'une page : GETcontent(contenu seedé relu) → PUTrename(ok + 400 sur titre vide + titre relu en base) → POSTtrash(parent_section='Trash'+deleted_atrelus en base) ·sidebar/workspace-tree: 200 HTML, fragment « No pages yet » sans cookie ·settings/avatar-color: couleur relue sur l'utilisateur de la session (pasLIMIT 1), valeurs d'origine restaurées enfinally·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.pycontre tous les tests (chaîne de chemin littérale) → 5 routes à 0 référence, toutes couvertes : ·POST /properties/evaluate-formula: 200 + shape, 400 sansexpression(le moteur renvoie1 + 2tel quel aujourd'hui — le smoke valide le câble route/auth/parse, pas le moteur) ·POST /properties/compute-rollup: 400collection_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 scopeadmin→ 200 +logsliste ·GET /webhooks/events: catalogue non vide + wildcards*/page.*·POST /webhooks/verify-signature: valid=True avecsign_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.pypasse de 3 à 16 routes couvertes (22@routerau total) : ·board-configGET/POST : défauts à 5 colonnes sans board, création puis relecture du roundtrip ·col-mappingPOST/DELETE : 404 sans board, upsertlabelvérifié, suppression vérifiée ·cardPOST : 404 sans board,{"status": "ok"}avec ·collaboratorsGET :gitea.get_collaboratorsstubbé (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=truepuis 0) · checklist mutations : PATCH item (checked/content relus EN BASE), DELETE item, DELETE checklist (réapparitionCOUNT=0) — seed + cleanup- Reste
api.py: 6 routes Gitea (issues ×4 + créations checklists) → stub de transport httpx. Reste global :dashboard.py17/63,api_v2.py50/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.pypasse de 1/10 à 8 routes couvertes : les 5 listes (recents/favorites/published/private/workspace) en un test de boucle,/privateavec une page seedée et retrouvée,/children/{id}avec un parent/enfant seedés (titre retrouvé),/repositoryvide 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-workspaceun 500 dès qu'un workspace existait — les deux lisaientlocal_workspace_items, une table qui n'est créée nulle part dans le codebase (grep : 0CREATE TABLE), avec 0 référence front.library._format_sizedevenu mort : supprimé aussi (une version vit dansdashboard.py, inchangée) local_workspace_items: plus aucune occurrence dansapp/
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 avecgitea.list_webhooksstubbé (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 depuisusers.sidebar_config, 400 sansconfig, remise en état en fin de test ·github_routes.py(2/2) : status{"linked": False}, disconnect ok- Reste (phase 2) : quasi nuls —
library.py1/10,api.py3/23,dashboard.py17/63,api_v2.py50/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.runsur 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'awaitd'avant). Noteponytail:: clients httpx créés à chaque appel partout → aucun lien de boucle ; sinonrun_coroutine_threadsafe+ boucle du lifespan - A21 (phase 2b) — 15 routes
api_v2dont les seuls awaits étaientrequest.json/_fire_event/fire_published/fire_unpublished→ paramètreBody(default={})+run_event_sync(...)+ conversion endef 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 seulawaitétaitbody = await request.json()passent à un paramètre FastAPIbody: dict = Body(default={})(parsing async fait par FastAPI avant l'appel) puis sont converties endef→ threadpool. Équivalences vérifiées avant-engagement : corps absent →{}(identique autry/exceptd'avant), JSON invalide → 422 (avant : traité silencieusement comme{}), zérobody[...] =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 (
slicefermant 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 defsans aucunawaitconverties endef: 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 — aucunawait/async with/async for/asynciodans 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 dansawait 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 uneContextVaravantcall_next(visible des templates via{{ csp_nonce() }}) ; 38 tags<script>inline des templates, la constante de moduleLOCAL_LOGIN_HTML(helper_with_nonce()au rendu) et 3 scripts Python danscollections.pyle 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 viascript-src-attr 'unsafe-inline'(détaché descript-src: le nonce les aurait désactivés aussi) https://cdn.jsdelivr.net/https://unpkg.comajoutés àscript-srcetstyle-src: les vues chart/map decollectionsles utilisent et étaient bloquées par la CSP depuis toujours (commentaireponytail:→ upgrade : vendoriser ces libs puis retirer les hôtes)- Reste d'A20 :
unsafe-eval(Alpinex-dataen string → build@alpinejs/csp), externalisation du JS inline (A27), resserrerimg-src/connect-src
Tests
test_csp_nonce_per_request: pagebase.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()faitBEGIN→fn(conn)→ marqueschema_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,ValueErrorsinon) : 25 copies de{r[1] for r in conn.execute("PRAGMA table_info(...)")}éliminées dansmigrations.py.table_exists/column_existspréconisés par l'audit non livrés : aucune migration n'interrogesqlite_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.pypartagé : 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=1pour 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 29Environment(...)é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/accountsrenvoyaientSELECT *surusers: password_hash,login_attemptsetlocked_untilexposé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 deapi_v2.pypassent par la factory (Bearer + scope en un appel, contrôle manuel supprimé ; les 4 variantsadmin|is_adminrestent 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 desettings.app_base_url+ localhost/origines d'extension (allow_origin_regex), méthodes et entêtes minutés,allow_credentials=Trueexplicite ; testtest_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.0de base.html éliminés ;sw.jsn'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
standalonemort supprimé (0 lecteur) ;sqlite:////data/…ne produit plus un chemin UNC sous Windows ;.env.examplene promet plus PostgreSQL (non supporté) ; raise au boot siAPP_SECRET_KEYvaut 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 desettings.rate_limit_requests(60 annoncés, 100 codés en dur) ; clé =X-Forwarded-Foruniquement 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) ; 2logger.debugde scheduler passés enwarning - A35 — OpenAPI régénéré : 439 → 511 chemins,
info.version 7.3.9; README à jour (était v6.7.0) ; compteur deAPI_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/…deviennentlogger.exception(fn)(19 fichiers, 63 dans des handlersasync) : les échecs du pipeline d'événements/webhooks et des écritures sont enfin visibles dans les logs - A25 (critique) — plus de
tryautour dematerialize_propertiesdanscreate_collection_v2etapply_db_template_v2: un échec annule la transaction au lieu de commiter une collection sans schéma - A21 (partiel) —
PRAGMA busy_timeout=5000dansget_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_idet_current_adminne retombent plus sur la rowadmin: 401 sans session (les 24 sites de_current_user_id+PATCH/POST /api/agent/providers) — un anonymous ne pouvait plus orienter leping()du serveur vers unapi_baseinterne _check_api_base()sur les 2 routes provider : schemehttp(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 Ollamahttp://localhost:11434/v1( commentaireponytail:: 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çoiventX-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 passentnode --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 detest_sessions_listed_and_revocable - suite 1026/1026 ·
ruff check app testsOK — 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_PATHSaprè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 dewelcome.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 legacyboard.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_redirectsmanuel +_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) + actionwebhookvalidé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_uploadbranché (10 MB + extensions),FLOWDECK_DATA_DIRremplace le/datacodé 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 deserve_uploaded_file) → 403 hors/data/avatars - A18 —
GET /workspace/public/{id}: 404 explicite pour les basesrestricted/private(permission_type) ethtml.escapesur 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/cbor2installées, rebinding deapp.config.settingssupprimé danstest_v54.py(isolation rétablie) ; cycle v6.8→v7.3 committé + tagv7.3.0 - A3 —
PUT /api/user/password: 401 sans session +current_passwordexigé ; helper_require_user_id()sur profile/password/token/forge ;/api/usersorti de la liste CSRF exemptée - A4 —
POST /api/v1/tokenetPOST /api/user/token: 401 sans session, chemin legacyuser_id=0supprimé - 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/pagessorti 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 deAPP_SECRET_KEY - A10 —
app/templating.py: un seulENVavecautoescape=select_autoescape(["html"]), 29 instantiations remplacées ; re-tri des|safe(corps d'issue + commentaires échappés,sidebar_configen|tojson)
Tests
- Client de test connecté par défaut (
_TestSessionAuth: session + CSRF injectés hors cookie jar) + helperanon()sur les 40 tests d'anonymat - Suite complète : 1016 passed / 0 failed ·
ruff check app testsOK
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)) etteamspace_members(rôlesowner/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 deviewerimplicite pour un compte qui n'en est pas membre) ;pages.teamspace_id+collections.teamspace_id - Verified pages —
page_verifications(badge ✅,verified_by,note,expires_at90 j par défaut, re-vérifier remplace) ; l'indexGET /api/v2/wiki/verifiedexclut les badges expirés et les pages de teamspaces privés ; sweepPOST /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 parGET /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 viaview_stats) - Wiki Home —
GET /api/v2/wiki/home(teamspaces + verified + recents) - Blocs —
app/services/wiki_blocks.py:mermaid(SVG inline simmdcest 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 surproperty_values_json→ barre %) ; les trois sont rendues parexport.blocks_to_html(donc présentes dans l'export HTML/PDF) et exposées viaPOST /api/v2/wiki/blocks/preview - Migration 29 —
teamspaces,teamspace_members,page_verifications,comment_reactions,page_follows,guest_shares,page_views+ colonnesteamspace_idsurpages/collections - Sidebar teamspaces — section
Teamspacesdansbase.html(état Alpineteamspaces/loadTeamspaces/openTeamspace, section ouverte par défaut, icône/label/ordre, fallback du panneau de personnalisation) →GET /api/v2/wiki/teamspacesaccepte désormais l'omission deworkspace_id(listing cross-workspace avecworkspace_name), page HTMLGET /wiki/teamspaces/{id}(pages + collections du teamspace, rôle affiché) ; entréeteamspacesdanssidebar_config.DEFAULT_CONFIG - Notif
page.updatedaux followers —wiki.notify_followers_of_page_update(une par 10 min,actor_idexclu) branchée dansautomations.fire_event; payloadsactor_idajoutés dansboard.update_pageetboard.save_page_blocks; commenter une page = suivre (auto-followwiki.ensure_follow, défaut ON) - Charts avancés — type
number(KPI) avec agrégatscount|sum|avg|min|maxdanscollections._render_chart(+_chart_aggregate/_fmt_number) ; le « 0 » n'est plus forcé à 1 ; les dashboards multi-DB se rendent viaGET /db/{collection_id}/dashboards/{dashboard_id}(widgetscollection_id, ≤ 40 widgets, ≤ 200 lignes/chart) ;view_collectionchoisit maintenant la config de vue correspondant auview_typedemandé - Unfurl
gitea:/github:—POST /board/api/og/metadatarésout les refsgitea:owner/repo/github:owner/repoviaGiteaClient.get_repo_info(ajouté) ouGitHubAdapter(token optionnel, sinon API publique) sans télécharger la page ; champs bookmarkurl/title/description/image/site_nameconservés dans l'autosave du bloc - UI Settings → Audit —
settings.html: l'ongletadmin-auditinterroge désormais/api/v2/audit/logs(sourcesapi/permissions/sso, filtresactor/action, pagination « Load more », export CSV) - SSO 21 casses — dépendances
python3-saml==1.16.0+authlib==1.8.0(plusxmlsec/isodate/joserfc) installées →test_v67_sso.py38/38 - Sécurité —
sanitize_equationretire désormais</>/\(pas de breakout KaTeX/markup),revoke_guest404 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, notifpage.updatedthrottlée + acteur exclu,ensure_followidempotent, regex + unfurl gitea/github + endpoint/board/api/og/metadata, KPI +_chart_values(0 conservé) + dashboards multi-DB + 404)ruff check app testsOK · suite complètepython -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}(Bearerscim_tokens, schémas core:2.0:User,active→users.is_active+ révocationuser_sessions,OperationsPATCHactive/userName) ; tokens SHA-256 stockés, affichés une seule fois, révocables (/api/v2/scim/tokens) ; exempté CSRF (clients IdP sans cookie) et 404application/scim+jsonau 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éfipendingsigné 5 min ;POST /auth/local-loginrenvoie2fa_requiredsans créer de session, puisPOST /auth/local-verifyl'é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-rejeusign_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_ssoqui bloque le login local par domaine dansauth.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/logsfusionneapi_audit_log+permission_audit_log+sso_login_historyen un schéma commun, filtressource/actor/action, pagination, export?format=csv(admin ou Bearerread: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 parAgentEngineavant les ACL, fileagent_approvalspour les écritures, décision admin (/api/v2/agent-approvals/{id}/decide) + événementagent.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/v2et/auth/webauthnrenvoyaient une redirection 302 vers/workspaces(handler global) : ils retournent désormais du JSON, et/scim/v2répond enapplication/scim+json /scim/v2et 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 testsOK ·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 + notifcalendar.conflict) ; lignes touchées par le pull jamais repoussées ; API CRUD +POST .../sync(app/routers/meetings.py, session ou Bearerwrite, 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), transcriptionSTT_COMMANDou transcript manuel, résuméai_writing.summarize(offline-capable) → triggermeeting.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 testsOK · suite 871 verts (21 échecstest_v67_sso.pypré-existants —oneloginabsent 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) : CRUDGET/POST /workspace/automations/{id}/steps,PUT/DELETE /workspace/automations/steps/{id}(session, validation serveur),PUT .../mode(anydéfaut /allfenêtre 5 min) ; conditions AND réutilisantmatch_condition_props;form.submitteddé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 viaGiteaClient/ GitHub API, tokenuser_oauth_tokens),agent_trigger(conversation + runAgentEngine),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/Bearerwrite, 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 testsOK · suite 855 verts (21 échecstest_v67_sso.pypré-existants —oneloginabsent de l'environnement ; 1 flaky parallèletest_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 danschildren), encodeur hashed-TFhash-256(md5 % 256, L2, déterministe, zéro dépendance,embed_texts()pluggable), cosinus pur Python, fusion RRF (k=60), job incrémentalindex_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 Bearerread) : lexical FTS5/LIKE + vecteurs, filtreworkspace_id, scope membership,PermissionManager(pagesrestrictedmasquées),search_excludedrespecté, 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 testsOK · suite 825 verts (seuls 21 échecstest_v67_sso.pypré-existants —oneloginabsent 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: CRUDGET/POST/PATCH/DELETE /api/v2/sites(session ou Bearer + scopewrite, paginationX-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 publicGET /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_utilssalé, cookie signé 24h,GET|POST /s/<slug>/auth), expiry (410),noindex, OG/Twitter cards,GET /s/<slug>/sitemap.xml, domaine custom via headerHost - 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=1sans chrome) +POST /f/<token>(anonyme : rate-limit 20/h/IP, honeypot, validationvalidate_property_rule) ; chaque soumission =collection_pages+form_responses(ip_hash jour, pas d'IP), notif in-app + triggerform.submitted - Migration 24 — tables
sites,site_pages,site_views,form_responses, colonnecollections.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, embedruff check app testsOK · pas de régression (801 verts ; seuls 21 échecstest_v67_sso.pypré-existants — dépendanceoneloginabsente 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), ACSPOST /auth/saml/callback(signature, audience, destination, InResponseTo, anti-replaysso_requests), SP metadataGET /auth/saml/metadata, SLOGET|POST /auth/saml/logout(relais LogoutRequest/Response vers l'IdP) ;allowSingleLabelDomainsactivé pour les hôts homelab/LAN - OIDC + PKCE —
app/auth/providers/oidc_provider.py(authlib 1.8.0) :GET /auth/oidc/login(state+code_verifierstocké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_endpointsi 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 danssso_config(champ secret vide = conserver l'existant), détection de rejeu viasso_requests(TTL 15 min),sso_login_history(succès + échecs),safe_next_path(pas de redirection externe) - Routeur —
app/routers/sso.py: parcours navigateur + APIGET|POST|PUT|DELETE /api/v2/sso/config(admin, headerX-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/samlet/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 lignesworkspace_membersexplicites ; le fallback « viewer » implicite n'en est pas un)- Help — section « Enterprise SSO » + badge
.help-badge.ssodans/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
/logoutdé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 publicproviders, extensionPermissionManagerpytest -n auto→ 802 passed, 0 skipped ·ruff check app testsOK ·eslint static/js0 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 + scopesread/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}/actionsPOST /api/v2/agents/conversations/{id}/run— run synchrone JSON (le flux SSE reste interne) :{status, final, error, reasoning[], actions[], events[], duration_ms}(500 sifailed)POST /api/v2/agents/{id}/trigger— déclenchement externe d'un agent custom- propriété des conversations vérifiée par
user_id→404pour 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(409sur collision,overwrite: truepour é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 documentGET/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.startedetagent.run.failedémis (seulagent.run.finishedl'était) via_fire_agent_webhook(); les 3 sont déjà au catalogue → abonnementagent.*
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.jsonré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 testsOK ·eslint static/js0 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.skipdepuis 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 (journalretrying→retrying→delivered), échec aprèsMAX_ATTEMPTS(4 POST +failedfinal),fire_eventignore les events inconnus, aucun abonné → aucun appel, fan-out wildcard (page.*reçoit,collection.creatednon), flow complet signé bout en bout (HMAC vérifié sur le body reçu + journaldelivered),retry_due_deliveries(rowretryingpérimée → re-fire →delivered, originalsuperseded) - Fixture
dbisolée — SQLite temporaire par test (env + singletonsettingsrestaurés, safe-n auto), tables webhook créées parinit_db();httpx.MockTransportinjecté via stub module +RETRY_DELAYS=(0,0,0)(zéro sleep réel)
Changed
- ROADMAP — case « migration
sync.pyvers 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 testsOK ·eslint static/js0 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_idhérité pour les ACL) ; sync de titre bidirectionnelle ligne ↔ page - Endpoint
GET /db/pages/{row_id}/open/api— renvoie lepage_idde contenu d'une ligne (garde_require_viewcollection) ; 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
synceddans_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_idsurGET /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 viasync_synced_blocks_in_page()(fonction morte depuis v5.14.0), récursif dans leschildren(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_deletedmarqué 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 tablepages: 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 dupage_countdes workspaces
Tests
tests/test_v65_synced_db.py— 17 tests ; suite complète 742 verts (pytest -n auto) ;ruff check app testsOK ;eslint static/js0 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— chaqueupdateenvoyé par le client embarque la version du bloc dont dérive sa saisie ; le serveur calculemerge_block_3way(base, current, incoming). Sansbase(ancien client) → LWW historique, rétro-compatible. - Adoption du résultat fusionné — l'
ackrenvoie le bloc fusionné + drapeauconflict; le client met à jour sabase, 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_writerdédiée ;_broadcast()faitput_nowaitet n'attend plus le socket → un client lent ne fige plus la room entière. - Coalescence des curseurs — le writer réduit les messages
selempilé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éponseack stalesans 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 + compteurslow_disconnects, pour qu'une room ne stagne jamais sur un pair mortel. app/templates/_page_editor_realtime.html— les updates incluent désormaisbase; nouveau traitement de l'ackavecmerged.
Fixed
- Fuite de rooms — une tentative de connexion WS sur une page inexistante (4404) enregistrait une
Roomorpheline 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 surNone; 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 sansbase, conflit scalaire), fuite 4404, coalescence des curseurs, endpoint stats, anti-flood. - 14 tests
tests/test_realtime.pyexistants préservés (non-régression). ruff check app testsOK ·eslint static/js0 problème.
v6.3.0 (2026-09-21) — API publique complète v2 (REST + scopes + OpenAPI)
L'API publique
/api/v2devient 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: colonnesscopes TEXT DEFAULT 'read,write'etexpires_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) etidempotency_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 surapi_tokens, supportextension_devices(clipper) et legacyuser_tokens, vérificationrevoked+expires_at, mise à jourlast_used_at. - Scopes hiérarchiques
read < write < admin(un scope supérieur satisfait un besoin inférieur), dépendancerequire_scope()→ 403 explicite ;validate_scopes_input()→ 400 sur scope inconnu. - Pagination
parse_pagination()(défaut 30 / max 100) + headerX-Total-Count; ISO-8601 UTC viato_iso8601(); parsing des colonnes*_json. - Erreurs RFC 7807
application/problem+json(type/title/status/detail/instance) pour tout/api/v2, via le handler unifié surStarletteHTTPException(app/main.py). - Idempotence
Idempotency-Keysur 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, configurableAPI_V2_RATE_LIMIT_PER_TOKEN). public_api_insecure_ok: le token de devfd-public-keyn'est accepté que siPUBLIC_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(filtresfilter[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.jsongénéré (402 chemins)..env.example:PUBLIC_API_INSECURE_OK=false,API_V2_RATE_LIMIT_PER_TOKEN=300.VERSIONetapp/main.pypassé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 testsOK.
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/cliple workspace sidebar est rafraîchi sans reload (polling + eventclipper:clipped) pour que la page clippée apparaisse immédiatement. - Bloc
bookmark— le servicecreate_page_from_clip()renvoie désormais un blocbookmarkfidèle au payload OG (URL d'origine +embed_srcrésolu viaembeds.py), rendu correct en éditeur / page publique/p/<slug>/ exports MD/HTML/PDF (préserve favicon + description). - Fix éditeur
7f998fa— SyntaxErrorduplicate innerdans_page_editor_scripts.html(variableinnerredéclarée lors du drag & drop multi-sélection) corrigé ; autosave + WS realtime non bloqués. - Tests & build —
flowdeck-clipper.ziprégénéré (static/extension/),ruff/eslintverts,16 teststest_web_clipper.pyverts.
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(permissionsactiveTab, 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, bundleflowdeck-clipper.zipservi à/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). Cap10 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 viacreate_page_from_clip()+log_clip()),GET /status(auth + compteurs devices/clips),POST /auth/verify(enregistre deviceregister_device()→ tokenfd_…montré une fois),GET /devices,DELETE /devices/{id}; page HTMLGET /extensions(téléchargement + liste devices/clips). - Auth triple — session cookie
flowdeck_sessionOU Bearerapi_tokens(hashsha256) OU Bearerextension_devicesOU legacyuser_tokens(_user_from_request()), rate-limit50 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 indexidx_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_routerinclus ;VERSIONbump6.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éfautinherit). - 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/privatesans 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/apifiltré par visible,_require_view/_require_edithelpers 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 parcan_view_property/get_visible_propertiescô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 parscripts/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. RoutesGET /manifest.jsonetGET /sw.jsservies 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 versionsync_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 colonnessync_versionsurpages,collection_pages,collectionsavec triggersAFTER 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 Playwrighte2e/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 ») etGET /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_publicré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 surPUT /api/pages/{id}etPOST /api/pages/{id}/blockspour 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), optionsPOST /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, tablepage_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 checkOK. - 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.htmlgagne un sélecteur de mode (persisté dansviewConfig.calendar_mode), navigation ‹/› adaptée au mode, vue Jour en agenda trié par heure, Semaine en 7 colonnes. Les événements viennent deGET /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_jsonsous__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 tablereminder_log (page_id, occurrence_date); cible = personnes assignées, repli premier admin ; notifie en in-app + email (prefreminders). Configreminders_enabled/reminder_scan_interval_seconds. - Fuseaux horaires — colonne
users.timezone; réglable dans Settings → Notifications (picker IANA viaGET/POST /api/notifications/timezone, listeGET /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éspersonavant/après surPUT /db/pages/{id}/api: les nouvellement assignés reçoivent une notif in-app + email (prefassignments). Préférences étendues :comments,mentions,reminders,assignments. - Template Meeting notes — propriétés
AgendaetNotesajouté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 checkOK. - 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_bycâ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 endpointGET /db/{collection_id}/members/api(membres du workspace, repli tous utilisateurs) alimente le sélecteurperson(chips + avatars). - Groupes de propriétés — colonne
collection_properties.group_name, en-tête de table avec sections pliables (db-group-row+ chevron) etPOST /db/{collection_id}/property-groups/apipour réassigner les colonnes. - Vues sauvegardées par utilisateur —
collection_views.created_by;GET /db/{id}/views/apine renvoie que les vues de l'utilisateur (les vues partagéesNULLrestent visibles),save-as/duplicateattribuent le propriétaire,DELETE /db/views/{id}/apiet renommage. - Swimlanes Kanban —
sub_group_by: une rangée de colonnes par lane (2e dimension de groupement). - WIP limits —
wip_limitspar colonne, colonne surlignée + compteurWIP n/limitau dépassement. - Cartes configurables —
card_properties(propriétés affichées),card_size(compact/détaillé) et couverturecover_mode(none/icon/color/propertyviacover_property). - Calendar drag & drop — vue mois (
date_propertyconfigurable), 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.htmlréé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}/apifusionne désormais les valeurs soumises avec l'existant (PATCH partiel) au lieu de remplacer toute la ligne ;cover_urlpar 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 checkOK. - 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(skippar défaut,update= mise à jour des pages et upsert des lignes par titre,duplicate). Compteurspages_updated/rows_updateddans le rapport. - Erreurs partielles — l'import continue page par page en cas d'échec
(
status: "partial", listeerrors), 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ésrelation(valeurs = ids decollection_pages). Auto-exécuté après un import Notion (clérelationsdu 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
/importenrichi : multi-fichiers, mode de ré-import, import URL, import forge (issues et fichiers), téléchargement du rapport. - Tests —
tests/test_v56_import.pyporté à 43 tests (incrémental ×4, lot, URL ×2, forge-repo ×2, relations, rapport de job). Suite complète 491 verte,ruff checkOK. - 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.
- Word
- 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_milestonesajouté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).
- Forge issues —
- UI — l'assistant
/importliste désormais les 20 sources, accepte.docx/.pdf/.ics/.opmlet propose un panneau « import depuis une forge ». - Dépendances —
python-docx,pypdfajouté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 checkOK. - 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), interfaceImporter(detect()/parse()) + registre auto-détection, pipeline commun (hiérarchieparent_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 (tableimport_items), migration 9 (import_items+import_jobs). - Assistant UI d'import — page
/import(templateimport.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 ouasync=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éskey:: value, pages journal,{{[[TODO]]}}), HTML (Apple Notes, Bear, Ulysses, OneNote), Google Keep (Takeout JSON), plus l'import Markdown/.zipgé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_blocksreconnaît désormais les cases à cocher- [ ]/- [x](elles étaient absorbées par la branche puces à puces). - Dépendances —
openpyxl,beautifulsoup4,PyYAMLajouté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 checkOK.
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.pyréé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 ; helpersresolve_embed(),inline_kind(),provider(). Nouvel endpointPOST /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èreembed_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_metadatane 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 checkOK. - 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.pymute désormais le singletonapp.config.settingsau lieu de le remplacer. Les modules qui importaientsettingsau 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 autoen local et en CI). La config de base ne force plus-n(évite l'échecunrecognized arguments: -nsipytest-xdistn'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_dueet API admin/api/settings/backups. - OAuth (mock) — tests d'intégration complets dans
tests/test_v52_infra.py: redirect authorize → callback (Gitea + GitHub), modelinksur un compte local, rejet d'unstateinvalide, construction des URLs d'autorisation. - Schéma complet hors lifespan —
init_db()crée aussiwebhook_subscriptions(auparavant uniquement dans le lifespan FastAPI). - Linting —
ruff check app testspasse sans erreur (corrections E701/E702, B904, B007, E741, F821, F841, W293, UP031, E731 + config FastAPI pour B008) ; migration de.eslintrc.jsonverseslint.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 bloccode. - 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—AIWritingServicesans 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.Writeouvre le composeur inline existant ;SummarizeetContinueinsèrent le résultat sous le bloc ;Translateremplace 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 ;Tabl'insère,Escapela rejette. - AI properties — bouton ✨ sur chaque ligne de la table database :
POST /api/agent/writing/propertiespropose 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) etPOST /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/sharedacceptedir=made|received|allet renseigneshare_dirsur 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_sharedexposé par/api/local-workspace/tree). - Tests (tests/test_sharing.py +6) : direction made/received/all de la
bibliothèque, fallback dir invalide, et rendu
pageIsSharedsur 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/vieweralors que l'API n'accepte queview/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+ZouCtrl+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+clicsur 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 viadata-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 nouveauxfirst_col_headerpersistés et pris en compte dans les exports Markdown/HTML (app/services/export.py) ainsi que que dansblocksToMarkdownclient. - Intégration realtime : méthode
syncNow/forceEmitsurwindow.__fdRTappelé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 unsynccomplet. - 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+titleen base, flush immédiat à la déconnexion du dernier client. - Sécurité :
connect-srcCSP é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_opsunitaires.
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
buttondans 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, devenueinline-flex(avecoverflow: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
payloadpartagé 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, sansmax_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 unnoticeexpliquant 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
textContentqui é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()lisaitel.textContent, qui supprime les<br>: les sauts de ligne étaient perdus dès la sauvegarde du bloc. Ajout degt()(basé surinnerText, qui rend bien les<br>en\n) utilisé parsync(), 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 puisgt()/innerTextdécoupe le contenu en « avant » / « après », exactement comme un vrai clavier. Fini le décalagebeforequi perdait la fin d'un bloc multi-lignes. _resultText(composer IA) lit pargt()pour préserver les\n.
Agent — validation des modèles (fournisseur bruité)
GET {base}/modelsde 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 pournvidia: 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()litel.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\nrestants 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
:emptyavec le texte figé « New page » et la couleur très sombre--text-dim.:emptycasse 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'aurender(). Pendant la frappe, l'événementinputmettait à jourdirtyetautoSavemais jamais.empty: le texte d'aide persistait sur un bloc qui avait pourtant du contenu.
Correctif
- Le listener
inputdes blocs (rendurender()) bascule désormais.emptyen direct selon le contenu : dès qu'un caractère est saisi,.emptyest retiré et le placeholder disparaît ; quand le bloc est vidé,.emptyrevient 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
.emptyest 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::befored'aide ne se rendait jamais. - La règle cible désormais
.block-content [data-bid].empty::before(l'élément qui porte réellementdata-placeholder+.empty).
Flèches ↑/↓ du menu /
- La navigation dépendait de l'endroit où se trouvait le focus (
_invs bloc vs ailleurs) :onKdn'est lié qu'à#_blocksCt, l'input vit dans#_slashMenu(un voisin), d'où un comportement fragile/incohérent. - Ajout d'un écouteur
keydownau 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
.selectedpasse en!importantpour 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.generatepassait par l'engine à outils (/conversations/{id}/run) qui envoie un schématools/tool_choiceque DeepSeek rejette en 400 Bad Request.- Bascule de
generatesur 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-secondaryavec 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:hoverla 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/generateenregistré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 defdApplyDocument) convertit la réponse markdown en blocs réels viamd2b():## titre→ blocheading_2, tableaux GFM → bloctable,```→ bloccode, 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, viamdEsc()dansrenderBlock(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 blocembed_type:'collection'rendu parFlowDeckDB.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) etcreate_from_template()(collection + propriétés + vue par défaut).POST /db/apietPOST /db/inline/apiacceptent"template": "<nom>"et matérialisent les propriétés (titre exclu — c'est le titre de ligne). Colonneiconajouté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 parvalidate_property_rule()dansproperty_types.py(required, unique, min, max, min_length, max_length). - Serveur :
create_page_apietupdate_page_api(/db/{c}/pages/api,/db/pages/{id}/api) valident les valeurs avant écriture → 400 + message clair. La règleuniqueexclut 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/apiacceptentvalidation(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+Pdansbase.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 etopenQuickFind()ouvrent désormais la palette (fini le toast « Quick Find — Ctrl+K »). - Endpoint
GET /api/search?q=(app/routers/search.py) + serviceapp/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 tablepages; repli automatique surLIKEsi 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) : tableschema_versionenregistrant 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+ triggerspages_fts_ai/ad/au+ backfill. init_db()termine désormais parapply_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 versionschema_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/mentionspar sections : Fichiers de l'espace courant («workspace_idtransmis 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
/runaccepteskill_ids(tableau) et l'engine injecte tous leurs prompts dans les instructions système (skill_idreste 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 lesskill_idépinglés viaskill_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 sestool_calls(avecid), et chaque résultat d'outil répond avec letool_call_idcorrespondant (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 SSEerror) 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_callsanalysés conservent leuridet 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.pyversion → 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_workspacevide. 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 (parworkspace_idouworkspace_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.pyversion → 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_workspacepuis 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.pyversion → 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 plussearch_workspace/create_collectionintempestifs. - 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 (modereplacepour « Traduire cette page ») viawindow.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.pyversion → 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 » + raccourciCtrl/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 etfdAgent.askouvrent explicitement le panneau (fini le toggle silencieux). RaccourciCtrl/Cmd+Jglobal. - 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 · » 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
textareaarrondie 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.pyversion → 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-textembarquait 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 viabase_urldans/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
meetinginteractif 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.generatesur 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
meetingajouté aux blocs sauvegardés).
Infra
VERSION→ 4.12.1 ;app/main.pyversion → 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_errorsuruser_llm_keysetllm_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'étatverified; l'endpoint admin/providers/testmarque le fournisseur par défaut de l'espace. /api/agent/providersenrichi : chaque provider exposeconfigured,verified,functional,last_erroretsource(user/global/open) ;default_provider+default_verifieden 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.pyversion → 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 sanscurrentConv). 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 finaldu flux SSE) est insérée automatiquement dans la page sous forme de blocs édités (hookwindow.__fdInsertAI).
- méthodes
- 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)) + serviceapp/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}/modelspour OpenAI-compatible (Bearer), headersx-api-key/anthropic-version(Anthropic),x-goog-api-key(Gemini) ; stockés dansmodels_jsonet fusionnés dansGET /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 viaPATCHet sans appel LLM.
Infra
VERSION→ 4.11.0 ; migration idempotente (création tableuser_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,
/runutilise 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 surPOST /runet persistés par conversation (colonnesprovider/modelsuragent_conversations, migrées automatiquement). Bouton ⚙️ pointant vers la config admin. - Endpoints API :
GET /api/agent/providersenrichi : liste complète des providers avec modèles connus,requires_key,api_baseethas_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éthodeLLMClient.ping()) ; le providerofflinerépond sans réseau.
- Table
llm_config(1 ligne, id=1) + serviceapp/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 toujoursgpt-4o;PROVIDER_MODELSdocumente les modèles connus par provider.
Infra
VERSION→ 4.10.1 ; migration idempotente des colonnesagent_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 + fonctionundo_action()(rollback). - Streaming SSE : endpoint
/api/agent/conversations/{id}/runqui émetreasoning,actionetfinalen 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 avecToolResult+ 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 deapp/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). Tablecommentsétendue (target_type,target_id,anchor_block_id,anchor_start,anchor_end) et migrée de son FKcollection_pagesvers 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@logindans 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=) ; routernotifications+collaborationenregistrés ; exclusions CSRF (/api/notifications,/api/comments) ; leRateLimitMiddlewarerespecte désormaissettings.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
deleteCountdeArray.spliceau lieu d'utilisersplice(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 CSSapp.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
tabledans 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, cellulescontenteditableé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) :
md2bdétecte les tableaux pipe (| a | b |+ ligne de séparateurs) et produit un bloctableé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
tablesortent 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 CSSapp.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→ bloctable) 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 surxhtml2pdfsi 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) + policesfonts-dejavu-coreetfonts-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) : lecontentde 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 pagesmarkdown/fichiers.mden 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
.mduploadé → contenu réel dans Markdown/HTML/PDF ; fichier code → exporté ; fichier binaire → non déversé. - Vérifié en réel sur
/data(pageREADME.mdet 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 enabsolute; top:100%→ rendus SOUS le viewport, donc invisibles. Repositionnés enposition:fixedsous la topbar (scoped.page-editor-wrapper > …pour ne pas casser les.more-menudes pages library/local_workspace) + variantes mobile dans le media query < 768px. - Sous-menu Export se refermait — l'itém « ↗ Export » faisait
moreOpen=falseavant d'ouvrirexportOpen: les 4 formats restaient inaccessibles au clic. Devient un toggle (exportOpen = !exportOpen), le menu More reste ouvert. - Cache CSS — version du query string
app.cssbumpé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_contentsetmath— nouveaux types dans le slash menu (éditeur)- Multi-colonnes — bloc
columnsavec 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.htmln'importait pas la macrofd_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 depuislocalhostet192.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_datagère le cookiegitea:owner:repopour 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=1pour 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 paradmin_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): remplaceUNIQUE(name)dans la tabletags- 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()appelledoFilter() - 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/v1exempté
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
commentsavec 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