Compare commits

...
34 Commits
Author SHA1 Message Date
bruno 12beab01c9 fix(editor+sidebar): bouton ⋯ retiré, Form via modale app, doublon du dernier fichier du tree
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / lint (push) Failing after 1m37s
- "Get started with" : suppression du bouton "⋯" et de son menu mort
  (Table/Board/List/Timeline/Calendar/Gallery/Import — aucun handler).
- Pilule Form : createForm() plantait sur page vide ("Cannot read
  properties of undefined (reading 'push')") car this.inlineDBs n'était
  jamais initialisé dans editorState() (seulement via loadInlineDBs si un
  bloc inline_dbs existait déjà). Corrigé : inlineDBs:[] initialisé.
  Le nom du form est demandé via une modale Alpine personnalisée
  (flowdeck-modal*) au lieu du prompt() natif.
- Sidebar tree après drag&drop : refreshSidebarTree() préservait le lien
  "Manage Workspaces" via ul.querySelector('li:last-child') — non scopé,
  il attrapait le dernier fichier d'une sous-liste de dossier imbriquée
  (premier li:last-child du document) et le ré-insérait → doublon du
  dernier fichier. Corrigé : ':scope > li:last-child' (enfant direct).
2026-10-10 20:49:45 -04:00
bruno 090174df0a fix(local-workspace): menu contextuel du sidebar cassé — fermeture prématurée d'app-layout
FlowDeck CI / lint (push) Failing after 1m36s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 2h58m20s
Un </div> fantôme dans le bloc Content view (local_workspace.html) fermait
content-area puis app-layout AVANT les balises #context-menu et
#ws-context-menu. Les deux menus tombaient en enfants directs de <body>,
hors du scope Alpine x-data="appState()" : directives :class/:style/
@click.outside jamais montées → menu contextuel invisible (showWsContext
mettait bien wsCtx.visible=true, mais rien ne réagissait).

Retiré le </div> excédentaire — le bloc se referme désormais sur
tags → item → template → list. Vérifié e2e (Playwright) : menu dans
app-layout, visible au clic droit, 4 items, 0 pageerror.
2026-10-10 20:02:27 -04:00
bruno fc8548194a feat(templates): refonte complète des templates façon Notion + vues/agents-skills
FlowDeck CI / lint (push) Failing after 1m32s
FlowDeck CI / test (push) Failing after 27m50s
FlowDeck CI / docker (push) Skipped
Templates (v7.71.x) :

- registre unifié \	emplates\ (migrations 48-49) + TemplateService.instantiate unique (UI, API v2, agent, scheduler)

- sélecteur (pilule page vide, menu •••, commande /template), gestionnaire /templates, menu New ▾, From template, base inline dans un document

- 141 presets système (59 pages, 42 bases, 15 blocs, 25 lignes), titre auto depuis le template, variables title réservée

- récurrences RRULE + scheduler dédupliqué, agent apply_template/list_templates, API /api/templates + /api/v2/fd-templates

- correctifs : bouton Templates, centrage fenêtre, filtres CSP, flux de création, variable title

- tests : tests/test_fd_templates.py (19) et e2e/templates_picker.spec.js (8)

Inclut le travail déjà présent dans le working tree (vues Notion : view_query/view_aggregate/form_projection/geocoding, property_types, database_table, docs agents-skills) et ignore .playwright-mcp/.
2026-10-10 18:52:19 -04:00
bruno d0452e10dd fix: aligner version FastAPI sur VERSION 7.70.1 (test PWA)
FlowDeck CI / lint (push) Successful in 1m38s
FlowDeck CI / test (push) Successful in 25m27s
FlowDeck CI / docker (push) Successful in 1m33s
2026-10-10 01:07:27 -04:00
bruno 57c6cbec03 ci: relancer le CI (run precedent fige sur worker)
FlowDeck CI / lint (push) Successful in 1m41s
FlowDeck CI / test (push) Failing after 3h7m16s
FlowDeck CI / docker (push) Skipped
2026-10-10 00:37:39 -04:00
bruno 1fedafa808 fix: sidebar Repository et pages forge — erreurs console, cache assets (v7.70.1)
FlowDeck CI / lint (push) Successful in 1m37s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 1h9m58s
2026-10-09 23:33:48 -04:00
bruno d95178156f feat: sidebar Repository identique au Workspace-local, pages forge unifiees, GC miroirs vides
FlowDeck CI / lint (push) Successful in 1m37s
FlowDeck CI / test (push) Successful in 25m11s
FlowDeck CI / docker (push) Successful in 1m32s
2026-10-09 22:33:34 -04:00
bruno 9ea0cddf3c fix: CI verte — fuite du client HTTP mocké + rate limiter actif dans test_agent
FlowDeck CI / lint (push) Successful in 1m40s
FlowDeck CI / test (push) Successful in 24m45s
FlowDeck CI / docker (push) Successful in 2m8s
Deux causes indépendantes faisaient échouer le job test de la CI (et
seulement en CI : ni .env ni les mêmes ordres de chargement en local) :

- test_agent_web_tools.mock_http patche http_client.shared_client pendant
  sa fenêtre d'exécution. Le premier import d'importers/url_fetch dans
  cette fenêtre fige la factory moquée dans le namespace du module →
  tous les appels fetch_url suivants du processus passaient par le handler
  mocké de l'autre test (« assert '…/post' == '…/page' » dans
  test_v56_import). url_fetch résout désormais le client à l'appel, et le
  mock_http restaure la vraie factory (référence figée au chargement du
  module) y compris sur url_fetch ;
- test_agent.py posait RATE_LIMIT_ENABLED=false en env var, sans effet sur
  le singleton Settings déjà instancié : sous pytest -n auto, le worker
  dépassait le quota de 60 req/min et 8 tests recevaient des 429
  (KeyError 'id' au passage). Le fixture désactive maintenant le limiter
  sur le singleton, comme les autres fichiers de tests.
2026-10-09 17:55:44 -04:00
bruno 0218d8f5e5 fix: /local-workspace — chemin du header + clic sur un dossier du sidebar (v7.69.9)
FlowDeck CI / lint (push) Successful in 1m37s
FlowDeck CI / test (push) Failing after 24m19s
FlowDeck CI / docker (push) Skipped
Corrige deux régressions de /local-workspace :

- le chemin du header restait bloqué sur « Home / <workspace> » quel que
  soit le dossier affiché : la route rend désormais breadcrumb_items
  (Home / <workspace> / <dossier> / <sous-dossier>, niveaux cliquables,
  collapse « … » au-delà de 4) et la navigation sans rechargement recalcule
  le chemin via l'event flowdeck:breadcrumb-changed ;
- le clic sur un dossier du sidebar affichait TOUS les composants à la
  fois : Alpine.data('wsInitData') retournait le même objet singleton, le
  2e montage (navigation partielle) levait « Cannot redefine property:
  \ » et initTree abandonnait, laissant tout le contenu au state
  brut. La factory retourne désormais une enveloppe fraîche par montage
  qui délègue à l'état réactif partagé. #lw-config est aussi relu à chaque
  exécution (le 2e montage gardait le folder_id du 1er chargement).

Inclus également le travail en cours de l'arbre : Library (colonnes Last
visited/Source, ordre d'en-tête, favoris à icônes Workspace), Meeting
Notes (bloc, CSS, routes, docs), coloration de code hljs, badges
favori/publié dans l'arbre local-workspace, docs (DATA_MODEL,
architectures) et tests associés.
2026-10-09 16:58:04 -04:00
bruno 7468e497c6 fix: le bouton close de Settings ferme en un clic (v7.69.4)
FlowDeck CI / lint (push) Successful in 1m34s
FlowDeck CI / test (push) Failing after 15m40s
FlowDeck CI / docker (push) Skipped
Le bouton close de Settings devait être cliqué deux fois. Symptomatique depuis
le menu utilisateur ; depuis /workspaces ça marchait.

Cause : closeSettings() faisait history.back() PUIS window.location.reload()
après un délai FIXE de 100 ms. Le retour est une navigation asynchrone : quand
elle n'est pas terminée en 100 ms, le reload() retombe sur /settings et le
panneau « se ferme puis revient ». La profondeur d'historique diffère selon le
parcours — d'où le caractère intermittent et le fait que /workspaces
échappait au symptôme.

Correctif : location.replace() vers la page d'origine. Une seule navigation
déterministe — ni history.back(), ni popstate, ni rechargement différé. Aucun
chemin ne peut plus se terminer sans navigation. Effet secondaire assumé et
souhaitable : « Retour » ne rouvre plus Settings.

La branche window.opener ne fait plus de return inconditionnel : window.close()
échoue en silence quand l'onglet n'a pas été ouvert par du script, et l'ancien
code abandonnait alors sans navigation de repli — le bouton semblait mort.

CONTRÔLE NÉGATIF FAIT — le même spec a été exécuté contre deux builds :
  ancien closeSettings() -> le parcours menu-utilisateur ÉCHOUE
                            (Received: "…/settings"), /workspaces passe ;
  correctif              -> les deux passent.
Le test verrouille aussi que le panneau position:fixed n'est plus peint
(.settings-overlay absent), qui est le symptôme exact rapporté.

Correction de diagnostic : le diagnostic posé en v7.69.3 (« Alpine ne lie pas
le composant x-data="settingsInit()" ») était FAUX — un artefact de mon banc
d'essai target="_blank", pas le parcours réel. La page étant réactive après le
premier clic, le handler s'exécutait bien. ROADMAP.md et CHANGELOG.md § v7.69.3
corrigés pour ne pas laisser une piste fausse en héritage.

pytest 1421 passed / 0 failed · ruff OK · OpenAPI 526 chemins / 7.69.4
e2e 18/18 verts sur l'instance déployée
2026-10-08 22:02:08 -04:00
bruno 162eb257c7 fix: la section Aide est positionnée comme Settings (v7.69.3)
FlowDeck CI / lint (push) Successful in 1m32s
FlowDeck CI / test (push) Failing after 16m17s
FlowDeck CI / docker (push) Skipped
La section Aide et la section Settings partagent le même gabarit
(`.settings-overlay` / `.settings-panel`) et la même feuille de style
(`static/css/settings.css`), mais affichaient deux rendus différents.

Cause : `help.html` neutralisait son overlay via un style INLINE sur la balise
— `position:static`, `background:none`, `backdrop-filter:none`, `padding:0` —
et imposait par-dessus un panneau `width:100%;max-width:1050px;
height:calc(100vh - 120px);margin:0 auto`. Settings, lui, laissait
`settings.css` faire son travail : overlay `fixed;inset:0` avec fond flouté,
panneau `85vh` centré par `margin:auto`.

Résultat perçu : Aide s'affichait comme un bloc posé dans la page, Settings
comme une modale centrée floutant l'arrière-plan — malgré un code quasi
identique.

Correctif : les surcharges inline sont retirées de `help.html`. La géométrie
vient désormais entièrement de `settings.css`, commun aux deux pages. Aucune
valeur n'est dupliquée ; les deux balises sont strictement identiques.

Vérifié en e2e en comparant les géométries CALCULÉES des deux pages (pas des
valeurs de code) : overlay `fixed` avec fond opère des deux côtés, panneau
1050x765 centré à top:68px, bouton close à 13/13px du coin du panneau — les
deux jeux de mesures sont identiques.

Tests : `tests/test_v7693_help_positioning.py` (5) verrouille l'absence de
surcharges inline et l'identité de balise avec Settings ;
`e2e/v7693_help_positioning.spec.js` compare les géométries calculées.

NON INCLUS — double-clic sur le bouton close de Settings :
le bug a été REPRODUIT en e2e et sa cause racine identifiée, mais le correctif
n'est volontairement PAS dans ce commit. Voir ROADMAP.md § v7.69.3 pour le
relevé complet et la prochaine étape.

pytest 1421 passed / 0 failed · ruff OK · e2e 15/15 verts sur l'instance déployée
2026-10-08 21:07:17 -04:00
bruno b9a776b626 fix: le favicon est enfin le vrai logo, plus un placeholder (v7.69.2)
FlowDeck CI / lint (push) Successful in 1m34s
FlowDeck CI / test (push) Failing after 3h6m20s
FlowDeck CI / docker (push) Skipped
Le favicon était un carré bleu #2383E2 avec les lettres « FD » écrites en
<text> SVG : génériques, sans aucun lien avec la marque, et rendues avec la
police installée chez le visiteur (donc instables).

static/favicon.ico est désormais généré depuis assets/logo.jpg — la même
source que la bannière et les icônes PWA. 16/32/48 px dans un seul fichier,
un seul <link> pour tous les navigateurs, 4,1 Ko.

Pourquoi un fond sombre et pas le logo nu :
  le logo est blanc à ~82 % (mesuré via Pillow — 50,7 % de blanc pur, plus
  32 % de blancs proches). Posé nu, il se dissoudrait sur un onglet clair,
  qui est le cas nominal de Firefox et de Safari. Il est donc posé sur un
  carré #191919 arrondi, LE MÊME fond que les icônes PWA générées en
  v7.69.0, ce qui garde l'identité cohérente entre onglet, écran d'accueil
  et partage.

Contraste mesuré à 16 px : σ = 106 avec le fond sombre, contre σ = 49 pour
le logo seul — le fond sombre double littéralement la lisibilité à cette
taille. Le test statique verrouille les tailles embarquées dans l'ICO.

Mise à jour des 5 templates qui pointaient vers l'ancien SVG (base, import,
landing, public_page, welcome) + la liste de pré-cache du service worker.
static/favicon.svg SUPPRIMÉ : plus aucune référence dans le dépôt, le test
le vérifie pour éviter qu'il revienne.

pytest 1416 passed / 0 failed · ruff OK · OpenAPI 526 chemins / 7.69.2
2026-10-08 19:41:37 -04:00
bruno 674baf6954 fix: og:image et twitter:image en URL absolue (v7.69.1)
FlowDeck CI / lint (push) Successful in 1m34s
FlowDeck CI / test (push) Failing after 3h12m7s
FlowDeck CI / docker (push) Skipped
Les meta `og:image` / `twitter:image` ajoutés en v7.69.0 pointaient vers une
URL relative (`/static/img/logo-512.jpg`). Les crawlers — LinkedIn, Slack,
Discord, iMessage — ignorent une URL relative : la carte de partage de FlowDeck
était donc vide partout où un lien est collé.

Correction via le motif déjà en place dans `app/templating.py` : un global
Jinja `app_base_url()` construit à partir de `settings.app_base_url`, qui est
LA MÊME source que les liens de partage de pages (`collaboration.py:32`).
Si cette valeur est mal renseignée, ces liens l'étaient déjà — comportement
cohérent plutôt qu'une deuxième source de vérité à maintenir.

Lecture paresseuse de `settings` dans la fonction, à l'image de
`_plugin_enabled` : un import au chargement du module créerait un import
circulaire.

Rendu vérifié sur l'instance déployée :
  og:image" content="http://localhost:8080/static/img/logo-512.jpg

Tests : `test_og_image_url_is_absolute_via_base_url` vérifie le global ET
l'absence de la forme relative dans les deux templates. Piège évité au passage
— la forme absolue CONTIENT l'ancienne en sous-chaîne, donc l'assertion
négative porte sur `content="/static/img/` (le guillemet avant la barre) et
non sur l'URL complète.

pytest 1413 passed / 0 failed · ruff OK · OpenAPI 526 chemins / 7.69.1
e2e 14/14 verts sur l'instance redéployée
2026-10-08 19:00:13 -04:00
bruno 3d3b46b58b fix: sélection multi-blocs fonctionnelle (Ctrl+C/X, drag souris, clic droit) + identité visuelle (v7.69.0)
FlowDeck CI / lint (push) Successful in 1m34s
FlowDeck CI / test (push) Failing after 17m31s
FlowDeck CI / docker (push) Skipped
Trois bugs d'éditeur, chacun avec sa cause racine prouvée par trace
d'événements — le mécanisme Clipboard livré en v7.68 était inutilisable.

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

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

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

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

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

pytest 1413 passed / 0 failed · ruff OK · OpenAPI 526 chemins / 7.69.0
2026-10-08 17:27:14 -04:00
bruno 7cf922106f fix: colonnes rendues, tables IA sans pipe de tête, copier/couper/coller multi-blocs (v7.68.0)
FlowDeck CI / lint (push) Successful in 1m33s
FlowDeck CI / test (push) Failing after 18m53s
FlowDeck CI / docker (push) Skipped
Colonnes / équation / TOC
- renderBlock() n'appelait JAMAIS renderColumnsBlock()/renderMathBlock()/renderTOC()
  (3 renderers morts) → un bloc colonnes (slash « Column list », Turn into
  2/3/4/5 colonnes) tombait dans le rendu générique = paragraphe vide.
  Dispatch ajouté ; la × de suppression de colonne (opacity:0 sans survol)
  devient visible au survol.

Tables rendues par l'IA
- _tblIsData/_tblIsSep exigeaient un | de tête → le GFM sans pipe (A | B /
  --- | ---) devenait des paragraphes. Pipe de tête optionnel, séparateur
  gardé à ≥2 cellules (« texte » + « --- » reste un séparateur). md2b et
  paste2b partagent les helpers.

Copier / couper / coller
- Sélection de texte MULTI-BLOCS : _clipState()/_clipMarkdown() (début partiel
  → blocs entiers via blocksToMarkdown → fin partielle), clipCopy()/clipCut()
  (coupure = tête du 1er bloc + queue du dernier), listeners copy/cut (Ctrl+C/X
  via les événements natifs), groupe Clipboard (Cut/Copy/Paste) dans le menu du
  clic droit avec la sélection figée à l'ouverture (la recherche prend le focus),
  clic droit sur un bloc sélectionné = sélection multi-blocs conservée.
- Coller un seul bloc structuré (table/code/titre) = vraie insertion (avant :
  texte brut) ; pasteBlocks colle après le dernier bloc sans focus.
- « Copy link to block » réparé (data-bm-act link → copy-link).

Tests: pytest tests/ -q -n auto → 1394 passed / 0 failed ; ruff OK ;
e2e v768_columns_clipboard (4 : slash+Turn into colonnes, table IA sans pipe,
copier/couper/coller, clic droit Clipboard) + v762/v764/v766/v7641/v763/
regression_editor_mount → verts sur :8081 (image flowdeck:v7.68.0).
2026-10-08 15:23:46 -04:00
bruno d58c321c07 feat: réactions dans le tiroir Comments (+ suppression) et refonte du menu de sélection (v7.67.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Tiroir Comments
- Section « Reactions » au-dessus des fils : émoji, compte (si >1) et texte
  ancré lu dans le DOM (reactionList() / _anchorText(), offsets source hors
  pastilles) ; l'état vide ne s'affiche plus s'il reste des réactions.
- Suppression : ✕ sur la ligne du tiroir (mes réactions) ET croix ::after au
  survol de la pastille sur l'émoji que j'ai posé (aucun nœud ajouté → offsets
  intacts). Deux entrées → removeReaction() → POST toggle → loadReactions().

Menu de sélection (maquette ASCII)
- Ligne 4 = rangée scannée : 💬 Comment (libellé) à gauche, 🙂+ réaction à
  droite (.ft-row-split) ; la réaction sort de la ligne 3.
- Entête Skills avec ≡ à droite (::after), liste Skills scrollable.
- ✨ Ask AI retiré de la toolbar : doublon exact de « Edit with AI » du pied.
- Écart volontaire à la maquette : le barré (T) de la ligne 2 est conservé.

Fix racine (flake de gate)
- _is_public_host attrape OSError (le garde SSRF renvoie un booléen, il ne
  lève plus) : un socket.timeout DNS ne bascule plus fetch_og_metadata dans
  son except Exception → repli silencieux → AssertionError.
- fixture stub_dns sur TestOGParser (même motif que test_agent_web_tools) :
  zéro vrai DNS dans les tests OG.

Tests: pytest tests/ -q -n auto → 1390 passed / 0 failed (flake corrigé) ;
ruff check app tests → OK ; e2e v764 (réactions dans le tiroir + suppression
A/B), v762 (nouvelle ligne 4), v766, v7641, v763 → 6 passed sur :8081
(image flowdeck:v7.67.0 rebuild + redéployée).
2026-10-08 14:25:48 -04:00
bruno db6bf03a00 fix: icône du bouton « commenter » = bulle Feather + (message-square) (v7.66.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Chemin SVG dessiné à la main remplacé par le message-square éprouvé de Feather
(viewBox 24) + un « + » centré dans la bulle — même famille de traits que les
autres icônes outline de l'éditeur. Le test statique suit.

Gates: pytest tests/ -q -n auto → 1386 passed (1 flake connu et non lié
test_v55::TestOGParser::test_fetch_og_metadata_success : échoue parfois sous
-xdist, passe seul et passe au run complet final) ; ruff OK ;
e2e v766 (2) + v764 (1) → 3 passed sur :8081 (image flowdeck:v7.66.0
rebuild + redéployée).
2026-10-08 13:09:22 -04:00
bruno c031bf67de chore: ne pas versionner commit_msg.txt (fichier de message ajouté par erreur en v7.66.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Ajouté à .gitignore + retiré de l'index ; le message de commit de v7.66.0
(ab69777) l'avait embarqué via `git add -A`.
2026-10-08 12:47:33 -04:00
bruno ab6977733d feat: Escape du menu slash retire le « / » + bouton « commenter » à droite du bloc (v7.66.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- SM.escClose() : un seul implément, appelé par les deux chemins de fermeture
  du menu slash (focus dans le bloc → onKd, focus dans l'entrée du menu →
  listener document). Retire le « / » d'ouverture seulement, remet le caret,
  sync() + autoSave().
- Bouton .block-comment-btn à droite du bloc (gouttière 64px) : icône bulle de
  conversation + , visible au survol/focus comme le handle, masqué sur mobile
  (2 breakpoints) et sur page verrouillée ; onclick = E.commentBlock(idx).
  Remplace l'ancien .block-actions de renderBlock (opacity:0 sans règle de
  survol → un + invisible qui capait les clics dans la gouttière).
- fmtComment() restaure la plage de la toolbar (_fmtRestore) avant d'ancrer :
  commentBlock() ne se replie plus sur this._sel (plage périmée éventuelle).

Tests: pytest tests/ -q -n auto → 1386 passed ; ruff check app tests → OK ;
e2e v766 (Esc → menu fermé + bloc vide ; bouton masqué → visible au survol,
à droite du wrapper, ancre = bloc visé) + v764/v762/v7641/v763/
regression_editor_mount → 7 passed sur :8081.
2026-10-08 12:47:00 -04:00
bruno 00c5e2a60d feat: commentaire ancré sur la SÉLECTION de texte (pas le bloc) + retrait du bouton bleu « 💬 Comment » (v7.65.0)
FlowDeck CI / lint (push) Successful in 1m34s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 12m52s
- commentBlock() : l'ancre est la sélection vivante dans le bloc (repli : la
  plage mémorisée par la toolbar de sélection, qui survit au clic du bouton),
  sinon tout le bloc ; offsets via _srcOffset() → anchor_start/anchor_end
  exacts dans le texte source, l'ancre jaune ne couvre que le texte choisi.
- Suppression de #_commentSelBtn (bouton bleu flottant « 💬 Comment » au-dessus
  de la sélection), de showCommentBtn()/hideCommentBtn() et du listener
  mousedown associé ; déclencheurs restants : 💬 de la toolbar de sélection,
  menu de bloc, Ctrl+⇧+M.
- toggleComments() remet _commentSel à zéro à la fermeture du tiroir : un
  brouillon abandonné ne peut plus ancrer le commentaire suivant ailleurs.
- e2e v764_comments_reactions : sous-sélection « reaction a un » → ancre = la
  plage seule (avant/après reload), #_commentSelBtn absent.

Tests: pytest tests/ -q -n auto → 1383 passed ; ruff check app tests → OK ;
e2e v764/v762/v763/v7641/regression_editor_mount → 5 passed sur :8081.
2026-10-08 12:19:56 -04:00
bruno 858649e3c3 fix: la touche Delete supprime maintenant le bloc vide (comme Backspace) (v7.64.1)
FlowDeck CI / lint (push) Successful in 1m34s
FlowDeck CI / test (push) Failing after 18m41s
FlowDeck CI / docker (push) Skipped
- onKd : la garde « bloc sans texte → supprimer le bloc » couvre Backspace ET
  Delete (une seule branche partagée) — Delete vide d'abord le bloc, puis le
  supprime au coup suivant ; le dernier bloc de la page n'est jamais supprimé
  (l'éditeur garde un paragraphe)
- le vide est jugé avec gtTok(el) (le texte lu à la sauvegarde) et non
  textContent : une pastille de réaction seule n'empêche plus la suppression
- Tests : e2e/v7641_delete_key.spec.js (texte → Delete vide le bloc → Delete
  supprime le bloc → dernier bloc jamais supprimé) ; 4/4 specs e2e verts,
  suite complète 1383 passed / 0 failed, ruff OK, OpenAPI 7.64.1 (526 chemins)
2026-10-08 11:48:16 -04:00
bruno 400a827e3f feat: commentaires façon Notion — ancres jaunes, tiroir refondu, réactions emoji sur texte (v7.64.0)
FlowDeck CI / lint (push) Successful in 2m5s
FlowDeck CI / test (push) Failing after 3h5m38s
FlowDeck CI / docker (push) Skipped
- Réaction sur texte : bouton 🙂+ (« React to selected text ») dans la toolbar
  de sélection → sélecteur d'émoji EXISTANT en mode réaction (en-tête = texte
  sélectionné + ✋, onglets masqués, ligne Recent au-dessus de la grille
  People, Filter… et barre de catégories avec + custom inchangés)
- Backend : migration 37 text_reactions (UNIQUE plage × emoji × utilisateur →
  POST idempotent) + GET/POST /api/pages/{id}/reactions (GET groupé par plage
  emoji/count/mine, POST toggle, 400 sur plage vide)
- Affichage : plage commentée/réagie peinte en jaune (_paintAnchors après
  render/loadComments/loadReactions), pastille 😀+compte après la plage, clic
  pastille = rouvrir le sélecteur, clic ancre = ouvrir et centrer le fil
- Pastilles = annotations : gtTok/gtMd les ignorent (y compris le chemin
  rapide innerText) et les offsets _srcOffset/_srcNodeLen les excluent → le
  texte sauvé est identique (vérifié dans le e2e)
- Tiroir refondu : carte de fil (avatar, nom, horodatage, ✓ toggle
  resolve/reopen, ⋯ → Copy link #cmt-<id>/Delete), saisie « Add a comment... »
  avec @ et envoi ↑/Entrée ; #cmt-<id> ouvre et centre le fil au chargement ;
  loadComments + loadReactions à l'ouverture (commentCount enfin renseigné)
- Fixes : resolveComment ne pouvait pas rouvrir (toujours resolved:true) ;
  showCommentBtn ignorait les sélections à ancre élément (bouton 💬 jamais
  affiché)
- Tests : tests/test_v764_comments_reactions.py (8, dont toggle API réel),
  e2e/v764_comments_reactions.spec.js (8 étapes) — 3/3 specs e2e verts,
  suite complète 1383 passed / 0 failed, ruff OK, OpenAPI 7.64.0 (526 chemins)
2026-10-08 10:46:14 -04:00
bruno 699165469d feat: menus mobiles façon Notion — barre horizontale au « / » + Insert block plein écran (v7.63.0)
FlowDeck CI / lint (push) Successful in 1m32s
FlowDeck CI / test (push) Failing after 21m41s
FlowDeck CI / docker (push) Skipped
- Barre flottante défilante sous le curseur (mobile ≤ 768 px) au lieu de la
  popup desktop, 15 boutons dans l'ordre de la description : +▾, Turn into▾,
  @, 💬, 🖼, 🗑, ←, →, ↑, ↓, ↩, ↪, Color▾, More▾, ⌨
- +▾ → feuille plein écran « Insert block » (titre centré, Cancel bleu, groupes
  Basic blocks / AI writing / Media / Data / Synced / Actions, raccourcis
  markdown à droite) ; items partagés avec la popup desktop (SM._slashItemHtml)
- Turn into ▾ / Color ▾ → feuilles réutilisant les générateurs extraits
  _blockTurnItems / _blockColorItems (extraits de _blockTurnMenuOpen /
  _blockColorMenuOpen, comportement desktop inchangé)
- More ▾ → menu contextuel de bloc existant ; @ insère + ouvre les mentions ;
  💬 commentaire ; 🖼 bloc image ; 🗑 suppression ; ⌨ masque le clavier ;
  ↩ ↪ annuler/rétablir
- moveBlock(bid, dir) : ↑↓ réordonne (racine + enfants toggle/columns),
  → indent dans le frère précédent, ← outdent, garde-fous avec toast
- Fermeture auto de la barre quand le « / » n'est plus en tête de bloc ; taps
  dans la barre/les feuilles exclus du handler « clic hors-zone » ; Cancel ne
  referme que la feuille (la barre reste tant que le / est tapé)
- Raccourcis slash alignés sur les préfixes markdown (-, [], >, ---), groupe
  SYNCED libellé « Synced »
- Tests : tests/test_v763_mobile_menus.py (ordre, câblage, structure, styles +
  moveBlock exécuté sous node), e2e/v763_mobile_menus.spec.js (390×844, 1
  passed) ; test_v761 adapté aux extractions ; suite complète 1375 passed / 0
  failed, ruff OK, OpenAPI 7.63.0 (525 chemins)
2026-10-08 09:36:37 -04:00
bruno 3a6445ca4e feat: menu contextuel au highlight de texte — toolbar 4 lignes façon Notion (v7.62.0)
FlowDeck CI / lint (push) Successful in 1m32s
FlowDeck CI / test (push) Failing after 20m17s
FlowDeck CI / docker (push) Skipped
- Toolbar de sélection sous le surlignage : ligne de style (T + type de bloc
  dynamique → sous-menu Turn into), A/B/I/U/T barré/Tx, lien/surlignage/
  code/équation/…, Comment + Ask AI, section grise Skills (4 actions) et pied
  « Edit with AI » (Alt+⇧+E) ; positionnée sous la sélection (au-dessus si
  manque de place), fermée au clic hors sélection
- « … » → menu contextuel de bloc existant (recherche « Search actions… »,
  groupe Text, 10 actions + raccourcis, pied auteur/heure/taille)
- Info-bulle claire à droite des éléments du menu : data-tooltip-html +
  classe .tooltip-light dans le tooltip global (app.js)
- Persistance du formatage inline : sérialiseur gtMd() (DOM formaté → source
  markdown) branché sur sync() ; mdEsc() rend __…__ (u), ==…== (mark) et
  $$…$$ (équation inline KaTeX, data-tex conservé)
- Fixes racine : askAI(idx) écrasé par askAI() → askAIBlock(idx) (menu de bloc,
  Ctrl+J, Alt+⇧+E) ; ed.__x (Alpine v2) inexistant sous v3 → _xed() via
  _x_dataStack[0] (showFmt/hideCommentBtn/mentions ne s'exécutaient jamais) ;
  ancre de sélection nœud élément gérée ; checkMd() appelé avec un id au lieu
  de l'index (exception à chaque barre d'espace)
- Tests : tests/test_v762_selection_toolbar.py (roundtrip gtMd/mdEsc sous node)
  + e2e/v762_selection_toolbar.spec.js (structure, sous-menus, géométrie,
  persistance bold/highlight après reload) ; suite complète 1368 passed / 0
  failed, ruff OK, OpenAPI régénérée 7.62.0 (525 chemins)
2026-10-08 08:39:39 -04:00
bruno 033490c464 feat: menu contextuel de bloc refondu façon Notion — 10 actions, sous-menus et raccourcis (v7.61.0)
FlowDeck CI / lint (push) Successful in 2m16s
FlowDeck CI / test (push) Failing after 20m32s
FlowDeck CI / docker (push) Skipped
- panneau de gauche : recherche « Search actions... », section « Text », les
  10 actions dans l'ordre Notion (Turn into, Color, Copy link Alt+⇧+L,
  Duplicate Ctrl+D, Move to Ctrl+⇧+P, Delete Del, Comment Ctrl+⇧+M,
  Suggest edits Ctrl+⇧+Alt+X, Ask AI Ctrl+J, Skills) + pied métadonnées
  (auteur / heure de dernière édition / mots-caractères du bloc)
- panneau de droite au survol (fermeture différée) : Turn into avec ✓ du
  type actuel + Heading 1-4, listes, To-do, Toggle, Code, Quote, Callout,
  Block equation, Synced block, 2/3/4/5 columns (info-bulle « Create N
  columns of blocks ») ; Color avec Last used / Text color / Background
  color (10 couleurs nommées, ✓ courante, carré de couleur) ; Skills =
  compétences réelles de l'espace appliquées au bloc via l'IA
- turnInto(idx,type,cols) : crée N colonnes, sélecteur de source pour les
  blocs synchronisés ; setBlockColor mémorise la dernière couleur (Ctrl+⇧+H)
- nouvelles actions éditeur : commentBlock (sélection + tiroir), askAI
  (composeur sans effacer le bloc), suggestEdit / runSkill (IA, insertion
  sous le bloc), applyLastColor
- raccourcis : handler clavier dédié (Ctrl+J, Ctrl+⇧+H, Ctrl+⇧+M,
  Ctrl+⇧+Alt+X, Alt+⇧+L, Ctrl+⇧+P, Ctrl+D sans double, Del seulement avec le
  menu ouvert, Échap) — les entrées Copy/Cut/Paste du menu sont retirées
  (Ctrl+C/X/V conservés)
- styles : .bm-group, .bm-foot, .bm-check, .bm-current, .bm-dot, sous-menu
  60vh, recherche focalisée à l'ouverture
- tests : tests/test_v761_block_menu.py (9 tests, dont node : colonnes) +
  test_v7593 adapté ; suite complète 1362 passed / 0 failed, ruff 0, eslint 0
- livraison : VERSION + app/main = 7.61.0, OpenAPI 525 chemins, CHANGELOG
2026-10-07 23:09:21 -04:00
bruno f132885b08 feat: bouton + à gauche du handle de bloc — insère un bloc dessous (v7.60.0)
FlowDeck CI / lint (push) Successful in 2m15s
FlowDeck CI / test (push) Failing after 19m27s
FlowDeck CI / docker (push) Skipped
- nouveau bouton .block-insert-btn émis AVANT le handle (6 points) dans le
  wrapper de chaque bloc de premier niveau : clic → E.addAfter(idx) →
  addAt(idx+1) → bloc inséré sous le courant et focusé (mécanisme déjà en
  place, réutilisé tel quel)
- position : left:-64px (gouttière .blocks-container = 64px), -56px dans le
  breakpoint où la gouttière passe à 32px ; apparaît au survol/focus comme le
  handle ; masqué avec lui sur écran étroit (2 breakpoints)
- aria-label + title « Add block below »
- tests : tests/test_v760_block_insert_btn.py (2 tests : ordre dans le
  wrapper, câblage E.addAfter, positions/masquages CSS)
- livraison : VERSION + app/main = 7.60.0, OpenAPI 525 chemins, CHANGELOG
2026-10-07 22:25:27 -04:00
bruno a79bdc1641 fix: « Turn into » du menu contextuel ne fonctionnait pas (v7.59.3)
FlowDeck CI / lint (push) Successful in 2m20s
FlowDeck CI / test (push) Failing after 3h12m11s
FlowDeck CI / docker (push) Skipped
- cause : la garde de turnInto() faisait _BLOCK_TURN_TYPES.indexOf(type) sur
  un tableau d'objets {id,name} → toujours -1 → retour immédiat après
  l'ouverture du sous-menu, aucun changement de format
- correction : validation par id (_BLOCK_TURN_TYPES.some(t=>t.id===type))
- même fonction : convertir un texte en To-do / Toggle vidait le contenu
  (exclusion par erreur) ; seuls les types sans champ texte (image, embed,
  meeting, divider, table_of_contents, database, button, columns) le vident
- tests : tests/test_v7593_turn_into.py (4 tests, dont 1 comportemental node
  qui exécute la vraie turnInto() extraite de la source)
- livraison : VERSION + app/main = 7.59.3, OpenAPI 525 chemins, CHANGELOG
2026-10-07 21:32:22 -04:00
bruno a67bd252aa fix: aide (bouton ✕), Insert below qui perdait le format, listes numérotées invisibles (v7.59.2)
FlowDeck CI / lint (push) Successful in 2m16s
FlowDeck CI / test (push) Failing after 18m25s
FlowDeck CI / docker (push) Skipped
- aide : bouton ✕ en haut à droite du panneau (classe partagée
  settings-close, 44 px mobile) + helpInit.close() (retour en arrière,
  sinon /workspaces) ; .settings-panel en position:relative pour ancrer
  hamburger + ✕ au panneau
- éditeur « Insert below » (/Write with AI) : insère le markdown source
  conservé à la génération (AIC._md) au lieu du texte extrait du HTML rendu
  (_resultText() reste seulement en repli) → titres/listes/gras conservés
- listes numérotées : aucun numéro n'était affiché (CSS = retrait seul) ;
  le rang des blocs numbered_list contigus est calculé au rendu (data-num)
  et rendu via content: attr(data-num) — /numbered list et l'insertion
  agent affichent 1, 2, 3
- md2b : cases à cocher - [ ] / - [x] testées avant la branche des puces
  (aligné sur paste2b) + séparateur 1) accepté (CommonMark)
- menu slash : requête tapée dans le bloc (/numbered list) répercutée dans
  le filtre du menu quand le focus n'a pas rejoint l'entrée
- tests : tests/test_v7592_ui_fixes.py (5 tests, dont 1 comportemental node
  qui exécute le vrai md2b) ; ruff I001 préexistant sur test_agent.py
- livraison : VERSION + app/main = 7.59.2, OpenAPI 525 chemins, CHANGELOG
2026-10-07 20:29:02 -04:00
bruno 39d34ac866 fix(llm): Mistral 403 tier_not_allowed — défauts tous-plans + sonde chat + migration 36 (v7.59.1)
FlowDeck CI / lint (push) Failing after 2m8s
FlowDeck CI / test (push) Failing after 20m4s
FlowDeck CI / docker (push) Skipped
La clé fonctionne: /v1/models 200 + chat 200 (vérifié live). L'échec venait
du modèle: mistral-large-latest (défaut PROVIDERS + tête de liste) n'est pas
servi par les plans d'abonnement basiques → 403 code 1910 « tier_not_allowed »,
et le Test connection sélectionnait systématiquement ce premier candidat.

- PROVIDERS mistral default → mistral-small-latest (tous plans)
- PROVIDER_MODELS réordonnée: tous-plans d'abord (ordre du test de connexion)
- mistral ∈ _CHAT_VALIDATED_PROVIDERS: le fetch modèles ne garde que les
  modèles réellement servis (46 listés → 25 utilisables avec la clé du compte)
- migration 36: default_model bloqué (large/pixtral-large) reset en base

Test live déployé: {ok:true, model:mistral-small-latest, reply:PONG,
verified:true} · tests/test_agent.py 53/53 · nouveau test d'ancrage
test_mistral_defaults_are_tier_safe
2026-10-07 16:25:04 -04:00
bruno efa3d57e93 feat(mobile): side-nav settings + scroll drawer + aide refondue settings-style (v7.59.0)
FlowDeck CI / lint (push) Successful in 2m15s
FlowDeck CI / test (push) Failing after 23m33s
FlowDeck CI / docker (push) Skipped
- Sidebar mobile: display:block du breakpoint ≤768px cassait flex:1 de
  .sidebar-scroll → molette/touch incapables de scroller le drawer. Flex
  restauré. Gate: wheel scroll scrollTop > 0.
- Settings mobile: nav horizontale remplacée par une side-navigation gauche
  drawer (hamburger .settings-menu-btn + backdrop, fermeture auto au choix).
  Squelette panneau extrait vers static/css/settings.css (partagé).
- /help refaite au complet: template help.html (13 sections couvrant toute
  l'app — éditeur, databases/vues, tâches, workspaces, collab, agent IA,
  API v2, PWA, SSO, raccourcis, tips) avec le même panneau 2 colonnes +
  drawer mobile; route réduite à un render; help.js (Alpine.data helpInit).

E2E mobile_regression 6/6 + smoke 2/2 · pytest app+pwa 230/230
2026-10-07 15:36:05 -04:00
bruno 992200a90a fix(mobile): hamburger ouvrait sidebarCollapsed au lieu du drawer + layout settings responsive (v7.58.1)
FlowDeck CI / lint (push) Successful in 2m15s
FlowDeck CI / test (push) Failing after 21m14s
FlowDeck CI / docker (push) Skipped
Le handler du hamburger était une expression Alpine avec un effet de bord
dans un ternaire (sidebarCollapsed ? toggleSidebar() : (mobileSidebarOpen = true, ...)).
Évaluée sans eval dans le build CSP d'Alpine, elle ne produisait aucun effet
visible sur mobile: le bouton ne faisait rien (prouvé via Alpine.evaluate@390px,
open=false). Remplacée par la méthode fdHamburgerClick() (matchMedia 768px:
drawer mobile, sinon toggle desktop).

Settings: panel 1050px + nav verticale 200px inutilisable sur 390px →
@media 768px: plein écran (dvh), nav en onglets horizontaux scrollables,
rows en colonne, prov-grid 1 colonne, close 44px.

+ gate e2e mobile_regression.spec.js (drawer open/close + viewport settings)
2026-10-07 14:27:33 -04:00
bruno 21b96afa12 merge: origin/develop → main (contenu déjà intégré, arbre identique)
FlowDeck CI / lint (push) Successful in 2m16s
FlowDeck CI / test (push) Failing after 18m47s
FlowDeck CI / docker (push) Skipped
Vérification : merge-tree propre, diff de l'arbre fusionné vs main vide —
les 4 fichiers de 0f86294 sont déjà en main. Merge d'alignement de branche.
2026-10-07 12:08:32 -04:00
bruno 16f73fe39e feat: Add plugins — catalogue on/off à effet réel (v7.58.0, phase 8/8)
FlowDeck CI / lint (push) Successful in 2m14s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 2h7m52s
- app/services/plugins.py + migration 35 : table plugins (slug, name,
  description, enabled) pré-remplie avec 3 modules câblés — web-tools,
  web-clipper, automations ; ligne absente = activé (défaut sûr)
- automations OFF → dépendance FastAPI posée à l'include_router dans main.py
  (aucun router touché) → toutes les routes /workspace/automations* refusées +
  garde de tick du scheduler en arrière-plan
- web-clipper OFF → GET /extensions et tout /api/v2/web-clipper/* refusés
- web-tools OFF → web_search et fetch_url retirés du schéma ET de execute()
  via ToolRegistry._all() : le LLM ne les voit plus
- UI rendue côté serveur : global Jinja plugin_enabled(slug) — nav
  « Extensions » / « Automations » en {% if %} (absentes du DOM), sections
  conditionnées en x-show dans settings.html
- menu + : l'entrée « Add plugins » devient vivante (fini disabled:true) —
  liste des 3 plugins avec bascule, GET/PATCH /api/agent/plugins[/slug]
  (slug inconnu → 404, 401 sans session)
- tests : tests/test_v758_plugins.py (10 tests) — routes refusées (302 hors
  /api, 404 JSON pour /api*), outils retirés, nav disparue, persistance,
  câblage ; assertions disabled:true == 0 dans les tests des phases 1/3/4/5/7
- livraison : VERSION + app/main = 7.58.0, OpenAPI 525 chemins, CHANGELOG,
  ROADMAP phase 8 cochée (menu + complet), avenant phase 8 (docs)
2026-10-07 10:19:25 -04:00
bruno 0f86294abc merge: main → develop (sync) 2026-07-20 11:02:42 -04:00
152 changed files with 31850 additions and 2529 deletions
+4
View File
@@ -23,6 +23,10 @@ uv.lock
*.db-*
test-commit.md
upload_test.txt
commit_msg.txt
e2e/node_modules/
e2e/shots/
e2e/test-results/
# Artefacts du navigateur Playwright MCP (logs / snapshots)
.playwright-mcp/
+871 -1513
View File
File diff suppressed because it is too large Load Diff
+1129
View File
File diff suppressed because it is too large Load Diff
+244 -9
View File
@@ -284,7 +284,7 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
---
## v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (phases 1-7 ✅ 2026-10-06 · phase 8 planifiée)
## v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (phases 1-8 ✅ 2026-10-06 — menu + complet)
> **Objectif** : faire du bouton **+** du panneau agent (à gauche de la zone d'édition)
> un menu à sections, tel que demandé :
@@ -496,18 +496,35 @@ CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j.
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.57.0**, OpenAPI régénéré,
CHANGELOG, ce ROADMAP
### Phase 8 — v7.58.0 — Add plugins · effort M
### Phase 8 — v7.58.0 — Add plugins · effort M ✅
- [ ] **Registre `plugins`** (`slug`, `name`, `description`, `enabled`) — état persisté
- [ ] **Catalogue dans le menu +** — bascule on/off à effet **réel** : un plugin
désactivé retire vraiment sa route/UI (au minimum 3 plugins câblés : web tools de
l'agent, web clipper, automations)
- [ ] **Tests** — plugin OFF = route refusée + UI absente (pas seulement un drapeau lu)
- [x] **Registre `plugins`** (`slug`, `name`, `description`, `enabled`) —
migration **35**, table pré-remplie avec les **3 modules** ; ligne absente =
activé (défaut sûr)
- [x] **Catalogue dans le menu +** — l'entrée « Add plugins » devient vivante
(fini le `disabled:true`) : liste des 3 plugins + bascule via
`GET/PATCH /api/agent/plugins[/slug]`
- [x] **Bascule on/off à effet réel** :
- `automations` → **toutes** les routes `/workspace/automations*` refusées
(dépendance FastAPI posée **à l'`include_router`**, aucun router touché) +
scheduler de fond en veille (garde par tick) ;
- `web-clipper` → `GET /extensions` + `/api/v2/web-clipper/*` refusés ;
- `web-tools` → `web_search` / `fetch_url` retirés du **schéma** et de
`execute()` (`ToolRegistry._all()`) ;
- UI → nav « Extensions » / « Automations » rendues sous
`{% if plugin_enabled(...) %}` (global Jinja, élément **absent** du DOM),
sections conditionnées en `x-show`
- [x] **Tests** — `tests/test_v758_plugins.py` : **10 tests** — plugin OFF =
route refusée (302 hors `/api`, 404 JSON pour `/api*`) + UI absente + outil
retiré, persistance, 401 sans session
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.58.0**, OpenAPI régénéré
(525 chemins), CHANGELOG, ce ROADMAP
---
*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 7 livrées le
2026-10-06 (v7.51.0 → v7.57.0)**, phase 8 (plugins) à valider avant « go ».
*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 8 livrées le
2026-10-06 (v7.51.0 → v7.58.0) : le menu + est complet** (aucune section
« bientôt »). Toute extension ultérieure = nouvelle phase à valider.
Chaque phase se clôt par tests verts, bump de version, CHANGELOG et ROADMAP à jour.*
---
@@ -1391,6 +1408,224 @@ Détails livrés :
- [x] **Tests follow-ups** — `tests/test_v73_wiki_polish.py` (58 → **72 tests**) : sidebar cross-workspace, page teamspace (owner 200 / outsider 404 API), auto-follow comment, notif throttlée, unfurl gitea/github + endpoint, KPI + dashboards multi-DB
- [x] **Suite complète** — `python -m pytest -n auto` → **1016 passed** (était 981 passed / 21 failed en SSO)
### v7.62.0 — Menu contextuel au highlight de texte ✅ (2026-10-08)
> **Objectif** : menu façon Notion (mode sombre) au surlignage de texte dans l'éditeur. **COMPLETED**.
- [x] **Toolbar de sélection 4 lignes** — `T` + type de bloc dynamique + `›` (sous-menu Turn into existant) / `A` `B` `I` `U` `T` barré `Tx` / 🔗 `S` `</>` `√x` `…` / 💬 `✨`, section grise **Skills** (Improve writing, Proofread, Explain, Reformat → `runSkill`) et pied **Edit with AI** `Alt+⇧+E` — affichée **sous** la sélection (au-dessus si pas de place), clampée au viewport, fermeture au clic hors sélection
- [x] **`…` → menu contextuel de bloc** déjà existant (recherche « Search actions… », groupe Text, 10 actions + raccourcis, pied métadonnées auteur/heure/taille) — pas de doublon de menu
- [x] **Persistance du formatage inline** — sérialiseur `gtMd()` (DOM → source markdown) branché sur `sync()` ; `mdEsc()` rend `__…__`, `==…==`, `$$…$$` (KaTeX inline) — avant, `sync()` relisait du texte brut et perdait tout le formatage au prochain rendu
- [x] **Info-bulle claire à droite** — `data-tooltip-html` + `.tooltip-light` dans le tooltip global (`app.js`)
- [x] **Fix `askAI(idx)` écrasé par `askAI()`** — « Ask AI » / `Ctrl+J` ouvraient l'IA de page → `askAIBlock(idx)`
- [x] **Fix « toolbar muette sous Alpine v3 »** — `ed.__x` (API v2) n'existe plus : `showFmt`/`hideCommentBtn`/mentions ne s'exécutaient jamais → accès via `_xed()` (`_x_dataStack[0]`) ; ancre de sélection élément gérée ; `checkMd()` recevait un id au lieu d'un index (exception à chaque espace)
- [x] **Tests** — `tests/test_v762_selection_toolbar.py` (roundtrip `gtMd`/`mdEsc` réel sous node) + **e2e Playwright** `e2e/v762_selection_toolbar.spec.js` (structure, sous-menus, géométrie, persistance bold/highlight après reload) ; `VERSION` + `app/main.py` = 7.62.0
### v7.63.0 — Menus mobiles façon Notion ✅ (2026-10-08)
> **Objectif** : parité des menus mobiles (Android sombre) — barre contextuelle au
> « / » + menu « Insert block » plein écran. **COMPLETED**.
- [x] **Barre horizontale défilante** sous le curseur (mobile ≤ 768 px) — 15 boutons
dans l'ordre Notion : `+▾`, `Turn into▾`, `@`, 💬, 🖼, 🗑, `←→↑↓`, `↩↪`, `Color▾`,
`More▾`, `⌨` ; `overflow-x:auto`, fermeture auto quand le `/` disparaît, taps
exclus du handler « clic hors-zone »
- [x] **Feuille plein écran « Insert block »** (`+`) — titre centré + « Cancel » bleu,
groupes CMDS avec raccourcis markdown (items partagés avec la popup desktop) ;
`Turn into` / `Color` réutilisent les générateurs extraits (`_blockTurnItems`,
`_blockColorItems`) ; `More` ouvre le menu de bloc existant
- [x] **`moveBlock(bid, dir)`** — ↑↓ ordre (racine + enfants toggle/columns),
→ indent dans le frère précédent, ← outdent, garde-fous toast
- [x] **Raccourcis slash** alignés sur les préfixes markdown (`-`, `[]`, `>`, `---`)
- [x] **Tests** — `tests/test_v763_mobile_menus.py` (+ `moveBlock` exécuté sous node),
e2e mobile `e2e/v763_mobile_menus.spec.js` (390×844) ; `VERSION` + `app/main.py`
= 7.63.0
### v7.64.0 — Commentaires & réactions façon Notion ✅ (2026-10-08)
> **Objectif** : affichage/prise de commentaire identique à Notion + réaction emoji
> sur le texte sélectionné. **COMPLETED**.
- [x] **Réaction sur texte** — bouton 🙂+ (« React to selected text ») dans la
toolbar de sélection → sélecteur d'émoji **existant** en mode réaction (en-tête =
texte sélectionné + ✋, onglets masqués, Recent au-dessus de People, Filter…,
catégories + `+` custom) ; plage jaune + pastille (emoji + compte), clic pastille
= rouvrir le sélecteur pour la plage
- [x] **Backend** — migration 37 `text_reactions` (UNIQUE plage × emoji × user) +
`GET/POST /api/pages/{id}/reactions` (GET groupé, POST toggle) ; `gtTok`/`gtMd`
ignorent les pastilles et `_srcOffset` exclut les annotations des offsets → le
texte sauvé est identique
- [x] **Ancres jaunes** — `_paintAnchors()` après chaque `render()`/chargement ;
clic sur l'ancre → tiroir ouvert + fil centré (flash) ; résolu = gris
- [x] **Tiroir refondu** — carte de fil (avatar, nom, horodatage, ✓ toggle
resolve/reopen, ⋯ → Copy link `#cmt-<id>` / Delete), saisie « Add a comment... »
avec `@` et envoi `↑`/Entrée ; `resolveComment(c)` corrige l'impossibilité de
rouvrir
- [x] **Tests** — `tests/test_v764_comments_reactions.py` (8, dont toggle API réel),
e2e `e2e/v764_comments_reactions.spec.js` ; `VERSION` + `app/main.py` = 7.64.0
- [x] **v7.64.1 — Fix Delete sur bloc vide** : la garde « texte vide → supprimer
le bloc » couvre `Backspace` **et** `Delete` (une seule branche dans `onKd`),
vide jugé via `gtTok()` (ignore les pastilles) ; e2e
`e2e/v7641_delete_key.spec.js`
### v7.65.0 — Comment ancré sur la sélection de texte ✅ (2026-10-08)
> **Objectif** : le commentaire porte sur le texte sélectionné dans le bloc,
> pas sur le bloc entier, et le bouton bleu flottant disparaît. **COMPLETED**.
- [x] **Ancrage sur la plage** — `commentBlock()` prend la sélection vivante dans
le bloc (repli : la plage mémorisée par la toolbar, qui survit au clic du
bouton), sinon tout le bloc ; offsets calculés par `_srcOffset()` →
`anchor_start/anchor_end` exacts dans le texte source
- [x] **Bouton bleu « 💬 Comment » retiré** — `#_commentSelBtn`,
`showCommentBtn()`, `hideCommentBtn()` et leur listener `mousedown` supprimés ;
déclencheurs restants = 💬 de la toolbar de sélection, menu de bloc,
Ctrl+⇧+M
- [x] **Tiroir** — fermer remet `_commentSel` à zéro (pas d'ancrage périmé)
- [x] **Tests** — e2e `e2e/v764_comments_reactions.spec.js` : sous-sélection
`reaction a un` → `.fd-anchor[data-cid]` = la plage seule, avant/après
reload, `#_commentSelBtn` absent ; `pytest` 1383 passed, `ruff` OK ;
`VERSION` + `app/main.py` = 7.65.0
### v7.66.0 — Escape du menu slash + bouton commenter à droite ✅ (2026-10-08)
> **Objectif** : Esc efface le `/` qui a ouvert le menu slash, et chaque bloc
> gagne un bouton de commentaire à droite. **COMPLETED**.
- [x] **`SM.escClose()`** — un seul implément, appelé par les deux chemins
(focus dans le bloc → `onKd`, focus dans l'entrée du menu → listener
`document`) ; retire le `/` d'ouverture seulement, remet le caret, `sync` +
`autoSave`
- [x] **Bouton à droite du bloc** — `.block-comment-btn` (bulle + `+`,
`E.commentBlock(idx)`), gouttière 64px, visible au survol, masqué mobile +
page verrouillée ; remplace l'ancien `.block-actions` mort (opacity:0 sans
survol → `+` invisible qui capait les clics)
- [x] **`fmtComment()`** restaure la plage de la toolbar avant d'ancrer ;
`commentBlock()` ne se replie plus sur `this._sel` périmé
- [x] **Tests** — e2e `e2e/v766_slash_esc_comment_btn.spec.js` (Esc → menu
fermé + bloc vide ; bouton à droite mesuré, ancre = bloc entier),
`tests/test_v766_slash_esc_comment_btn.py` (3) ; `pytest` **1386 passed**,
`ruff` OK ; `VERSION` + `app/main.py` = 7.66.0
### v7.67.0 — Réactions dans le tiroir + suppression + refonte du menu de sélection ✅ (2026-10-08)
> **Objectif** : une réaction s'ajoute/supprime depuis le tiroir Comments, et
> le menu de sélection correspond à la maquette ASCII fournie. **COMPLETED**.
- [x] **Section « Reactions » du tiroir** — `reactionList()` + `_anchorText()`
(texte de la plage lu dans le DOM, hors pastilles), compte si >1, état vide
adapté ; `removeReaction()` = POST toggle
- [x] **Suppression** — ✕ dans le tiroir (mes réactions seulement) **et**
croix `::after` au survol de la pastille sur l'émoji que j'ai posé (aucun
nœud ajouté → offsets source intacts)
- [x] **Refonte du menu** — ligne 4 scannée `💬 Comment | 🙂 réaction`,
réaction sortie de la ligne 3, entête `Skills ≡`, liste Skills scrollable,
`✨ Ask AI` retiré (doublon exact du pied)
- écart volontaire à la maquette : le **barré (T)** de la ligne 2 est
conservé (il n'y figure pas) pour ne pas perdre la fonction
- [x] **Flake de gate corrigé** — `_is_public_host` attrape `OSError` (le
garde SSRF ne lève plus) + fixture `stub_dns` sur `TestOGParser`
(le test faisait un vrai `getaddrinfo("example.com")`, échec sous `-n auto`)
- [x] **Tests** — `tests/test_v767_reactions_drawer.py` (3), e2e v764 étendu
(réactions dans le tiroir + suppression A/B), e2e v762 (nouvelle ligne 4) ;
`pytest` **1390 passed / 0 failed**, `ruff` OK ; `VERSION` +
`app/main.py` = 7.67.0
### v7.68.0 — Colonnes rendues, tables IA, copier/couper/coller multi-blocs ✅ (2026-10-08)
> **Objectif** : les colonnes (slash + Turn into) marchent, l'IA crée de vrais
> tableaux, et une sélection multi-blocs se copie/coupe/colle. **COMPLETED**.
- [x] **Renderers morts branchés** — `renderBlock()` n'appelait jamais
`renderColumnsBlock()` / `renderMathBlock()` / `renderTOC()` → bloc colonnes,
équation et TOC rendaient un paragraphe vide (bug rapporté) ; × de
suppression de colonne visible au survol (était invisible mais cliquable)
- [x] **Tables IA** — `_tblIsData`/`_tblIsSep` acceptent le GFM **sans pipe de
tête** (séparateur ≥2 cellules pour ne pas avaler « texte » + « --- ») ;
`md2b` et `paste2b` alignés d'office
- [x] **Coller** un seul bloc structuré (table/code/titre) = vraie insertion ;
« Copy link to block » réparé (`link` → `copy-link`)
- [x] **Copier/couper multi-blocs** — `_clipState()`/`_clipMarkdown()`/
`clipCopy()`/`clipCut()`, listeners `copy`/`cut` (Ctrl+C/X), groupe
**Clipboard** dans le menu du clic droit (sélection figée à l'ouverture),
clic droit sur un bloc sélectionné = sélection multi-blocs conservée
- [x] **Tests** — `tests/test_v768_columns_clipboard.py` (4), e2e
`v768_columns_clipboard.spec.js` (4) ; `pytest` **1394 passed / 0 failed**,
`ruff` OK ; `VERSION` + `app/main.py` = 7.68.0
### v7.69.0 — La sélection multi-blocs fonctionne enfin ✅ (2026-10-08)
**Contexte** : v7.68.0 livrait la mécanique Clipboard (plage multi-blocs, handlers
`copy`/`cut`, groupe Clipboard du menu contextuel) mais elle était **inutilisable en
pratique** — trois rapports d'usage : Ctrl+C ne copie rien, le glisser souris ne
sélectionne qu'un bloc, le clic droit efface le surlignage.
**Causes racines trouvées (trace d'événements à l'appui, pas de supposition)** :
| Symptôme | Cause réelle |
|----------|--------------|
| Ctrl+C/X muet | le navigateur **n'émet aucun événement `copy`** quand la sélection traverse deux `contenteditable` : `getSelection().toString()` renvoie `""`, donc nos handlers n'étaient jamais atteints |
| Glisser = 1 bloc | séquence `mouseup` → `click` → `focusin` → **collapse** : la plage reconstruite mourait dans la foulée |
| Clic droit efface | `.bm-search` prenait le focus 10 ms après l'ouverture du menu |
**Corrections** : interception du raccourci au `keydown` (capture `document`,
garde `closest('#_blocksCt')`) ; plage reconstruite mise en attente
(`_mdSelRange`) et restaurée au tour suivant, remise à zéro à chaque nouveau
geste ; le menu ne vole plus le focus quand un texte est sélectionné ;
`preventDefault` du clic droit remonté sur `mousedown` (sur `contextmenu` c'est
trop tard).
**Identité visuelle** : bannière + logo intégrés — WebP 22,5 Ko / 5,3 Ko (les
originaux faisaient 1,6 Mo / 1,1 Mo), JPEG de secours, icônes PWA régénérées à
partir du vrai logo, `<picture>` sur la landing, `og:image`/`twitter:card`.
**Vérification** : `tests/test_v769_selection_persistence.py` (19 statiques) +
`e2e/v769_selection_persistence.spec.js` (5, **vrais gestes**) ; le spec v768 a
été corrigé pour remplacer `execCommand` et les plages fabriquées par de vrais
gestes.
**Piège de test relevé au passage** : les `···` dans les échecs Playwright sont
le marqueur de troncature du message d'erreur, **pas** un bug produit — une heure
passée à chercher une fuite inexistante. Vérifier `JSON.stringify()` de la valeur
réelle avant de croire un affichage tronqué.
---
### v7.69.3 — Aide alignée sur Settings ✅ · double-clic close ⏳ ouvert (2026-10-08)
**Livré** : la section Aide partage maintenant le positionnement exact de la
section Settings. `help.html` neutralisait son overlay en style inline
(`position:static`, fond transparent, sans blur, sans padding) et forçait un
panneau `1050px / 100vh-120px` — les deux pages pourtant identiques au CSS
près produisaient deux rendus. Surcharges retirées ; e2e compare les
géométries calculées : **identiques des deux côtés** (overlay `fixed` avec
fond, panneau 1050×765 centré, close à 13/13 px du coin).
**Terminé en v7.69.4** — double-clic sur le bouton close de Settings.
Le diagnostic posé en v7.69.3 était **FAUX** : je pensais qu'Alpine ne liait
pas le composant (`_x_dataStack` absent), donc que le handler était inerte.
La question à l'utilisateur a tranché : *« après le premier clic la page est
réactive »* → Alpine lie bien le composant, le handler s'exécute. Le scénario
`target="_blank"` qui m'avait donné `_x_dataStack` absent était un artefact de
mon propre banc d'essai, pas le parcours réel. **Ne pas relire ce diagnostic.**
Cause réelle : `closeSettings()` faisait `history.back()` puis
`window.location.reload()` après un délai **fixe de 100 ms**. Entre les deux,
le retour est une navigation asynchrone ; quand elle n'est pas terminée en
100 ms, le `reload()` retombe sur `/settings` — le panneau « se ferme puis
revient ». Depuis `/workspaces`, la profondeur d'historique diffère et le
retour arrive à temps : d'où l'absence de symptôme sur ce seul parcours.
Correctif : `location.replace()` vers la page d'origine. Une seule navigation
déterministe, ni `history.back()`, ni `popstate`, ni rechargement différé.
Effet secondaire assumé et souhaitable : « Retour » ne rouvre plus Settings.
**Preuve avant/après (contrôle négatif fait)** : même spec, deux builds.
- ancien `closeSettings()` → le test du parcours menu-utilisateur **ÉCHOUE**
(`Received: "…/settings"`), celui de `/workspaces` passe ;
- correctif → **les deux passent**.
**Piège relevé** : ma « réfutation » de la thèse de course était fausse. J'avais
interposé 400 ms via `page.route()`, qui intercepte les requêtes de sous-ressources
— pas la navigation du document lors d'un `history.back()`, servie depuis le
bfcache. Le délai n'a donc jamais été appliqué. Un contrôle négatif (rebuild
avec l'ancien code) aurait tranché en un tour ; il a fallu plusieurs tours pour
le faire.
---
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
+1 -1
View File
@@ -1 +1 @@
7.57.0
7.71.5
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.50.2 (peek de Library nu — iframe pleine, comme local-workspace) | **Statut**: EN COURS 🔄
> **Début**: 2026-07-08 | **Version**: v7.69.4 (sélection multi-blocs fonctionnelle ; identité visuelle logo/bannière/favicon ; Aide alignée sur Settings ; close Settings réparé)
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
## Avancement Global
+8
View File
@@ -147,6 +147,14 @@ class Settings(BaseSettings):
github_token: str = "" # PAT GitHub : 30 req/min au lieu de 10
web_fetch_max_chars: int = 20000 # texte renvoyé par fetch_url
# ── Vues Notion : géocodage + plafonds (§8.2, §13.4, §20) ──
GEOCODER_PROVIDER: str = "none" # none | nominatim | custom
GEOCODER_ENDPOINT: str = "https://nominatim.openstreetmap.org"
VIEW_ROWS_MAX: int = 1000
MAP_POINTS_MAX: int = 100
TIMELINE_LOAD_MAX: int = 200
CHART_MAX_SUBGROUPS: int = 50
@property
def db_path(self) -> Path:
if self.database_url == "sqlite:///:memory:":
+11
View File
@@ -833,6 +833,17 @@ def init_db():
from app.migrations import apply_migrations
apply_migrations(conn)
# ── Templates unifiés (§9.5, §12) : backfill idempotent des silos
# legacy + presets système. Non bloquant : un échec laisse les silos
# legacy servir leurs anciennes surfaces (dégradation, pas panne).
try:
from app.services.fd_templates import ensure_registry, reset_registry_cache
reset_registry_cache()
ensure_registry(conn)
except Exception:
import logging as _logging
_logging.getLogger(__name__).exception("ensure_registry templates")
# Webhook subscriptions (v2.1.0) — created here (idempotent) so the full
# schema exists without depending on the FastAPI lifespan startup.
from app.services.webhook_outbound import init_webhook_tables
+19 -6
View File
@@ -5,7 +5,7 @@ import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi import Depends, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as _StarHTTPException
@@ -44,6 +44,7 @@ from app.routers.audit import router as audit_router
from app.routers.automations import router as automations_router
from app.routers.collaboration import router as collaboration_router
from app.routers.emoji import router as emoji_router
from app.routers.fd_templates import router as fd_templates_router
from app.routers.gitea import router as gitea_router
from app.routers.github_routes import router as github_router
from app.routers.governance import router as governance_router
@@ -62,6 +63,7 @@ from app.routers.web_clipper import router as web_clipper_router
from app.routers.webauthn import router as webauthn_router
from app.routers.wiki import router as wiki_router
from app.routers.workers import router as workers_router
from app.services import plugins as plugins_service
from app.services.webhook_outbound import init_webhook_tables
logging.basicConfig(
@@ -153,6 +155,10 @@ async def lifespan(_app: FastAPI):
from app.services.reminders import reminder_scheduler
reminder_task = _spawn("reminder_scheduler", reminder_scheduler)
# ── Templates récurrents (§11.2) : scan 60 s dédupliqué ──
from app.services.fd_templates import template_recurrence_scheduler
template_rec_task = _spawn("template_recurrence_scheduler", template_recurrence_scheduler)
# ── Semantic search (v6.9.0): incremental vector indexing ──
from app.services.semantic_search import semantic_index_scheduler
semantic_task = _spawn("semantic_index_scheduler", semantic_index_scheduler)
@@ -171,7 +177,7 @@ async def lifespan(_app: FastAPI):
try:
yield
finally:
_tasks = (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task, semantic_task, calendar_task)
_tasks = (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task, template_rec_task, semantic_task, calendar_task)
if webhook_task is not None:
_tasks = _tasks + (webhook_task,)
for task in _tasks:
@@ -185,7 +191,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.57.0",
version="7.71.5",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
@@ -235,7 +241,9 @@ app.include_router(sharing.router)
app.include_router(sidebar_config.router)
app.include_router(export.router)
app.include_router(notifications_router)
app.include_router(automations_router)
# Plugin « automations » OFF → chaque route de ce router renvoie 404
app.include_router(automations_router,
dependencies=[Depends(plugins_service.plugin_required("automations"))])
app.include_router(collaboration_router)
app.include_router(emoji_router)
app.include_router(realtime_router)
@@ -247,8 +255,11 @@ app.include_router(sync.router)
app.include_router(imports_router)
app.include_router(import_page_router)
app.include_router(permissions_router)
app.include_router(web_clipper_api_router)
app.include_router(web_clipper_router)
# Plugin « web-clipper » OFF → page /extensions + API clip refusées
app.include_router(web_clipper_api_router,
dependencies=[Depends(plugins_service.plugin_required("web-clipper"))])
app.include_router(web_clipper_router,
dependencies=[Depends(plugins_service.plugin_required("web-clipper"))])
app.include_router(api_v2_router)
app.include_router(api_v2_agent_router)
app.include_router(sites_router)
@@ -262,6 +273,8 @@ app.include_router(audit_router)
app.include_router(governance_router)
# v7.3.0 — teamspaces + verified wiki
app.include_router(wiki_router)
# Templates unifiés façon Notion (registre + gestionnaire + API interne)
app.include_router(fd_templates_router)
app.mount("/static", StaticFiles(directory="static"), name="static")
+46 -8
View File
@@ -20,16 +20,54 @@ ALLOWED_EXTENSIONS: frozenset[str] = frozenset({
# Images
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico",
# Documents
".pdf", ".md", ".markdown", ".txt", ".log", ".csv",
# Code
".py", ".js", ".jsx", ".ts", ".tsx", ".html", ".htm", ".xml", ".css",
".json", ".yaml", ".yml", ".toml", ".sql", ".sh", ".bash", ".zsh",
".ps1", ".rs", ".go", ".java", ".rb", ".php", ".c", ".cpp", ".h",
".swift", ".kt", ".scala", ".r",
".pdf", ".md", ".markdown", ".mkd", ".mdown", ".mdwn", ".rmd", ".qmd",
".txt", ".log", ".csv", ".tsv", ".tab", ".nfo", ".asc",
".tex", ".latex", ".sty", ".cls", ".dtx", ".bib", ".bbl",
# Code — kept in sync with app/services/export.py::_TEXTUAL_EXTS
".py", ".pyw", ".pyi", ".gyp", ".gypi",
".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx",
".html", ".htm", ".xhtml", ".xht", ".xml", ".xsd", ".xsl", ".xslt",
".rss", ".atom", ".plist", ".wsf", ".axml",
".vue", ".svelte", ".astro",
".css", ".scss", ".sass", ".less",
".json", ".json5", ".jsonc", ".geojson", ".webmanifest",
".har", ".avsc",
".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".env",
".properties", ".prop", ".inf", ".reg", ".url",
".gitconfig", ".gitmodules", ".editorconfig",
".sql", ".pgsql", ".psql", ".mysql", ".mssql",
".sh", ".bash", ".zsh", ".ksh", ".mksh", ".yash", ".fish",
".ps1", ".psm1", ".psd1", ".bat", ".cmd",
".rb", ".gemspec", ".rake", ".rbw",
".rs", ".go", ".java", ".jsp", ".kt", ".kts",
".scala", ".sc", ".groovy", ".gvy", ".gradle",
".c", ".cc", ".cpp", ".c++", ".cxx", ".hh", ".hxx", ".hcc", ".h", ".hpp",
".cs", ".csx", ".cake", ".ino", ".pde",
".php", ".phtml", ".swift", ".r", ".jl", ".lua", ".dart",
".pl", ".pm", ".pod",
".hs", ".lhs", ".ex", ".exs", ".eex", ".heex", ".leex",
".erl", ".hrl", ".escript",
".clj", ".cljs", ".cljc", ".edn",
".ml", ".mli", ".fs", ".fsi", ".fsx", ".fsscript",
".nim", ".nims", ".d", ".di",
".m", ".mm", ".vb", ".vbs", ".bas", ".pas", ".pp", ".dpr",
".vhd", ".vhdl", ".v", ".vh", ".sv", ".svh",
".asm", ".s", ".nasm", ".inc",
".ada", ".ads", ".adb",
".f", ".for", ".f90", ".f95", ".f03", ".f08",
".cob", ".cbl", ".cpy",
".dockerfile", ".containerfile",
".tf", ".tfvars", ".hcl",
".cmake", ".makefile", ".mk", ".mak", ".ninja",
".gql", ".graphql", ".graphqls", ".proto", ".thrift",
".vim", ".vimrc", ".gvimrc", ".awk",
".gcode", ".gco", ".nc", ".ngc",
".http", ".rest", ".nginx", ".apache", ".htaccess", ".wat",
".diff", ".patch", ".rej",
".mod", ".sum", ".lock", ".text",
".gitignore", ".gitattributes", ".gitkeep", ".dockerignore", ".npmignore",
# Archives
".zip", ".tar", ".gz", ".rar", ".7z",
# Misc
".env", ".cfg", ".conf", ".ini", ".dockerfile", ".makefile",
})
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
+371
View File
@@ -1586,6 +1586,30 @@ def _migration_my_tasks_mapping(conn: sqlite3.Connection) -> None:
)
@register(35, "v7.58.0: registre des plugins (on/off à effet réel)")
def _migration_plugins(conn: sqlite3.Connection) -> None:
"""Catalogue de l'instance : 3 modules câblés (routes/UI/outils réels)."""
conn.execute(
"""CREATE TABLE IF NOT EXISTS plugins (
slug TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1
)"""
)
conn.executemany(
"INSERT OR IGNORE INTO plugins (slug, name, description, enabled) VALUES (?,?,?,1)",
[
("web-tools", "Outils web de l'agent",
"Retire web_search et fetch_url du registre d'outils de l'agent"),
("web-clipper", "Web Clipper",
"Coupe la page /extensions et l'API /api/v2/web-clipper/*"),
("automations", "Automations",
"Coupe /workspace/automations* et le scheduler en arrière-plan"),
],
)
@register(34, "v7.57.0: connecteurs — kind/auth/tools_json (Discord, Telegram, MCP)")
def _migration_connector_kinds(conn: sqlite3.Connection) -> None:
"""Discord (auth « Bot »), Telegram (jeton dans l'URL via `{secret}`) et
@@ -1654,3 +1678,350 @@ def _migration_agent_memory(conn: sqlite3.Connection) -> None:
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)"""
)
@register(37, "v7.64.0: réactions emoji sur un texte sélectionné")
def _migration_text_reactions(conn: sqlite3.Connection) -> None:
"""Réactions emoji ancrées sur une plage de texte (façon Notion) : miroir des
ancres de commentaires (block_id + offsets dans le texte rendu). Une ligne par
(plage, emoji, utilisateur) → le POST agit comme un toggle et le GET regroupe
par plage pour afficher la pastille d'émojis."""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS text_reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
block_id TEXT NOT NULL,
anchor_start INTEGER NOT NULL,
anchor_end INTEGER NOT NULL,
emoji TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(page_id, block_id, anchor_start, anchor_end, emoji, user_id)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_text_reactions_page ON text_reactions(page_id)"
)
@register(36, "v7.59.1: mistral — modèles par défaut hors plan basique")
def _migration_mistral_default_model(conn: sqlite3.Connection) -> None:
"""mistral-large-latest / pixtral-large-latest renvoient 403
« tier_not_allowed » sur les plans d'abonnement basiques : la clé est
valide mais le test de connexion et l'agent échouaient. Reset du
default_model stocké → vide = nouveau défaut du provider (small-latest,
servi par tous les plans).
"""
blocked = ("mistral-large-latest", "pixtral-large-latest")
conn.execute(
"UPDATE user_llm_keys SET default_model='' "
"WHERE provider='mistral' AND default_model IN (?,?)",
blocked,
)
conn.execute(
"UPDATE llm_config SET model='mistral-small-latest' "
"WHERE provider='mistral' AND model IN (?,?)",
blocked,
)
@register(38, "v7.1.1: meeting block Notion — états, segments, consentement, partage")
def _migration_meeting_notion_block(conn: sqlite3.Connection) -> None:
"""v7.1.1 — refonte Meetings façon Notion (§3A/3B du doc d'architecture).
``meeting_transcripts`` gagne la machine à états du bloc
(idle/recording/paused/processing/done/failed), les segments groupés par
source, la photo d'instructions, le journal de consentement résumé, les
étapes Thinking persistées, le résumé structuré + titre généré + classe de
complétude. Deux tables append-only : consentement et diffusions.
Règle d'unicité : une occurrence calendrier = au plus une note.
"""
cols = columns(conn, "meeting_transcripts")
for ddl in (
"ALTER TABLE meeting_transcripts ADD COLUMN status TEXT NOT NULL DEFAULT 'idle'",
"ALTER TABLE meeting_transcripts ADD COLUMN mode TEXT NOT NULL DEFAULT 'mic_only'",
"ALTER TABLE meeting_transcripts ADD COLUMN channels_json TEXT NOT NULL DEFAULT '[]'",
"ALTER TABLE meeting_transcripts ADD COLUMN instruction_id TEXT NOT NULL DEFAULT 'auto'",
"ALTER TABLE meeting_transcripts ADD COLUMN instruction_snapshot TEXT NOT NULL DEFAULT ''",
"ALTER TABLE meeting_transcripts ADD COLUMN consent_json TEXT NOT NULL DEFAULT ''",
"ALTER TABLE meeting_transcripts ADD COLUMN segments_json TEXT NOT NULL DEFAULT '[]'",
"ALTER TABLE meeting_transcripts ADD COLUMN processing_steps_json TEXT NOT NULL DEFAULT '[]'",
"ALTER TABLE meeting_transcripts ADD COLUMN summary_json TEXT NOT NULL DEFAULT ''",
"ALTER TABLE meeting_transcripts ADD COLUMN generated_title TEXT NOT NULL DEFAULT ''",
"ALTER TABLE meeting_transcripts ADD COLUMN title_source TEXT NOT NULL DEFAULT 'user'",
"ALTER TABLE meeting_transcripts ADD COLUMN completeness_class TEXT NOT NULL DEFAULT ''",
"ALTER TABLE meeting_transcripts ADD COLUMN quality_score REAL",
"ALTER TABLE meeting_transcripts ADD COLUMN duration_ms INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE meeting_transcripts ADD COLUMN paused_ms INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE meeting_transcripts ADD COLUMN started_at TIMESTAMP",
"ALTER TABLE meeting_transcripts ADD COLUMN stopped_at TIMESTAMP",
"ALTER TABLE meeting_transcripts ADD COLUMN event_occurrence_id TEXT NOT NULL DEFAULT ''",
"ALTER TABLE meeting_transcripts ADD COLUMN notes_text TEXT NOT NULL DEFAULT ''",
"ALTER TABLE meeting_transcripts ADD COLUMN banner_dismissed INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE meeting_transcripts ADD COLUMN share_bar_dismissed INTEGER NOT NULL DEFAULT 0",
):
name = ddl.split("ADD COLUMN")[1].strip().split()[0]
if name not in cols:
conn.execute(ddl)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS meeting_consent_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transcript_id INTEGER NOT NULL REFERENCES meeting_transcripts(id) ON DELETE CASCADE,
method TEXT NOT NULL DEFAULT 'start_attestation',
message_ref TEXT NOT NULL DEFAULT '',
attested_by INTEGER REFERENCES users(id),
participants_json TEXT NOT NULL DEFAULT '[]',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS meeting_distributions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transcript_id INTEGER NOT NULL REFERENCES meeting_transcripts(id) ON DELETE CASCADE,
channel TEXT NOT NULL DEFAULT 'copy_link',
actor_id INTEGER REFERENCES users(id),
recipients_json TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'created',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_meeting_consent_tr ON meeting_consent_log(transcript_id)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_meeting_distr_tr ON meeting_distributions(transcript_id)"
)
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS ux_meeting_one_per_occurrence "
"ON meeting_transcripts(event_occurrence_id) "
"WHERE event_occurrence_id <> ''"
)
# ── Vues Notion §10 : migrations 44 à 47 (après 39-43 réservées Agents&Skills) ──
@register(44, "vues-notion: view_config_v2 (config_version + index view_type)")
def _migration_view_config_v2(conn: sqlite3.Connection) -> None:
"""Migration 44 — versionnage des config_json de vues (phase 1, Chart)."""
cols = columns(conn, "collection_views")
if "config_version" not in cols:
conn.execute(
"ALTER TABLE collection_views ADD COLUMN config_version INTEGER NOT NULL DEFAULT 1"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_collection_views_type "
"ON collection_views(collection_id, view_type)"
)
@register(45, "vues-notion: place_geocode_cache")
def _migration_place_geocode_cache(conn: sqlite3.Connection) -> None:
"""Migration 45 — cache partage du Geocoding Service (phase 3, Map)."""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS geocode_cache (
query_hash TEXT PRIMARY KEY,
provider TEXT NOT NULL DEFAULT 'nominatim',
query_text TEXT NOT NULL DEFAULT '',
result_json TEXT NOT NULL DEFAULT '{}',
lat REAL,
lng REAL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
hit_count INTEGER NOT NULL DEFAULT 0
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_geocode_cache_created ON geocode_cache(created_at)"
)
@register(46, "vues-notion: dashboard_widgets")
def _migration_dashboard_widgets(conn: sqlite3.Connection) -> None:
"""Migration 46 — widgets de la vue Dashboard (phase 4)."""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS dashboard_widgets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
view_id INTEGER NOT NULL REFERENCES collection_views(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
row_index INTEGER NOT NULL DEFAULT 0,
col_span INTEGER NOT NULL DEFAULT 1,
height_units INTEGER NOT NULL DEFAULT 2,
source_collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL,
source_view_id INTEGER REFERENCES collection_views(id) ON DELETE SET NULL,
inline_config_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_view "
"ON dashboard_widgets(view_id, row_index, position)"
)
@register(47, "vues-notion: form_questions_v2")
def _migration_form_questions_v2(conn: sqlite3.Connection) -> None:
"""Migration 47 — questions du Form builder + repondant (phase 5)."""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS form_questions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
view_id INTEGER NOT NULL REFERENCES collection_views(id) ON DELETE CASCADE,
property_name TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
label_override TEXT,
sync_label INTEGER NOT NULL DEFAULT 1,
help_text TEXT NOT NULL DEFAULT '',
required INTEGER NOT NULL DEFAULT 0,
widget TEXT NOT NULL DEFAULT 'auto',
max_selections INTEGER,
conditional_json TEXT,
UNIQUE(view_id, property_name)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_form_questions_view "
"ON form_questions(view_id, position)"
)
for table, ddl, col in (
("form_responses", "ALTER TABLE form_responses ADD COLUMN view_id INTEGER", "view_id"),
("form_responses", "ALTER TABLE form_responses ADD COLUMN respondent_user_id INTEGER REFERENCES users(id)", "respondent_user_id"),
("form_responses", "ALTER TABLE form_responses ADD COLUMN anonymous INTEGER NOT NULL DEFAULT 0", "anonymous"),
):
try:
existing = columns(conn, table)
except Exception:
continue
if col not in existing:
try:
conn.execute(ddl)
except Exception:
pass
# ── Templates Notion §9 : migrations 48 à 49 (registre unifié + journal) ──
@register(48, "templates-notion: registre unifié `templates`")
def _migration_templates_registry(conn: sqlite3.Connection) -> None:
"""Migration 48 — registre unifié des templates (phase 1).
Catalogue les trois silos legacy (``database_templates``,
``page_templates``, ``page_global_templates``) SANS les détruire :
colonnes ``source_kind``/``source_id`` + backfill idempotent côté
service (``TemplateService.backfill_registry``), rejouable au boot.
"""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS templates (
id TEXT PRIMARY KEY,
workspace_id INTEGER NOT NULL DEFAULT 1,
teamspace_id INTEGER,
kind TEXT NOT NULL DEFAULT 'page'
CHECK (kind IN ('page','row','database','blocks')),
name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
icon TEXT NOT NULL DEFAULT '📄',
cover_url TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL DEFAULT 'personal'
CHECK (scope IN ('personal','workspace','teamspace','system')),
owner_id INTEGER,
target_collection_id INTEGER,
content_page_id INTEGER,
source_kind TEXT NOT NULL DEFAULT 'native'
CHECK (source_kind IN ('native','legacy_database','legacy_page','legacy_global')),
source_id TEXT NOT NULL DEFAULT '',
manifest_json TEXT NOT NULL DEFAULT '{}',
variables_json TEXT NOT NULL DEFAULT '[]',
is_default INTEGER NOT NULL DEFAULT 0,
is_system INTEGER NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0,
created_by INTEGER,
created_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT '',
archived_at TEXT,
deleted_at TEXT,
sync_version INTEGER NOT NULL DEFAULT 1
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_templates_ws_kind "
"ON templates(workspace_id, kind)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_templates_target "
"ON templates(target_collection_id)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_templates_source "
"ON templates(source_kind, source_id)"
)
# Un seul défaut actif par base (les lignes soft-deleted/archivées ne comptent pas).
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_templates_default "
"ON templates(target_collection_id) "
"WHERE is_default=1 AND deleted_at IS NULL AND archived_at IS NULL"
)
@register(49, "templates-notion: récurrences + journal `template_runs`")
def _migration_template_runs(conn: sqlite3.Connection) -> None:
"""Migration 49 — récurrences des templates de ligne + journal des
instanciations (compteurs du gestionnaire, dédup scheduler, audit)."""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS template_recurrences (
template_id TEXT PRIMARY KEY REFERENCES templates(id) ON DELETE CASCADE,
rrule TEXT NOT NULL DEFAULT '',
timezone TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
next_run_at TEXT,
last_run_at TEXT,
end_count INTEGER,
updated_by INTEGER,
updated_at TEXT NOT NULL DEFAULT ''
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS template_runs (
id TEXT PRIMARY KEY,
template_id TEXT NOT NULL REFERENCES templates(id) ON DELETE CASCADE,
run_source TEXT NOT NULL DEFAULT 'ui'
CHECK (run_source IN ('ui','api','agent','automation','recurrence')),
actor_id INTEGER,
occurrence_key TEXT,
created_type TEXT NOT NULL DEFAULT '',
created_id TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'ok'
CHECK (status IN ('ok','error','undone')),
warnings_json TEXT NOT NULL DEFAULT '[]',
error TEXT NOT NULL DEFAULT '',
idempotency_key TEXT,
created_at TEXT NOT NULL DEFAULT ''
)
"""
)
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_template_runs_occurrence "
"ON template_runs(occurrence_key) WHERE occurrence_key IS NOT NULL"
)
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_template_runs_idem "
"ON template_runs(idempotency_key) WHERE idempotency_key IS NOT NULL"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_template_runs_tpl "
"ON template_runs(template_id, created_at)"
)
+18 -1
View File
@@ -17,7 +17,7 @@ from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse
from app.auth.session import get_current_user
from app.config import settings
from app.db import get_conn
from app.services import connectors, oauth_connectors, skill_gallery
from app.services import connectors, oauth_connectors, plugins, skill_gallery
from app.services.agent_engine import AgentEngine, undo_action
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient
from app.services.llm_config import (
@@ -647,6 +647,23 @@ def list_connectors_route(request: Request):
return {"connectors": connectors.list_connectors(user_id)}
@router.get("/plugins")
def list_plugins_route(request: Request):
"""Catalogue des plugins de l'instance (menu + → « Add plugins »)."""
_current_user_id(request)
return {"plugins": plugins.list_plugins()}
@router.patch("/plugins/{slug}")
def set_plugin_route(request: Request, slug: str, body: dict = Body(default={})):
"""Bascule un plugin : l'effet est réel (routes/UI/outils), pas un drapeau."""
_current_user_id(request)
try:
return plugins.set_enabled(slug, bool(body.get("enabled")))
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/connectors")
def create_connectors_route(request: Request, body: dict = Body(default={})):
"""Ajoute un connecteur personnalisé — l'URL est validée (garde SSRF)."""
+141
View File
@@ -203,3 +203,144 @@ async def import_csv_v2(collection_id: int, request: Request, authorization: str
created += 1
conn.commit()
return {"imported": created, "status": "ok"}
# ── Registre unifié (§10.2) : liste / détail / instantiate / export / import ──
# (chemins /fd-templates/* : les /templates/* historiques restent inchangés)
def _v2_actor(user) -> dict:
return {"id": user.get("id"), "login": user.get("login", ""),
"full_name": user.get("full_name", ""),
"is_admin": bool(user.get("is_admin"))}
@router.get("/fd-templates")
def list_fd_templates_v2(request: Request, authorization: str | None = Header(default=None),
kind: str | None = None, q: str | None = None):
from app.services.fd_templates import TemplateService
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
items = TemplateService.list_templates(conn, _v2_actor(user), kind=kind, query=q)
return {"templates": items}
@router.get("/fd-templates/{template_id}")
def get_fd_template_v2(template_id: str, request: Request, authorization: str | None = Header(default=None)):
from app.services.fd_templates import TemplateService, _row_to_dto
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
try:
row = TemplateService.get_template(conn, _v2_actor(user), template_id)
return _row_to_dto(conn, row)
except LookupError:
raise HTTPException(404, "Template not found")
except PermissionError:
raise HTTPException(403, "Forbidden")
@router.post("/fd-templates/{template_id}/instantiate")
def instantiate_fd_template_v2(template_id: str, request: Request,
authorization: str | None = Header(default=None),
body: dict = Body(default={})):
from app.services.fd_templates import TemplateService
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
idem = request.headers.get("Idempotency-Key") or body.get("idempotency_key")
if idem:
hit = check_idempotency(request, user["id"])
if hit:
return hit["data"]
with get_conn() as conn:
try:
result = TemplateService.instantiate(
conn, _v2_actor(user), template_id, body.get("variables") or {},
body.get("context") or {}, idempotency_key=idem, run_source="api")
except LookupError:
raise HTTPException(404, "Template not found")
except PermissionError:
raise HTTPException(403, "Forbidden")
except ValueError as exc:
raise HTTPException(400, str(exc))
audit_log(user, "template.instantiate", "template", template_id, "")
if idem:
store_idempotency(idem, user["id"], result, 200)
return result
@router.get("/fd-templates/{template_id}/export")
def export_fd_template_v2(template_id: str, request: Request, authorization: str | None = Header(default=None)):
"""Export au format manifeste flowdeck-template v1 (annexe B)."""
from app.services.fd_templates import TemplateService, _read_holder_blocks, _row_to_dto
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
try:
row = TemplateService.get_template(conn, _v2_actor(user), template_id)
dto = _row_to_dto(conn, row)
blocks, _w = _read_holder_blocks(conn, row)
except LookupError:
raise HTTPException(404, "Template not found")
except PermissionError:
raise HTTPException(403, "Forbidden")
manifest = {
"format": "flowdeck-template", "version": 1, "kind": dto["kind"],
"name": dto["name"], "description": dto["description"], "icon": dto["icon"],
"category": dto["category"], "variables": dto["variables"],
"manifest": dto["manifest"], "content": {"content_format": "blocks", "blocks": blocks},
}
if dto.get("recurrence"):
manifest["recurrence"] = {"rrule": dto["recurrence"].get("rrule"),
"timezone": dto["recurrence"].get("timezone", "")}
return manifest
@router.post("/fd-templates/import")
def import_fd_template_v2(request: Request, authorization: str | None = Header(default=None),
body: dict = Body(default={})):
"""Import d'un manifeste flowdeck-template v1 (scope personnel, §14)."""
from app.services.fd_templates import KINDS, TemplateService
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if body.get("format") != "flowdeck-template":
raise HTTPException(400, "format must be flowdeck-template")
try:
version = int(body.get("version") or 0)
except (TypeError, ValueError):
raise HTTPException(400, "version invalide")
if version > 1:
raise HTTPException(400, "version de manifeste non supportée")
kind = body.get("kind") or "page"
if kind not in KINDS:
raise HTTPException(400, "kind inconnu")
blocks = ((body.get("content") or {}).get("blocks") or [])
if not isinstance(blocks, list) or len(blocks) > 2000:
raise HTTPException(400, "content.blocks invalide ou trop volumineux")
target = body.get("target") or {}
spec = {"kind": kind, "name": (body.get("name") or "Sans titre")[:200],
"description": (body.get("description") or "")[:2000],
"icon": body.get("icon") or "📄", "category": (body.get("category") or "")[:120],
"scope": "personal", "blocks": blocks,
"manifest": body.get("manifest") or {}, "variables": body.get("variables") or [],
"title_template": (body.get("manifest") or {}).get("title_template", "")}
if kind == "row":
# La base cible est résolue par nom dans le workspace, jamais par id
# externe ; si absente, le template est créé « orphelin » et signalé.
cname = target.get("collection_name", "")
with get_conn() as conn:
actor = _v2_actor(user)
row = conn.execute("SELECT id FROM collections WHERE name=?", (cname,)).fetchone() if cname else None
if row:
spec["target_collection_id"] = row["id"]
dto = TemplateService.create_template(conn, actor, spec)
conn.commit()
return {"id": dto["id"], "status": "imported"}
# Orphelin personnel : on choisit la première base comme pis-aller ? Non :
# le contrat exige un signalement — on refuse proprement.
raise HTTPException(400, "collection cible introuvable : match_by=collection_name")
with get_conn() as conn:
dto = TemplateService.create_template(conn, _v2_actor(user), spec)
conn.commit()
audit_log(user, "template.import", "template", dto["id"], "")
return {"id": dto["id"], "status": "imported"}
+26 -14
View File
@@ -252,7 +252,12 @@ def _file_icon(name: str, content_format: str = "") -> str:
def _load_workspace_pages(ws_cookie: str) -> list:
"""Load top-level pages with children for the active workspace."""
"""Load top-level pages with children for the active workspace.
v7.1.1 : les pages contenant un bloc AI Meeting Notes sont des notes de
réunion — elles vivent dans la section Meetings de la sidebar
(``GET /api/v2/meetings/notes``), pas dans l'arbre Workspace.
"""
if not ws_cookie:
return []
try:
@@ -261,7 +266,8 @@ def _load_workspace_pages(ws_cookie: str) -> list:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, "
"is_shared, share_mode, COALESCE(published,0) AS published "
"FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY created_at DESC",
"FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL AND collection_row_id IS NULL "
"AND REPLACE(COALESCE(content,''), ' ', '') NOT LIKE '%\"type\":\"meeting\"%' ORDER BY created_at DESC",
(ws_id,),
).fetchall()
items = []
@@ -289,12 +295,13 @@ def _load_workspace_pages(ws_cookie: str) -> list:
def _load_children(parent_id: int) -> list:
"""Recursively load children of a page."""
"""Recursively load children of a page (meeting notes excluded, § ci-dessus)."""
with get_conn() as conn:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, "
"is_shared, share_mode, COALESCE(published,0) AS published "
"FROM pages WHERE parent_id=? AND deleted_at IS NULL ORDER BY created_at",
"FROM pages WHERE parent_id=? AND deleted_at IS NULL "
"AND REPLACE(COALESCE(content,''), ' ', '') NOT LIKE '%\"type\":\"meeting\"%' ORDER BY created_at",
(parent_id,),
).fetchall()
children = []
@@ -382,7 +389,7 @@ def _load_shared_sidebar_pages(user_id: int) -> tuple[list, list, list, list]:
).fetchall()
published_rows = conn.execute(
f"SELECT id, title, workspace, updated_at FROM pages "
f"WHERE published=1 AND deleted_at IS NULL AND {scope_cond} "
f"WHERE (published=1 OR is_published=1) AND deleted_at IS NULL AND {scope_cond} "
f"ORDER BY updated_at DESC LIMIT 20",
scope_params,
).fetchall()
@@ -449,10 +456,10 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
has_active_workspace = False
local_ws_id = 0
if ws_cookie and ws_cookie.startswith("gitea:"):
# Gitea workspace: preserve context across pages. Also load the local
# mirror workspace so it appears in "My Workspaces" in the top section
# of the sidebar, in parallel with the Gitea repository tree.
if ws_cookie and (ws_cookie.startswith("gitea:") or ws_cookie.startswith("github:")):
# Forge workspace (Gitea/GitHub): preserve context across pages. Also
# load the local mirror workspace so it appears in Workspace in
# parallel with the Repository tree (same navigation as workspace-local).
parts = ws_cookie.split(":", 2)
if len(parts) >= 3:
gitea_owner = parts[1]
@@ -465,8 +472,9 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
try:
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
"SELECT id FROM workspaces WHERE owner_id=? AND name=? "
"AND (settings_json LIKE '%gitea_repo%' OR settings_json LIKE '%github_repo%')",
(user["id"], f"{gitea_owner}/{gitea_repo}"),
).fetchone()
if row:
local_ws_id = row["id"]
@@ -521,17 +529,21 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
uid = user["id"] if user and user.get("id") else 1
with get_conn() as conn:
fav_rows = conn.execute(
"SELECT p.id, p.title, p.workspace, p.updated_at FROM favorites f "
"SELECT p.id, p.title, p.workspace, p.updated_at, p.parent_section, "
"p.content_format FROM favorites f "
"JOIN pages p ON p.id = f.page_id "
"WHERE f.user_id=? ORDER BY f.position", (uid,)
"WHERE f.user_id=? AND p.deleted_at IS NULL ORDER BY f.position", (uid,)
).fetchall()
favorites = []
for r in fav_rows:
# v7.69.6 : mêmes icônes que le Workspace (noms SVG, pas d'emoji).
is_folder = r["parent_section"] == "Workspace"
favorites.append({
"id": f"page/{r['id']}",
"db_id": r["id"],
"name": r["title"] or "New page",
"icon": "📄",
"icon": "folder" if is_folder else _file_icon(
r["title"] or "", r["content_format"]),
"url": f"/pages/{r['id']}",
"active": False,
"indent": 0,
+11
View File
@@ -34,8 +34,19 @@ router = APIRouter(tags=["board"], prefix="/board")
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
from app.auth.session import SessionManager
from app.routers.dashboard._common import _cleanup_empty_mirrors, _ensure_forge_mirror
from app.templating import ENV
env = ENV
# Selecting a repository also opens a workspace-local of the same name:
# ensure the mirror exists, prune other empty mirrors.
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if user and owner and repo:
try:
mid = _ensure_forge_mirror(user["id"], owner, repo, forge="gitea")
_cleanup_empty_mirrors(user["id"], keep_name=f"{owner}/{repo}", keep_id=mid or 0)
except Exception:
logger.exception("board mirror ensure")
sidebar = _sidebar_data(request, owner, repo)
template = env.get_template("board.html")
return template.render(request=request, owner=owner, repo=repo, groups=[],
+15 -2
View File
@@ -95,9 +95,22 @@ def update_share(request: Request, page_id: int, body: dict = Body(default={})):
mode = body.get("mode", "private")
published = body.get("published", False)
with get_conn() as conn:
# v7.69.6 : les deux drapeaux ensemble (voir services/publish), plus
# un slug garanti quand on publie depuis le share dialog.
from app.services.publish import _unique_slug
row = conn.execute(
"SELECT publish_slug FROM pages WHERE id=?", (page_id,)
).fetchone()
slug = (row["publish_slug"] if row and row["publish_slug"] else "")
if published and not slug:
title_row = conn.execute(
"SELECT title FROM pages WHERE id=?", (page_id,)
).fetchone()
slug = _unique_slug(
conn, page_id, title_row["title"] if title_row else "")
conn.execute(
"UPDATE pages SET share_mode=?, published=? WHERE id=?",
(mode, 1 if published else 0, page_id),
"UPDATE pages SET share_mode=?, published=?, is_published=?, publish_slug=? WHERE id=?",
(mode, 1 if published else 0, 1 if published else 0, slug, page_id),
)
conn.commit()
return {"status": "ok", "share_mode": mode, "published": published}
+64
View File
@@ -206,3 +206,67 @@ def delete_comment(request: Request, comment_id: int):
conn.execute("DELETE FROM comments WHERE id=? OR parent_id=?", (comment_id, comment_id))
conn.commit()
return {"id": comment_id, "status": "deleted"}
# ── v7.64.0 : réactions emoji sur un texte sélectionné ──────────────────────
@router.get("/pages/{page_id}/reactions")
def list_reactions(request: Request, page_id: int):
"""Réactions regroupées par plage de texte : {block_id, start, end, emoji, count, mine}."""
user = _current_user(request)
with get_conn() as conn:
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
raise HTTPException(404, "Page not found")
rows = conn.execute(
"""SELECT block_id, anchor_start, anchor_end, emoji,
COUNT(*) AS count,
MAX(CASE WHEN user_id=? THEN 1 ELSE 0 END) AS mine
FROM text_reactions
WHERE page_id=?
GROUP BY block_id, anchor_start, anchor_end, emoji
ORDER BY MIN(id)""",
(user["id"], page_id),
).fetchall()
return {"page_id": page_id, "reactions": [dict(r) for r in rows]}
@router.post("/pages/{page_id}/reactions")
def toggle_reaction(request: Request, page_id: int, body: dict = Body(default={})):
"""Ajoute ou retire la réaction de l'utilisateur sur une plage de texte (toggle)."""
user = _current_user(request)
block = (body.get("block_id") or "").strip()
emoji = (body.get("emoji") or "").strip()
try:
start = int(body.get("anchor_start"))
end = int(body.get("anchor_end"))
except (TypeError, ValueError):
raise HTTPException(400, "anchor offsets required") from None
if not block or not emoji or start < 0 or end <= start:
raise HTTPException(400, "block_id, emoji and a non-empty range are required")
uid = user["id"]
with get_conn() as conn:
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
raise HTTPException(404, "Page not found")
conn.execute(
"INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
(uid, user.get("login", "admin"), user.get("full_name", "Admin")),
)
existing = conn.execute(
"""SELECT id FROM text_reactions
WHERE page_id=? AND block_id=? AND anchor_start=? AND anchor_end=?
AND emoji=? AND user_id=?""",
(page_id, block, start, end, emoji, uid),
).fetchone()
if existing:
conn.execute("DELETE FROM text_reactions WHERE id=?", (existing["id"],))
status = "removed"
else:
conn.execute(
"""INSERT INTO text_reactions
(page_id, block_id, anchor_start, anchor_end, emoji, user_id)
VALUES (?,?,?,?,?,?)""",
(page_id, block, start, end, emoji, uid),
)
status = "added"
conn.commit()
return {"status": status, "emoji": emoji}
+329 -1
View File
@@ -48,7 +48,18 @@ def update_view_config(request: Request, view_id: int, body: dict = Body(default
for key in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property",
"cover_mode", "card_properties", "visible_properties", "filters", "sorts",
"filter_conjunction", "date_property", "date_range_property",
"property_groups", "view_type"):
"property_groups", "view_type",
# Vues Notion (Chart/Timeline/Feed/Map/Dashboard/Form) — §10.4
"version", "chart_type", "chart_property", "measure", "omit_zero",
"cumulative", "hidden_groups", "group_order", "style", "order",
"aggregate", "title", "center_value", "palette", "height",
"data_labels", "legend", "grid", "smooth", "gradient",
"color_by_value", "separate_end_property", "scale", "show_table",
"table_properties", "load_limit", "show_dependencies", "sub_items",
"row_order", "show_view_count", "excerpt", "place_property",
"cluster", "default_style", "global_filters", "row_heights",
"excluded_widget_ids", "sharing", "submit_screen", "notify_email",
"description", "form_view_id"):
if key in body:
config[key] = body[key]
@@ -184,4 +195,321 @@ def duplicate_view_api(request: Request, view_id: int, body: dict = Body(default
"status": "duplicated"}
# ── Vues Notion : donnees de vues, agregation, geocodage, dashboard, form ──
def _load_view_collection(conn, collection_id: int, view_id: int):
view = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
if not view or int(view["collection_id"]) != int(collection_id):
raise HTTPException(status_code=404, detail="View not found")
try:
config = json.loads(view["config_json"] or "{}")
except (json.JSONDecodeError, TypeError):
config = {}
props = [dict(r) for r in conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,)).fetchall()]
pages = [dict(r) for r in conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
(collection_id,)).fetchall()]
return dict(view), config, props, pages
@router.get("/{collection_id}/views/{view_id}/rows")
def view_rows_api(request: Request, collection_id: int, view_id: int,
cursor: str | None = None, limit: int = 50,
window: str | None = None):
"""Lignes filtrees/triees/projetees d'une vue (Feed, Timeline, Map...)."""
from app.services.view_query import query_view_rows
with get_conn() as conn:
view, config, props, pages = _load_view_collection(conn, collection_id, view_id)
if window:
# Fenetre temporelle Timeline : "2026-01-01..2026-12-31" — filtree cote client.
config = dict(config)
limit = max(1, min(int(limit or 50), 1000))
rows, next_cursor = query_view_rows(pages, props, config, limit=limit, cursor=cursor)
undated = 0
date_prop = (config.get("date_property") or config.get("date_range_property"))
if date_prop and (view.get("view_type") == "timeline"):
from app.services.view_query import prop_value, text_of
by_name = {p["name"]: p for p in props}
by_id = {str(p["id"]): p for p in props}
dp = by_name.get(date_prop) or by_id.get(str(date_prop))
if dp:
for p in pages:
if not text_of(dp, prop_value(p, dp)):
undated += 1
return {"rows": rows, "next_cursor": next_cursor, "total": len(pages),
"undated": undated, "view_type": view.get("view_type")}
@router.post("/{collection_id}/views/{view_id}/aggregate")
def view_aggregate_api(request: Request, collection_id: int, view_id: int,
body: dict = Body(default={})):
"""Agregat groupe (Chart, KPI, widgets, drilldown) — §8.3."""
from app.services.view_aggregate import aggregate
with get_conn() as conn:
view, config, props, pages = _load_view_collection(conn, collection_id, view_id)
spec = {
"group_by": body.get("group_by", config.get("group_by")),
"sub_group_by": body.get("sub_group_by", config.get("sub_group_by")),
"measure": body.get("measure", config.get("measure", {"kind": "count"})),
"cumulative": body.get("cumulative", config.get("cumulative", False)),
"omit_zero": body.get("omit_zero", config.get("omit_zero", True)),
"hidden_groups": body.get("hidden_groups", config.get("hidden_groups", [])),
"order": body.get("order", config.get("order", config.get("group_order", "group_order"))),
"filters": (config.get("filters") or []) + (body.get("filters") or []),
"filter_conjunction": config.get("filter_conjunction", "and"),
"sorts": config.get("sorts"),
}
# Compat : ancienne config SSR {chart_property, aggregate}
if not spec["group_by"] and (config.get("chart_property") or body.get("chart_property")):
spec["group_by"] = config.get("chart_property") or body.get("chart_property")
if isinstance(spec["measure"], str):
spec["measure"] = {"kind": spec["measure"]}
if isinstance(spec["order"], str):
spec["order"] = {"by": spec["order"], "direction": "asc"}
return aggregate(pages, props, spec)
@router.get("/{collection_id}/views/{view_id}/drilldown")
def view_drilldown_api(request: Request, collection_id: int, view_id: int,
group: str = "", sub: str | None = None, limit: int = 100):
"""Tableau restreint des lignes d'un segment de Chart (§8.4)."""
from app.services.view_query import query_view_rows
with get_conn() as conn:
view, config, props, pages = _load_view_collection(conn, collection_id, view_id)
group_by = config.get("group_by") or config.get("chart_property")
extra = [{"property": group_by, "operator": "is", "value": group}] if group_by and group else []
if sub and config.get("sub_group_by"):
extra.append({"property": config["sub_group_by"], "operator": "is", "value": sub})
rows, _ = query_view_rows(pages, props, config, extra_filters=extra,
limit=max(1, min(int(limit or 100), 500)))
return {"rows": rows, "group": group, "sub": sub, "count": len(rows)}
@router.get("/geocode/search")
def geocode_search_api(request: Request, q: str = "", limit: int = 5):
"""Autocompletion d'adresses (editeur place). Provider none -> []."""
from app.services import geocoding as _geo
with get_conn() as conn:
return {"suggestions": _geo.search(conn, q, limit=min(int(limit or 5), 10))}
@router.post("/geocode/resolve")
def geocode_resolve_api(request: Request, body: dict = Body(default={})):
"""Adresse -> coordonnees (cache migration 45)."""
from app.services import geocoding as _geo
with get_conn() as conn:
result = _geo.resolve(conn, body.get("q") or body.get("address") or "")
if not result:
raise HTTPException(status_code=404, detail="Place not resolved")
return result
@router.get("/{collection_id}/views/{view_id}/widgets")
def dashboard_widgets_get(request: Request, collection_id: int, view_id: int):
with get_conn() as conn:
view, config, _, _ = _load_view_collection(conn, collection_id, view_id)
try:
rows = conn.execute(
"SELECT * FROM dashboard_widgets WHERE view_id=? ORDER BY row_index, position",
(view_id,)).fetchall()
widgets = [dict(r) for r in rows]
except Exception:
widgets = json.loads(config.get("widgets_json") or "[]") if isinstance(config.get("widgets_json"), str) else (config.get("widgets_json") or [])
return {"widgets": widgets, "config": config}
@router.put("/{collection_id}/views/{view_id}/widgets")
def dashboard_widgets_put(request: Request, collection_id: int, view_id: int,
body: dict = Body(default={})):
from app.routers.collections._common import _require_edit
from app.routers.collections._common import _session_user as _su
_require_edit(collection_id, _su(request))
widgets = body.get("widgets") or []
if len(widgets) > 12:
raise HTTPException(status_code=400, detail="12 widgets maximum")
with get_conn() as conn:
view = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
if not view or int(view["collection_id"]) != int(collection_id):
raise HTTPException(status_code=404, detail="View not found")
try:
conn.execute("DELETE FROM dashboard_widgets WHERE view_id=?", (view_id,))
for i, w in enumerate(widgets):
conn.execute(
"""INSERT INTO dashboard_widgets
(view_id, position, row_index, col_span, height_units,
source_collection_id, source_view_id, inline_config_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(view_id, i, int(w.get("row_index", i // 4)), int(w.get("col_span", 1) or 1),
int(w.get("height_units", 2) or 2),
w.get("source_collection_id"), w.get("source_view_id"),
json.dumps(w.get("inline_config") or w.get("inline_config_json") or {})),
)
conn.commit()
except HTTPException:
raise
except Exception:
# Repli : stockage JSON dans le config (pre-migration 46).
config = json.loads(view["config_json"] or "{}")
config["widgets_json"] = widgets
config["global_filters"] = body.get("global_filters", config.get("global_filters", []))
if body.get("global_filters") is not None:
config["global_filters"] = body["global_filters"]
conn.execute("UPDATE collection_views SET config_json=? WHERE id=?",
(json.dumps(config), view_id))
conn.commit()
return {"widgets": widgets, "fallback": "config_json"}
if body.get("global_filters") is not None:
config = json.loads(view["config_json"] or "{}")
config["global_filters"] = body["global_filters"]
conn.execute("UPDATE collection_views SET config_json=? WHERE id=?",
(json.dumps(config), view_id))
conn.commit()
try:
run_event_sync(fire_event("dashboard.layout_updated", {"view_id": view_id}))
except Exception:
pass
return {"widgets": widgets, "status": "saved"}
@router.get("/{collection_id}/views/{view_id}/widgets/{widget_id}/data")
def dashboard_widget_data(request: Request, collection_id: int, view_id: int, widget_id: int):
from app.services.view_aggregate import aggregate
from app.services.view_query import query_view_rows
with get_conn() as conn:
try:
w = conn.execute("SELECT * FROM dashboard_widgets WHERE id=? AND view_id=?",
(widget_id, view_id)).fetchone()
widget = dict(w) if w else None
except Exception:
widget = None
if widget is None:
raise HTTPException(status_code=404, detail="Widget not found")
src_cid = widget.get("source_collection_id") or collection_id
props = [dict(r) for r in conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(src_cid,)).fetchall()]
pages = [dict(r) for r in conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
(src_cid,)).fetchall()]
src_view = None
if widget.get("source_view_id"):
src_view = conn.execute("SELECT * FROM collection_views WHERE id=?",
(widget["source_view_id"],)).fetchone()
try:
inline = json.loads(widget.get("inline_config_json") or "{}")
except (json.JSONDecodeError, TypeError):
inline = {}
cfg = dict(inline)
if src_view:
try:
base = json.loads(src_view["config_json"] or "{}")
except (json.JSONDecodeError, TypeError):
base = {}
cfg = {**base, **inline}
vtype = (cfg.get("view_type") or src_view["view_type"] if src_view else cfg.get("view_type", "table"))
if (cfg.get("group_by") or cfg.get("chart_type")) and vtype in ("chart", "dashboard", None):
return aggregate(pages, props, {
"group_by": cfg.get("group_by"), "sub_group_by": cfg.get("sub_group_by"),
"measure": cfg.get("measure", {"kind": "count"}),
"omit_zero": cfg.get("omit_zero", True), "hidden_groups": cfg.get("hidden_groups", []),
"order": cfg.get("order", "group_order"), "filters": cfg.get("filters") or [],
})
rows, _ = query_view_rows(pages, props, cfg, limit=50)
return {"rows": rows, "view_type": vtype or "table"}
@router.get("/{collection_id}/views/{view_id}/form")
def form_builder_get(request: Request, collection_id: int, view_id: int):
from app.services.form_projection import default_questions_for
with get_conn() as conn:
view, config, props, _ = _load_view_collection(conn, collection_id, view_id)
try:
qs = [dict(r) for r in conn.execute(
"SELECT * FROM form_questions WHERE view_id=? ORDER BY position", (view_id,)).fetchall()]
except Exception:
qs = []
if not qs:
qs = default_questions_for([{"name": p["name"], "prop_type": p["prop_type"]} for p in props])
return {"config": config, "questions": qs}
@router.put("/{collection_id}/views/{view_id}/form")
def form_builder_put(request: Request, collection_id: int, view_id: int,
body: dict = Body(default={})):
from app.routers.collections._common import _require_edit
from app.routers.collections._common import _session_user as _su
from app.services.form_projection import project_form_config
_require_edit(collection_id, _su(request))
questions = body.get("questions") or []
form_cfg = body.get("config") or {}
with get_conn() as conn:
view = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
if not view or int(view["collection_id"]) != int(collection_id):
raise HTTPException(status_code=404, detail="View not found")
try:
base = json.loads(view["config_json"] or "{}")
except (json.JSONDecodeError, TypeError):
base = {}
base.update({k: v for k, v in form_cfg.items()
if k in ("title", "description", "sharing", "submit_screen", "notify_email")})
base["version"] = 2
try:
conn.execute("DELETE FROM form_questions WHERE view_id=?", (view_id,))
for i, q in enumerate(questions):
conn.execute(
"""INSERT INTO form_questions
(view_id, property_name, position, label_override, sync_label,
help_text, required, widget, max_selections, conditional_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(view_id, q.get("property_name"), i,
q.get("label_override"), 1 if q.get("sync_label", True) else 0,
q.get("help_text", ""), 1 if q.get("required") else 0,
q.get("widget", "auto"), q.get("max_selections"),
json.dumps(q.get("conditional")) if q.get("conditional") else q.get("conditional_json")),
)
stored_qs = [dict(r) for r in conn.execute(
"SELECT * FROM form_questions WHERE view_id=? ORDER BY position", (view_id,)).fetchall()]
except Exception:
base["form_questions_json"] = questions
stored_qs = questions
conn.execute("UPDATE collection_views SET config_json=? WHERE id=?",
(json.dumps(base), view_id))
conn.commit()
return {"questions": stored_qs, "status": "saved", "fallback": "config_json"}
projected = project_form_config(base, [{
"property_name": r["property_name"], "position": r["position"],
"label_override": r["label_override"], "sync_label": r["sync_label"],
"help_text": r["help_text"], "required": r["required"],
"widget": r["widget"], "max_selections": r["max_selections"],
"conditional_json": r["conditional_json"],
} for r in stored_qs])
base["projected_form_config"] = projected
# Synchro libelle -> nom de propriete (defaut Notion).
for r in stored_qs:
if r["sync_label"] and r["label_override"]:
conn.execute(
"UPDATE collection_properties SET name=? WHERE collection_id=? AND name=?",
(r["label_override"], collection_id, r["property_name"]))
conn.execute("UPDATE collection_views SET config_json=? WHERE id=?",
(json.dumps(base), view_id))
# La projection alimente aussi le pipeline public /f/ existant (meme collection).
try:
conn.execute("UPDATE collections SET form_config_json=? WHERE id=?",
(json.dumps(projected), collection_id))
except Exception:
pass
conn.commit()
return {"questions": stored_qs, "status": "saved",
"projected_fields": len(projected.get("fields", []))}
@router.post("/{collection_id}/views/{view_id}/form/preview-token")
def form_preview_token(request: Request, collection_id: int, view_id: int):
"""Jeton d'apercu non-soumissible (soumissions refusees cote serveur)."""
import secrets
return {"preview_token": "preview_" + secrets.token_hex(8), "submittable": False}
# ── v1.8.0 Sub-items & Dependencies ──
+197 -35
View File
@@ -90,7 +90,52 @@ def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list
def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True) -> dict:
def _recent_sidebar_pages(uid: int, limit: int = 8) -> list:
"""Fichiers réellement récents (dernière édition) pour la sidebar.
v7.69.6 : avant, seuls les dépôts Gitea alimentaient la section Recents
(vide sans dépôt). Icônes identiques au Workspace (noms SVG, pas
d'emoji). Notes de réunion exclues → section Meetings.
"""
if not uid:
return []
try:
from app.routers.board import _file_icon
with get_conn() as conn:
rows = conn.execute(
"""SELECT p.id, p.title, p.parent_section, p.content_format
FROM pages p
LEFT JOIN workspaces w ON w.id = p.workspace_id
WHERE p.deleted_at IS NULL AND p.parent_section != 'Trash'
AND (w.owner_id = ? OR p.workspace_id IS NULL)
AND REPLACE(COALESCE(p.content,''), ' ', '')
NOT LIKE '%"type":"meeting"%'
ORDER BY p.updated_at DESC LIMIT ?""",
(uid, limit),
).fetchall()
out = []
for r in rows:
is_folder = r["parent_section"] == "Workspace"
out.append({
"id": f"page/{r['id']}",
"name": r["title"] or "Untitled",
"icon": "folder" if is_folder else _file_icon(
r["title"] or "", r["content_format"]),
"url": f"/pages/{r['id']}",
"active": False,
"indent": 0,
"depth": 0,
"has_children": False,
"children": [],
})
return out
except Exception:
logger.exception("_recent_sidebar_pages")
return []
def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True,
gitea_owner: str = "", gitea_repo: str = "") -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws = user.get("login", "Bruno") if user else "Bruno"
initial = ws[0].upper() if ws else "B"
@@ -108,7 +153,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
except Exception:
logger.exception("_sidebar_data")
recent_pages = []
recent_pages = _recent_sidebar_pages(user["id"] if user else 0) if user else []
for repo in repos[:10]:
full_name = repo.get("full_name", "")
recent_pages.append({
@@ -130,37 +175,45 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
active_ws_name = "Workspace"
workspace_pages = []
gitea_workspace = False
gitea_owner = ""
gitea_repo = ""
has_active_workspace = False
local_ws_id = 0
if ws_cookie and ws_cookie.startswith("gitea:"):
# Gitea workspace: set owner/repo for client-side tree loading
# AND open the local workspace mirror of the same name in the
# sidebar's top "My Workspaces" section, in parallel with the
# Gitea repository tree.
# Explicit project context (e.g. /gitea-workspace?owner=…&repo=…) wins over
# the cookie: on a direct navigation / bookmark the cookie may be missing,
# yet the project is unambiguously identified by the URL. Without this the
# sidebar showed "No workspace open" even though the route just created the
# local mirror.
eff_owner, eff_repo = gitea_owner, gitea_repo
if not (eff_owner and eff_repo) and (ws_cookie.startswith("gitea:") or ws_cookie.startswith("github:")):
parts = ws_cookie.split(":", 2)
if len(parts) >= 3:
gitea_owner = parts[1]
gitea_repo = parts[2]
active_ws_name = f"{gitea_owner}/{gitea_repo}"
gitea_workspace = True
has_active_workspace = True
# Load the local mirror workspace tree so it appears in "My
# Workspaces" alongside the Gitea repository section.
if user:
try:
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
).fetchone()
if row:
local_ws_id = row["id"]
workspace_pages = _load_workspace_pages(str(local_ws_id))
except (ValueError, Exception):
pass
eff_owner, eff_repo = parts[1], parts[2]
if eff_owner and eff_repo:
# Forge workspace (Gitea or GitHub): set owner/repo for client-side
# tree loading AND open the local workspace mirror of the same name
# in the sidebar's top Workspace section, in parallel with the
# Repository tree (same navigation structure as workspace-local).
gitea_owner = eff_owner
gitea_repo = eff_repo
active_ws_name = f"{gitea_owner}/{gitea_repo}"
gitea_workspace = True
has_active_workspace = True
# Load the local mirror workspace tree so it appears in Workspace
# alongside the Repository section (gitea_repo or github_repo marker).
if user:
try:
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM workspaces WHERE owner_id=? AND name=? "
"AND (settings_json LIKE '%gitea_repo%' OR settings_json LIKE '%github_repo%')",
(user["id"], f"{gitea_owner}/{gitea_repo}"),
).fetchone()
if row:
local_ws_id = row["id"]
workspace_pages = _load_workspace_pages(str(local_ws_id))
except (ValueError, Exception):
pass
elif include_workspace and ws_cookie and user:
try:
wsi = int(ws_cookie)
@@ -204,15 +257,16 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
except Exception:
logger.exception("_sidebar_data")
# Get local workspace ID for Gitea workspace mirror (le miroir Gitea est un
# Get local workspace ID for forge workspace mirror (le miroir est un
# espace DISTINCT : on ne doit pas écraser ``local_ws_id`` (espace actif).
gitea_mirror_ws_id = 0
if gitea_workspace and gitea_owner and gitea_repo:
try:
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
"SELECT id FROM workspaces WHERE owner_id=? AND name=? "
"AND (settings_json LIKE '%gitea_repo%' OR settings_json LIKE '%github_repo%')",
(user["id"], f"{gitea_owner}/{gitea_repo}"),
).fetchone()
if row:
gitea_mirror_ws_id = row["id"]
@@ -359,21 +413,26 @@ def _require_page_view(request: Request, page_id: int) -> None:
def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | None = None) -> list:
"""Recursively build the tree of children for a node."""
# v7.69.6 : notes de réunion exclues (section Meetings, comme la sidebar).
no_meet = "AND REPLACE(COALESCE(content,''), ' ', '') NOT LIKE '%\"type\":\"meeting\"%'"
if parent_id is None:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, content, page_icon, "
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
"COALESCE(is_published,0) AS pub1, COALESCE(published,0) AS pub2, "
"(is_shared OR share_mode != 'private' OR COALESCE(published,0) OR COALESCE(is_published,0)) as is_shared, "
"created_at, updated_at FROM pages "
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL "
f"{no_meet}"
"ORDER BY created_at DESC",
(ws_id,),
).fetchall()
else:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, content, page_icon, "
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
"COALESCE(is_published,0) AS pub1, COALESCE(published,0) AS pub2, "
"(is_shared OR share_mode != 'private' OR COALESCE(published,0) OR COALESCE(is_published,0)) as is_shared, "
"created_at, updated_at FROM pages "
"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL "
f"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL {no_meet} "
"ORDER BY created_at DESC",
(parent_id, ws_id),
).fetchall()
@@ -441,6 +500,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | Non
"author": author,
"tags": tags_map.get(r["id"], []),
"is_shared": bool(r["is_shared"]),
"published": bool(r["pub1"] or r["pub2"]),
"favorited": r["id"] in favorited_ids,
})
return tree
@@ -564,6 +624,103 @@ def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
def _ensure_forge_mirror(user_id: int, owner: str, repo: str, forge: str = "gitea") -> int | None:
"""Ensure the local workspace mirror ``owner/repo`` exists, return its id.
Shared by /gitea-workspace and /github-workspace so selecting a repository
always opens a workspace-local of the same name (section Workspace).
Only auto-created mirrors carry ``settings_json`` with ``gitea_repo`` /
``github_repo`` — never touch regular user workspaces.
"""
import json as _json
ws_key = f"{owner}/{repo}" if owner and repo else ""
if not ws_key or not user_id:
return None
marker = f"{forge}_repo"
with get_conn() as conn:
existing = conn.execute(
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
(user_id, ws_key, f"%{marker}%"),
).fetchone()
if existing:
return existing["id"]
cur = conn.execute(
"INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)",
(ws_key, user_id, _json.dumps({marker: ws_key, f"{forge}_owner": owner})),
)
mirror_id = cur.lastrowid
conn.execute(
"INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)",
(mirror_id, user_id, "admin"),
)
conn.commit()
return mirror_id
def _is_mirror_empty(conn, ws_id: int) -> bool:
"""True when no file/folder was ever created in this mirror workspace.
« Vide » = zéro page active (``deleted_at IS NULL``). Trash / soft-deleted
rows don't count — an emptied-then-trashed mirror is still collectable.
"""
row = conn.execute(
"SELECT COUNT(*) AS c FROM pages WHERE workspace_id=? AND deleted_at IS NULL",
(ws_id,),
).fetchone()
return (row["c"] if row else 0) == 0
def _cleanup_empty_mirrors(user_id: int, keep_name: str = "", keep_id: int = 0) -> int:
"""Delete empty auto-created forge mirrors, except the one being opened.
Called when switching workspace / repository so empty workspace-locals
don't accumulate. Only workspaces whose ``settings_json`` contains
``gitea_repo`` or ``github_repo`` are eligible — regular workspaces are
never deleted here. Returns number of removed mirrors.
"""
if not user_id:
return 0
removed = 0
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name FROM workspaces WHERE owner_id=? "
"AND (settings_json LIKE '%gitea_repo%' OR settings_json LIKE '%github_repo%')",
(user_id,),
).fetchall()
for r in rows:
wid, name = r["id"], r["name"]
if keep_id and wid == keep_id:
continue
if keep_name and name == keep_name:
continue
try:
if not _is_mirror_empty(conn, wid):
continue
except Exception:
continue
try:
from app.services.collection_lifecycle import (
delete_collections,
workspace_collection_ids,
)
try:
collections = workspace_collection_ids(conn, wid)
if collections:
delete_collections(conn, collections)
except Exception:
logger.exception("_cleanup_empty_mirrors collections")
conn.execute("DELETE FROM pages WHERE workspace_id=?", (wid,))
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (wid,))
conn.execute("DELETE FROM workspaces WHERE id=?", (wid,))
removed += 1
except Exception:
logger.exception("_cleanup_empty_mirrors")
continue
conn.commit()
return removed
def _sanitize_id(block_id: str) -> str:
"""Sanitize a block id for use as an HTML anchor (only alnum kept)."""
if not block_id:
@@ -671,12 +828,17 @@ def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
)
elif t == "code":
lang = b.get("language", "")
try:
from app.services.export import normalize_code_lang
hljs_lang = normalize_code_lang(lang)
except Exception: # noqa: BLE001 — never break public rendering
hljs_lang = "plaintext"
lang_label = f"<div style='font-size:11px;opacity:.4;margin-bottom:8px;'>{lang}</div>" if lang else ""
html_parts.append(
f'<pre style="background:rgba(255,255,255,.05);padding:16px 20px;border-radius:8px;'
f'overflow-x:auto;font-size:14px;line-height:1.5;margin:12px 0;">'
f'{lang_label}'
f'<code>{c}</code></pre>'
f'<code class="hljs language-{hljs_lang}" data-hljs-lang="{hljs_lang}">{c}</code></pre>'
)
elif t == "divider":
html_parts.append('<hr style="border:none;border-top:1px solid rgba(255,255,255,.1);margin:16px 0;">')
+27 -3
View File
@@ -395,9 +395,33 @@ def sidebar_workspace_tree(request: Request):
if not ws_cookie:
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No pages yet</span></li>')
# Gitea workspace — no server-side tree, loaded client-side
if ws_cookie.startswith("gitea:"):
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">🔗</span><span class="page-name text-dim">Remote workspace</span></li>')
# Forge workspace (Gitea/GitHub) — the sidebar Workspace section shows the
# LOCAL mirror of the repository (the "parallel local workspace"), not a
# remote placeholder. Older behaviour returned "Remote workspace", which
# wiped the mirror tree on every CRUD refresh.
if ws_cookie.startswith("gitea:") or ws_cookie.startswith("github:"):
parts = ws_cookie.split(":", 2)
if len(parts) >= 3:
mirror = None
with get_conn() as conn:
mirror = conn.execute(
"SELECT id FROM workspaces WHERE owner_id=? AND name=? "
"AND (settings_json LIKE '%gitea_repo%' OR settings_json LIKE '%github_repo%')",
(user["id"], f"{parts[1]}/{parts[2]}"),
).fetchone()
if mirror:
pages = _load_workspace_pages(str(mirror["id"]))
if pages:
env = ENV
template = env.from_string(
"{% from '_workspace_tree_macro.html' import render_workspace_tree %}"
"{{ render_workspace_tree(pages) }}"
)
return HTMLResponse(template.render(pages=pages))
return HTMLResponse(
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
'<span class="page-name text-dim">No pages yet</span></li>'
)
try:
ws_id = int(ws_cookie)
+30
View File
@@ -18,6 +18,7 @@ from ._common import (
WORKSPACE_COOKIE,
_build_breadcrumb,
_build_tree_children,
_cleanup_empty_mirrors,
_file_page_disk_path,
_get_active_workspace,
_get_user_id,
@@ -64,6 +65,12 @@ def local_workspace_page(request: Request, folder: int = None, ws: int = None):
).fetchone()
if requested is not None:
ws_row = dict(requested)
# Switching workspace: drop other EMPTY forge mirrors (no file or
# folder ever created there) instead of keeping empty locals.
try:
_cleanup_empty_mirrors(user["id"], keep_id=ws_row["id"])
except Exception:
pass
# L'URL fait foi : on aligne le cookie pour que le reste de
# l'application (sidebar, API tree, breadcrumbs) suive le même
# workspace que celui affiché.
@@ -100,6 +107,28 @@ def _render_local_workspace(env, sidebar: dict, user: dict, ws_row: dict,
with get_conn() as conn:
breadcrumb = _build_breadcrumb(conn, folder)
# En-tête Notion-style (même mécanique que les pages) :
# Home / <workspace> / <dossier> / <sous-dossier> / …
# Chaque niveau cliquable renvoie à /local-workspace?folder=<id> — sans
# ça le header restait bloqué sur « Home / <workspace> » même quand le
# contenu affiché était celui d'un sous-dossier.
nav_crumbs: list = [{
"id": None,
"label": ws_name,
"url": "/local-workspace",
"icon": "folder",
"menu": False,
}]
for i, bcrumb in enumerate(breadcrumb):
is_last = i == len(breadcrumb) - 1
nav_crumbs.append({
"id": bcrumb["id"],
"label": bcrumb["name"],
"url": None if is_last else f"/local-workspace?folder={bcrumb['id']}",
"icon": "folder",
"menu": True,
})
ctx = {
**sidebar,
"user": user,
@@ -111,6 +140,7 @@ def _render_local_workspace(env, sidebar: dict, user: dict, ws_row: dict,
"nav_workspace_id": ws_id or 0,
"breadcrumb": breadcrumb,
"breadcrumbs": breadcrumb,
"breadcrumb_items": nav_crumbs,
}
template = env.get_template("local_workspace.html")
return HTMLResponse(
+21 -202
View File
@@ -94,6 +94,24 @@ def view_page_root(request: Request, page_id: int):
if not row:
return RedirectResponse("/workspaces", status_code=302)
page = dict(row)
# v7.69.6 : tracer la visite (colonne Library « Last visited », table recents).
try:
from datetime import UTC, datetime
_sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if _sess and _sess.get("id"):
with get_conn() as _conn:
_conn.execute(
"""INSERT INTO recents (user_id, page_id, workspace, source_type, accessed_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, page_id)
DO UPDATE SET workspace=excluded.workspace,
accessed_at=excluded.accessed_at""",
(_sess["id"], page_id, page.get("workspace", "") or "",
"local", datetime.now(UTC).replace(tzinfo=None).isoformat()),
)
_conn.commit()
except Exception:
pass
# v6.5.0: synced blocks resolve server-side at read time.
from app.services.synced_blocks import resolve_content_json
page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format"))
@@ -119,7 +137,7 @@ def view_page_root(request: Request, page_id: int):
_sess_user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
_uid = _sess_user.get("id") if _sess_user and _sess_user.get("id") else None
_can_edit = (not _locked) or bool(_sess_user and _sess_user.get("is_admin")) or (_locked_by and _uid and _locked_by == _uid)
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)),
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("is_published", 0) or page.get("published", 0)),
"is_locked": _locked,
"locked_by": _locked_by,
"can_edit": _can_edit,
@@ -251,209 +269,10 @@ def accounts_page(request: Request):
@router.get("/help", response_class=HTMLResponse)
def help_page(request: Request):
"""Comprehensive help & documentation page."""
"""Help & documentation page — settings-style panel with side navigation."""
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, [])
# Render via a block-based template so content_html lands in {% block content %}
block_tpl = env.from_string(
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
)
return HTMLResponse(block_tpl.render(
**sidebar,
request=request,
page_title="Help",
title_prefix="Help",
page_icon="❓",
content_html="""<style>
.help-page{max-width:900px;margin:0 auto;padding:40px 24px 80px;}
.help-hero{text-align:center;margin-bottom:48px;}
.help-hero h1{font-size:32px;font-weight:800;margin:0 0 8px;}
.help-hero p{font-size:16px;color:var(--text-dim);max-width:500px;margin:0 auto;}
.help-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;margin-bottom:48px;}
.help-card{background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:24px;transition:border-color .15s;}
.help-card:hover{border-color:rgba(255,255,255,.12);}
.help-card h3{font-size:15px;font-weight:600;margin:0 0 4px;display:flex;align-items:center;gap:8px;}
.help-card .icon{font-size:20px;}
.help-card p{font-size:13px;color:var(--text-dim);line-height:1.5;margin:8px 0 0;}
.help-card ul{list-style:none;padding:0;margin:12px 0 0;}
.help-card li{font-size:13px;padding:3px 0;color:var(--text-dim);}
.help-card li::before{content:'• ';color:var(--accent);}
.help-section{margin-bottom:48px;}
.help-section h2{font-size:20px;font-weight:700;margin:0 0 16px;padding-bottom:8px;border-bottom:1px solid var(--border);}
.help-kbd{display:inline-block;padding:2px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:12px;color:var(--text);min-width:16px;text-align:center;}
.help-shortcut-row{display:flex;align-items:center;gap:12px;padding:8px 12px;border-radius:6px;margin-bottom:2px;}
.help-shortcut-row:hover{background:var(--bg-hover);}
.help-shortcut-row .keys{display:flex;gap:4px;min-width:140px;}
.help-shortcut-row .desc{font-size:13px;color:var(--text-dim);}
.help-badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:11px;font-weight:600;}
.help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;}
.help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;}
.help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;}
.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
</style>
<div class="help-page">
<div class="help-hero">
<h1>❓ FlowDeck Help</h1>
<p>Everything you need to know about your Notion-style workspace with Gitea & GitHub integration.</p>
</div>
<div class="help-grid">
<div class="help-card">
<h3><span class="icon">🚀</span>Getting Started</h3>
<p>FlowDeck is your private, self-hosted workspace. Create pages, organize projects, and integrate with your Git forge.</p>
<ul>
<li>Create a workspace from the <b>Workspaces</b> page</li>
<li>Click <b>📄 New Page</b> in the sidebar to start writing</li>
<li>Use <span class="help-kbd">Ctrl+N</span> anywhere to create a page</li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">📝</span>Pages & Editor</h3>
<p>Notion-style block editor with slash commands, markdown shortcuts, and rich formatting.</p>
<ul>
<li>Type <span class="help-kbd">/</span> for the slash command menu</li>
<li>Drag & drop pages in the sidebar to reorganize</li>
<li>Right-click for context menu (duplicate, rename, delete)</li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">{{ fd_icon("folder",16) }}</span>Workspaces</h3>
<p>Organize your work into separate workspaces. Each has its own pages and files.</p>
<ul>
<li><span class="help-badge local">Local</span> Files stored on your server</li>
<li><span class="help-badge gitea">Gitea</span> Connect to browse & edit repos</li>
<li><span class="help-badge github">GitHub</span> Connect via Settings → Integrations</li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">🦎</span>Gitea Integration</h3>
<p>Connect your Gitea account to access repositories directly from FlowDeck.</p>
<ul>
<li>Go to <b>Settings → Integrations</b> to connect</li>
<li>Browse repo file trees in the sidebar</li>
<li>Create & edit files with commit messages</li>
<li>Sync labels as tags</li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">🌐</span>Sharing & Publishing</h3>
<p>Share pages with collaborators or publish them to the web.</p>
<ul>
<li>Click <b>Share</b> in the page editor top-right</li>
<li>Share with specific users or get a public link</li>
<li>Publish to make a page visible at <code>/p/your-slug</code></li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">📚</span>Library, Trash & Tasks</h3>
<p>Find all your content in one place with powerful filtering.</p>
<ul>
<li><b>Library</b> — Tabs for Recents, Favorites, Shared, Published</li>
<li><b>Trash</b> — Soft-deleted pages (30-day retention)</li>
<li><b>My Tasks</b> — Aggregated tasks from all collections</li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">📶</span>Offline & PWA</h3>
<p>Install FlowDeck as an app and keep working without a connection.</p>
<ul>
<li><b>Install</b> — browser menu → <i>Install app</i> / <i>Add to Home Screen</i></li>
<li>Edits made offline are queued locally and synced automatically</li>
<li>A <b>⟳</b> marker shows pages with pending changes</li>
</ul>
</div>
</div>
<div class="help-section">
<h2>⌨️ Keyboard Shortcuts</h2>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">N</span></div><div class="desc">Create new page</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">K</span></div><div class="desc">Quick find / command palette</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">S</span></div><div class="desc">Save current page</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">F2</span></div><div class="desc">Rename selected item</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Delete</span></div><div class="desc">Move selected item to trash</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Escape</span></div><div class="desc">Close modal / cancel editing</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Enter</span></div><div class="desc">Open selected page</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">O</span></div><div class="desc">New AI chat (in footer)</div></div>
</div>
<div class="help-section">
<h2>🔐 Authentication</h2>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
FlowDeck supports three authentication methods:<br>
<span class="help-badge local">Local</span> Email + password — create an account on the login page.<br>
<span class="help-badge gitea">Gitea OAuth</span> Login with your Gitea account. Your repos appear as workspaces.<br>
<span class="help-badge github">GitHub OAuth</span> Login or link your GitHub account in Settings → Integrations.<br><br>
<b>Tip:</b> You can connect Gitea/GitHub to an existing local account — your identity stays as your local user.
</p>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
<span class="help-badge sso">SSO</span> <b>Enterprise SSO</b> (v6.7.0) — sign in with your organization account.<br>
<i>For administrators:</i> open <b>Settings → Admin → SSO / Enterprise</b> and pick a provider:<br>
• <b>SAML 2.0</b> — paste the IdP <i>Entity ID</i>, <i>SSO URL</i> and signing certificate, then give the IdP this
<code>/auth/saml/metadata</code> link (it contains the SP Entity ID, ACS URL and certificate).<br>
• <b>OpenID Connect</b> — paste the <i>Issuer URL</i>, <i>Client ID</i> and <i>Client Secret</i> (PKCE is used, scopes default to <code>openid profile email</code>).<br>
• <b>Provisioning</b> — accounts are created automatically on first login, groups from the IdP map to workspace roles,
and <i>SSO only</i> disables local login (admins keep their local door). Every attempt is audited in
<b>Settings → Admin → SSO / Enterprise</b> (login history).
</p>
</div>
<div class="help-section">
<h2>📶 Offline mode (PWA)</h2>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
FlowDeck is a Progressive Web App: pages you visited stay available offline and your
edits are saved locally, then synchronised when the connection returns.<br><br>
<b>Install:</b> open your browser menu and choose <i>Install app</i> (Chrome/Edge) or
<i>Add to Home Screen</i> (Safari/iOS). FlowDeck then opens in its own window.<br>
<b>Offline editing:</b> while offline, the editor stores changes in the browser
(IndexedDB) and shows an offline banner with the number of pending changes. A
<b>⟳</b> icon appears next to pages that have unsynced edits.<br>
<b>Reconnection:</b> the queue is replayed automatically (and via Background Sync).
A spinner badge appears while syncing, followed by a confirmation toast.<br>
<b>Conflicts:</b> if a page changed on the server, the latest edit wins and a notice is
shown. If a page was deleted server-side, your offline copy is recreated as an orphan
page. If a page with the same title already exists, the offline copy is renamed
<i>“Title (copie offline)”</i>.
</p>
</div>
<div class="help-section">
<h2>🔌 API publique v2</h2>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
FlowDeck exposes a full REST API under <b>/api/v2</b> for third-party integrations.<br>
<b>Auth:</b> create a token in Settings → API tokens, then send it as
<code>Authorization: Bearer &lt;token&gt;</code>. Tokens carry scopes
<code>read</code>, <code>write</code> or <code>admin</code> (a higher scope implies the lower ones).<br>
<b>Features:</b> CRUD on collections, pages, properties, views, comments, notifications,
favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&amp;offset=</code> +
<code>X-Total-Count</code>), filters (<code>filter[prop]=value</code>), sorting, full-text search
(<code>/api/v2/search</code>), idempotency (<code>Idempotency-Key</code>) and RFC 7807 error bodies.<br>
<b>Reference:</b> interactive OpenAPI docs at <a href="/docs" target="_blank" rel="noopener">/docs</a>
(also <code>/redoc</code>, <code>docs/openapi-v2.json</code>).
</p>
</div>
<div class="help-section">
<h2>💡 Tips</h2>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
• Toggle the sidebar with the <b>«</b> button in the top-left corner.<br>
• Switch between workspaces using the dropdown menu in the sidebar header.<br>
• The <b>Private</b> section appears when a remote workspace is active — files here stay local.<br>
• Hover over any sidebar item to see action buttons (favorite, share, delete).<br>
• Use <b>Ctrl+Click</b> or <b>Shift+Click</b> to multi-select items in the sidebar.
</p>
</div>
</div>"""
))
return ENV.get_template("help.html").render(**sidebar, request=request)
@router.get("/accounts/settings", response_class=HTMLResponse)
+57 -25
View File
@@ -13,7 +13,13 @@ from app.auth.session import SessionManager
from app.db import get_conn
from app.services.gitea_client import get_user_gitea_client, gitea
from ._common import _get_active_workspace, _get_user_id, _sidebar_data
from ._common import (
_cleanup_empty_mirrors,
_ensure_forge_mirror,
_get_active_workspace,
_get_user_id,
_sidebar_data,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["dashboard"])
@@ -125,12 +131,9 @@ def workspace_page(request: Request):
@router.get("/gitea-workspace", response_class=HTMLResponse)
def gitea_workspace_page(request: Request):
"""Gitea workspace — browse repo files."""
import json
"""Gitea workspace — browse repo files (same layout as /local-workspace)."""
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302)
@@ -138,27 +141,16 @@ def gitea_workspace_page(request: Request):
repo = request.query_params.get("repo", "")
ws_key = f"{owner}/{repo}" if owner and repo else ""
ws_name = ws_key or "Gitea Workspace"
# Auto-create local workspace mirror for storing local files
local_ws_id = None
# Ensure the local workspace mirror of the same name BEFORE building the
# sidebar, then drop other EMPTY mirrors so switching repositories never
# accumulates empty workspace-locals.
local_ws_id = _ensure_forge_mirror(user["id"], owner, repo, forge="gitea") if ws_key else None
if ws_key:
with get_conn() as conn:
existing = conn.execute(
"SELECT id, owner_id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
(user["id"], ws_key, "%gitea_repo%")
).fetchone()
if existing:
local_ws_id = existing["id"]
else:
c = conn.execute(
"INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)",
(ws_key, user["id"], json.dumps({"gitea_repo": ws_key, "gitea_owner": owner}))
)
local_ws_id = c.lastrowid
conn.execute(
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)",
(local_ws_id, user["id"], "admin")
)
conn.commit()
_cleanup_empty_mirrors(user["id"], keep_name=ws_key, keep_id=local_ws_id or 0)
# Build the sidebar with the explicit project context so the local mirror
# (''Workspace'' section) is resolved even when the flowdeck_workspace
# cookie is absent (direct navigation / bookmark).
sidebar = _sidebar_data(request, [], gitea_owner=owner, gitea_repo=repo)
ctx = {
**sidebar,
"user": user,
@@ -167,6 +159,7 @@ def gitea_workspace_page(request: Request):
"gitea_workspace": True, # always true on this page
"gitea_owner": owner,
"gitea_repo": repo,
"forge": "gitea",
"workspace_name": ws_name,
"workspace_initial": repo[0].upper() if repo else "G",
"owner": owner,
@@ -179,6 +172,45 @@ def gitea_workspace_page(request: Request):
return resp
@router.get("/github-workspace", response_class=HTMLResponse)
def github_workspace_page(request: Request):
"""GitHub workspace — same layout as /local-workspace and /gitea-workspace."""
from app.templating import ENV
env = ENV
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302)
owner = request.query_params.get("owner", "")
repo = request.query_params.get("repo", "")
ws_key = f"{owner}/{repo}" if owner and repo else ""
ws_name = ws_key or "GitHub Workspace"
local_ws_id = _ensure_forge_mirror(user["id"], owner, repo, forge="github") if ws_key else None
if ws_key:
_cleanup_empty_mirrors(user["id"], keep_name=ws_key, keep_id=local_ws_id or 0)
# Reuse the same sidebar mechanism (Repository section + parallel local
# mirror). The cookie prefix carries the forge so refresh APIs resolve it.
sidebar = _sidebar_data(request, [], gitea_owner=owner, gitea_repo=repo)
ctx = {
**sidebar,
"user": user,
"active_ws_name": ws_name,
"workspace_key": ws_key,
"gitea_workspace": True, # sidebar Repository section visible
"gitea_owner": owner,
"gitea_repo": repo,
"forge": "github",
"workspace_name": ws_name,
"workspace_initial": repo[0].upper() if repo else "G",
"owner": owner,
"repo": repo,
"nav_workspace_id": local_ws_id or 0,
}
template = env.get_template("github_workspace.html")
resp = HTMLResponse(content=template.render(**ctx))
resp.set_cookie("flowdeck_workspace", f"github:{owner}:{repo}", path="/", samesite="lax")
return resp
@router.get("/api/workspace/projects")
+7 -1
View File
@@ -18,6 +18,7 @@ from app.services.collection_lifecycle import (
from ._common import (
WORKSPACE_COOKIE,
_cleanup_empty_mirrors,
_get_active_workspace,
_get_user_id,
_require_user_id,
@@ -158,7 +159,12 @@ def delete_workspace(request: Request, ws_id: int):
@router.post("/api/workspaces/{ws_id:int}/select")
def select_workspace(request: Request, ws_id: int):
"""Set the active workspace via cookie."""
_require_ws_owner(request, ws_id)
uid = _require_ws_owner(request, ws_id)
# Switching workspace: prune other empty forge mirrors.
try:
_cleanup_empty_mirrors(uid, keep_id=ws_id)
except Exception:
pass
response = JSONResponse({"status": "ok", "workspace_id": ws_id})
response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/")
return response
+480
View File
@@ -0,0 +1,480 @@
"""FlowDeck — Templates unifiés : gestionnaire SSR + API interne (cookie).
Cahier des charges : ``docs/architecture-templates-notion-flowdeck.md`` §10.1.
Toutes les surfaces (UI, API v2, agent, scheduler) passent par le même
``TemplateService`` — jamais d'accès direct aux silos legacy (P4, brief §0).
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Body, Query, Request
from fastapi.exceptions import HTTPException
from fastapi.responses import HTMLResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.fd_templates import TemplateService, ensure_registry
logger = logging.getLogger(__name__)
router = APIRouter(tags=["fd-templates"])
def _actor(request: Request) -> dict | None:
try:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
except Exception:
user = None
return user
def _require_actor(request: Request) -> dict:
user = _actor(request)
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
return user
def _as_http(exc: Exception) -> HTTPException:
if isinstance(exc, LookupError):
return HTTPException(404, str(exc) or "Template introuvable")
if isinstance(exc, PermissionError):
return HTTPException(403, str(exc) or "Accès refusé")
if isinstance(exc, ValueError):
return HTTPException(400, str(exc) or "Requête invalide")
logger.exception("templates API")
return HTTPException(500, "Erreur interne")
# ═══════════ Page SSR — gestionnaire /templates (§7.2) ═══════════
@router.get("/templates", response_class=HTMLResponse)
def templates_manager_page(request: Request):
"""Gestionnaire de templates : liste filtrable + side peek d'aperçu."""
from app.routers.board import _sidebar_data as board_sidebar
from app.templating import ENV
sidebar = board_sidebar(request, "", "")
try:
with get_conn() as conn:
ensure_registry(conn)
except Exception:
logger.exception("ensure_registry /templates")
template = ENV.get_template("fd_templates_manager.html")
return template.render(**sidebar)
# ═══════════ API interne — lecture ═══════════
@router.get("/api/templates")
def list_templates(
request: Request,
kind: str | None = Query(default=None),
scope: str | None = Query(default=None),
target_collection_id: int | None = Query(default=None),
q: str | None = Query(default=None),
include_archived: bool = Query(default=False),
):
actor = _actor(request)
with get_conn() as conn:
try:
items = TemplateService.list_templates(
conn, actor, kind=kind, scope=scope,
target_collection_id=target_collection_id, query=q,
include_archived=include_archived,
)
except Exception as exc:
raise _as_http(exc) from None
return {"templates": items}
@router.get("/api/templates/{template_id}")
def get_template(request: Request, template_id: str):
actor = _actor(request)
with get_conn() as conn:
try:
row = TemplateService.get_template(conn, actor, template_id)
from app.services.fd_templates import _row_to_dto
dto = _row_to_dto(conn, row)
preview = TemplateService.preview(conn, actor, template_id)
dto["blocks_preview"] = preview.get("blocks_preview", [])
except Exception as exc:
raise _as_http(exc) from None
return dto
@router.get("/api/collections/{collection_id}/templates")
def list_collection_templates(request: Request, collection_id: int):
"""Menu *New ▾* d'une base : templates de lignes + défaut (§7.3)."""
actor = _actor(request)
with get_conn() as conn:
try:
items = TemplateService.list_templates(
conn, actor, kind="row", target_collection_id=collection_id
)
default_id = next(
(t["id"] for t in items if t.get("is_default")), None
)
except Exception as exc:
raise _as_http(exc) from None
return {"templates": items, "default_id": default_id}
@router.get("/api/templates/{template_id}/runs")
def list_template_runs(request: Request, template_id: str, limit: int = Query(default=20)):
actor = _require_actor(request)
with get_conn() as conn:
try:
TemplateService.get_template(conn, actor, template_id)
rows = conn.execute(
"SELECT * FROM template_runs WHERE template_id=? ORDER BY created_at DESC LIMIT ?",
(template_id, max(1, min(int(limit), 100))),
).fetchall()
except Exception as exc:
raise _as_http(exc) from None
return {"runs": [dict(r) for r in rows]}
# ═══════════ API interne — écriture ═══════════
@router.post("/api/templates")
def create_template(request: Request, body: dict = Body(default={})):
actor = _require_actor(request)
with get_conn() as conn:
try:
# Création depuis une source existante (create_from_*, §2.1).
if body.get("from_page_id"):
dto = _create_from_page(conn, actor, int(body["from_page_id"]), body)
elif body.get("from_collection_id"):
dto = _create_from_collection(
conn, actor, int(body["from_collection_id"]), body
)
elif body.get("from_row"):
dto = _create_from_row(conn, actor, body["from_row"], body)
else:
dto = TemplateService.create_template(conn, actor, body)
conn.commit()
except Exception as exc:
raise _as_http(exc) from None
return dto
@router.patch("/api/templates/{template_id}")
def update_template(request: Request, template_id: str, body: dict = Body(default={})):
actor = _require_actor(request)
with get_conn() as conn:
try:
dto = TemplateService.update_template(conn, actor, template_id, body)
conn.commit()
except Exception as exc:
raise _as_http(exc) from None
return dto
@router.post("/api/templates/{template_id}/duplicate")
def duplicate_template(request: Request, template_id: str, body: dict = Body(default={})):
actor = _require_actor(request)
with get_conn() as conn:
try:
dto = TemplateService.duplicate_template(conn, actor, template_id, body)
conn.commit()
except Exception as exc:
raise _as_http(exc) from None
return dto
@router.delete("/api/templates/{template_id}")
def delete_template(request: Request, template_id: str):
actor = _require_actor(request)
with get_conn() as conn:
try:
result = TemplateService.delete_template(conn, actor, template_id)
conn.commit()
except Exception as exc:
raise _as_http(exc) from None
return result
@router.post("/api/templates/{template_id}/archive")
def archive_template(request: Request, template_id: str):
actor = _require_actor(request)
with get_conn() as conn:
try:
dto = TemplateService.archive_template(conn, actor, template_id, archived=True)
conn.commit()
except Exception as exc:
raise _as_http(exc) from None
return dto
@router.post("/api/templates/{template_id}/restore")
def restore_template(request: Request, template_id: str):
actor = _require_actor(request)
with get_conn() as conn:
try:
dto = TemplateService.archive_template(conn, actor, template_id, archived=False)
conn.commit()
except Exception as exc:
raise _as_http(exc) from None
return dto
@router.put("/api/templates/{template_id}/property-defaults")
def set_property_defaults(request: Request, template_id: str, body: dict = Body(default={})):
actor = _require_actor(request)
with get_conn() as conn:
try:
dto = TemplateService.update_template(
conn, actor, template_id,
{"property_defaults": body.get("property_defaults") or body},
)
conn.commit()
except Exception as exc:
raise _as_http(exc) from None
return dto
@router.put("/api/templates/{template_id}/variables")
def set_variables(request: Request, template_id: str, body: dict = Body(default={})):
actor = _require_actor(request)
variables = body.get("variables") if isinstance(body.get("variables"), list) else body.get("variables_json")
if variables is None and isinstance(body, list):
variables = body
with get_conn() as conn:
try:
dto = TemplateService.update_template(
conn, actor, template_id, {"variables": variables or []}
)
conn.commit()
except Exception as exc:
raise _as_http(exc) from None
return dto
@router.post("/api/templates/{template_id}/set-default")
def set_default(request: Request, template_id: str):
actor = _require_actor(request)
with get_conn() as conn:
try:
dto = TemplateService.set_default(conn, actor, template_id)
conn.commit()
except Exception as exc:
raise _as_http(exc) from None
return dto
@router.post("/api/templates/{template_id}/unset-default")
def unset_default(request: Request, template_id: str):
actor = _require_actor(request)
with get_conn() as conn:
try:
dto = TemplateService.unset_default(conn, actor, template_id)
conn.commit()
except Exception as exc:
raise _as_http(exc) from None
return dto
@router.put("/api/templates/{template_id}/recurrence")
def set_recurrence(request: Request, template_id: str, body: dict = Body(default={})):
actor = _require_actor(request)
spec = None
if body and body.get("rrule"):
spec = {
"rrule": body.get("rrule"),
"timezone": body.get("timezone") or "",
"enabled": body.get("enabled", True),
}
with get_conn() as conn:
try:
recurrence = TemplateService.set_recurrence(conn, actor, template_id, spec)
conn.commit()
except Exception as exc:
raise _as_http(exc) from None
return {"template_id": template_id, "recurrence": recurrence}
@router.post("/api/templates/{template_id}/preview")
def preview_template(request: Request, template_id: str, body: dict = Body(default={})):
actor = _actor(request)
with get_conn() as conn:
try:
result = TemplateService.preview(
conn, actor, template_id, body.get("variables") or {},
title=(body.get("title") or ""),
)
except Exception as exc:
raise _as_http(exc) from None
return result
@router.post("/api/templates/{template_id}/instantiate")
def instantiate_template(request: Request, template_id: str, body: dict = Body(default={})):
actor = _actor(request)
idempotency_key = request.headers.get("Idempotency-Key") or body.get("idempotency_key")
context = body.get("context") or {}
# La cible « page courante » porte l'id de page pour ajout/remplacement.
if body.get("page_id") and "target" not in context:
context["target"] = {"type": "current_page", "page_id": body.get("page_id"),
"mode": body.get("mode") or "fill"}
if body.get("collection_id") and "collection_id" not in context:
context["collection_id"] = body.get("collection_id")
if body.get("parent_page_id") and "parent_page_id" not in context:
context["parent_page_id"] = body.get("parent_page_id")
with get_conn() as conn:
try:
result = TemplateService.instantiate(
conn, actor, template_id, body.get("variables") or {}, context,
idempotency_key=idempotency_key,
run_source="api" if request.headers.get("authorization") else "ui",
)
except Exception as exc:
raise _as_http(exc) from None
return result
@router.post("/api/templates/runs/{run_id}/undo")
def undo_run(request: Request, run_id: str):
actor = _require_actor(request)
with get_conn() as conn:
try:
result = TemplateService.mark_undone(conn, actor, run_id)
except Exception as exc:
raise _as_http(exc) from None
return result
# ═══════════ Création depuis l'existant (create_from_*) ═══════════
def _create_from_page(conn, actor, page_id: int, spec: dict) -> dict:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
raise LookupError("Page source introuvable")
blocks: list = []
try:
if (row["content_format"] or "") == "blocks" and row["content"]:
blocks = json.loads(row["content"]) or []
except (json.JSONDecodeError, TypeError):
blocks = []
return TemplateService.create_template(
conn, actor,
{
"kind": spec.get("kind") or "page",
"name": spec.get("name") or f"{row['title'] or 'Sans titre'} (template)",
"description": spec.get("description") or "",
"icon": spec.get("icon") or (row["page_icon"] if "page_icon" in row.keys() else "📄") or "📄",
"category": spec.get("category") or "",
"scope": spec.get("scope") or "personal",
"workspace_id": spec.get("workspace_id"),
"target_collection_id": spec.get("target_collection_id"),
"blocks": blocks if isinstance(blocks, list) else [],
"variables": spec.get("variables") or [],
"title_template": spec.get("title_template") or f"{row['title'] or ''}",
},
)
def _create_from_collection(conn, actor, collection_id: int, spec: dict) -> dict:
collection = conn.execute(
"SELECT * FROM collections WHERE id=?", (collection_id,)
).fetchone()
if not collection:
raise LookupError("Base source introuvable")
props = [
dict(p)
for p in conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
]
schema = [
{
"name": p["name"],
"type": p["prop_type"] if "prop_type" in p else p.get("type", "text"),
"options": json.loads(p.get("options_json") or "[]"),
}
for p in props
]
manifest = {
"format": "flowdeck-template",
"version": 1,
"collection": {
"name": collection["name"],
"icon": collection["icon"] if "icon" in collection.keys() else "📋",
"schema": schema,
},
}
if spec.get("with_seed_rows"):
manifest["collection"]["seed_rows"] = []
return TemplateService.create_template(
conn, actor,
{
"kind": "database",
"name": spec.get("name") or f"{collection['name']} (template)",
"description": spec.get("description") or (collection["description"] or ""),
"icon": (collection["icon"] if "icon" in collection.keys() else "") or "🗂",
"category": spec.get("category") or "Base",
"scope": spec.get("scope") or "personal",
"workspace_id": spec.get("workspace_id"),
"manifest": manifest,
"variables": spec.get("variables") or [],
},
)
def _create_from_row(conn, actor, from_row: dict | int, spec: dict) -> dict:
if isinstance(from_row, dict):
collection_id = from_row.get("collection_id")
row_id = from_row.get("row_id")
else:
collection_id, row_id = spec.get("target_collection_id"), from_row
if not collection_id or not row_id:
raise ValueError("from_row exige {collection_id, row_id}")
source = conn.execute(
"SELECT * FROM collection_pages WHERE id=? AND collection_id=?",
(row_id, collection_id),
).fetchone()
if not source:
raise LookupError("Ligne source introuvable")
try:
pv = json.loads(source["property_values_json"] or "{}")
except (json.JSONDecodeError, TypeError):
pv = {}
# Les valeurs sont re-cléées par nom de propriété pour survivre à l'export.
props = {
str(p["id"]): p["name"]
for p in conn.execute(
"SELECT id, name FROM collection_properties WHERE collection_id=?",
(collection_id,),
).fetchall()
}
defaults = {props.get(k, k): v for k, v in (pv if isinstance(pv, dict) else {}).items()}
shadow_blocks: list = []
try:
shadow = conn.execute(
"SELECT content, content_format FROM pages WHERE collection_row_id=?",
(row_id,),
).fetchone()
if shadow and (shadow["content_format"] or "") == "blocks" and shadow["content"]:
shadow_blocks = json.loads(shadow["content"]) or []
except Exception:
shadow_blocks = []
return TemplateService.create_template(
conn, actor,
{
"kind": "row",
"name": spec.get("name") or f"{source['title'] or 'Sans titre'} (template)",
"description": spec.get("description") or "",
"icon": spec.get("icon") or "📄",
"category": spec.get("category") or "",
"scope": spec.get("scope") or "personal",
"workspace_id": spec.get("workspace_id"),
"target_collection_id": collection_id,
"blocks": shadow_blocks if isinstance(shadow_blocks, list) else [],
"manifest": {"property_defaults": defaults},
"title_template": spec.get("title_template") or (source["title"] or ""),
"variables": spec.get("variables") or [],
},
)
+87 -2
View File
@@ -1,10 +1,30 @@
"""GitHub OAuth — status and disconnect routes."""
from fastapi import APIRouter, Request
"""GitHub OAuth + repository browsing (mirrors /api/gitea shape for shared UI)."""
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
router = APIRouter(tags=["github"], prefix="/api/github")
def _require_github(request: Request):
"""Return a per-user GitHubAdapter or raise 401 (same contract as Gitea)."""
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.github_adapter import GitHubAdapter
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
with get_conn() as conn:
row = conn.execute(
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='github' "
"AND access_token IS NOT NULL AND access_token != '' ORDER BY updated_at DESC LIMIT 1",
(user["id"],),
).fetchone()
if not row:
raise HTTPException(status_code=401, detail="GitHub account not linked. Go to Settings → Integrations to connect.")
return GitHubAdapter(row["access_token"])
@router.get("/status")
def github_status(request: Request):
"""Check if the current user has GitHub linked."""
@@ -33,3 +53,68 @@ def disconnect_github(request: Request):
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=? AND provider='github'", (user["id"],))
conn.commit()
return {"status": "ok"}
# ── Repo tree (same shape as /api/gitea/projects/{owner}/{repo}/tree) ──
@router.get("/projects/{owner}/{repo}/tree")
async def github_repo_tree(request: Request, owner: str, repo: str, path: str = ""):
"""List one level of a GitHub repo (folders first client-side)."""
adapter = _require_github(request)
try:
tree = await adapter.get_repo_tree(owner, repo)
prefix = path.strip("/").strip()
items = []
seen_dirs: set[str] = set()
for entry in tree:
p = (entry.get("path") or "").strip("/")
if not p:
continue
if prefix:
if p != prefix and not p.startswith(prefix + "/"):
continue
rel = p[len(prefix) + 1:] if p != prefix else ""
else:
rel = p
if not rel:
continue
if "/" in rel:
top = rel.split("/")[0]
dir_path = f"{prefix}/{top}".strip("/") if prefix else top
if dir_path not in seen_dirs:
seen_dirs.add(dir_path)
items.append({"name": top, "path": dir_path, "type": "folder", "size": 0, "sha": ""})
else:
is_dir = entry.get("type") == "tree"
items.append({
"name": rel,
"path": p,
"type": "folder" if is_dir else "file",
"size": entry.get("size", 0),
"sha": entry.get("sha", ""),
})
# De-duplicate (recursive tree can list both tree + blob entries)
dedup: dict[str, dict] = {}
for it in items:
key = it["path"]
if key not in dedup or (dedup[key]["type"] == "file" and it["type"] == "folder"):
dedup[key] = it
return {"tree": sorted(dedup.values(), key=lambda x: (x["type"] != "folder", x["name"].lower()))}
except HTTPException:
raise
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=502)
@router.get("/projects/{owner}/{repo}/file")
async def github_get_file(request: Request, owner: str, repo: str, path: str = ""):
"""Get decoded file content (same shape as Gitea endpoint)."""
adapter = _require_github(request)
if not path:
return JSONResponse({"error": "Path required"}, status_code=400)
try:
content = await adapter.get_file_content(owner, repo, path)
return {"content": content, "path": path}
except HTTPException:
raise
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=502)
+124 -15
View File
@@ -52,21 +52,18 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
else:
url = f"/pages/{page_id}"
# Icon
# Icon — convention Workspace (noms SVG, comme la sidebar) : la liste
# des favoris de la sidebar consomme item.icon via fdIconHtml.
content_format = db_row.get("content_format", "blocks")
if db_row.get("is_folder"):
icon = "📁"
icon = "folder"
elif content_format == "file":
icon = "📄"
icon = "file"
fn = title.lower()
if fn.endswith(".pdf"):
icon = "📕"
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "🖼️"
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
icon = "📜"
if any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "image"
else:
icon = "📝"
icon = "edit"
return {
"id": page_id,
@@ -75,6 +72,8 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
"page_icon": db_row.get("page_icon") or "",
"is_folder": bool(db_row.get("is_folder", 0)),
"source_type": source_type,
"source_icon": {"local": "file", "gitea": "link",
"github": "external-link"}.get(source_type, "file"),
"source_label": _source_label(source_type, workspace),
"workspace": workspace,
"workspace_name": _source_label(source_type, workspace),
@@ -104,7 +103,63 @@ def _apply_source_filter(query: str, params: list, source_type: str) -> tuple[st
def _rows_to_items(rows, uid: int = 1) -> list:
items = [_build_item(dict(r), uid) for r in rows]
return _attach_tags(items)
items = _attach_tags(items)
return _attach_provenance(items, uid)
def _attach_provenance(items: list, uid: int = 1) -> list:
"""Created by / Last edited by / Last visited (colonnes Library).
- created_by : premier auteur des versions, sinon propriétaire du workspace ;
- edited_by : dernier auteur des versions, sinon created_by ;
- visited_at : dernier accès tracé de l'utilisateur (table recents).
Requêtes groupées : 3 allers-retours quelle que soit la taille du lot.
"""
if not items:
return items
ids = [it["id"] for it in items]
placeholders = ",".join("?" for _ in ids)
try:
with get_conn() as conn:
ver = conn.execute(
f"SELECT v.page_id, COALESCE(NULLIF(u.full_name,''), u.login, '') AS name "
f"FROM page_versions v LEFT JOIN users u ON u.id = v.user_id "
f"WHERE v.page_id IN ({placeholders}) ORDER BY v.id", ids,
).fetchall()
first: dict = {}
last: dict = {}
for r in ver:
first.setdefault(r["page_id"], r["name"] or "")
last[r["page_id"]] = r["name"] or ""
own = conn.execute(
f"SELECT p.id, COALESCE(NULLIF(u.full_name,''), u.login, '') AS owner "
f"FROM pages p LEFT JOIN workspaces w ON w.id = p.workspace_id "
f"LEFT JOIN users u ON u.id = w.owner_id "
f"WHERE p.id IN ({placeholders})", ids,
).fetchall()
owners = {r["id"]: r["owner"] or "" for r in own}
try:
vis = conn.execute(
f"SELECT page_id, MAX(accessed_at) AS visited_at FROM recents "
f"WHERE user_id = ? AND page_id IN ({placeholders}) "
f"GROUP BY page_id", [uid, *ids],
).fetchall()
except Exception:
vis = []
visited = {r["page_id"]: r["visited_at"] or "" for r in vis}
except Exception:
logger.exception("_attach_provenance")
return items
for it in items:
pid = it["id"]
author = first.get(pid) or owners.get(pid, "")
it["author"] = author
it["author_initial"] = (author or "?")[0].upper()
edited_by = last.get(pid) or author
it["edited_by"] = edited_by
it["edited_by_initial"] = (edited_by or "?")[0].upper()
it["visited_at"] = visited.get(pid, "")
return items
def _attach_tags(items: list) -> list:
@@ -156,6 +211,59 @@ BASE_SELECT = (
)
# Lignes contenant un bloc AI Meeting Notes (formats JSON navigateur/Python).
MEETING_BLOCK_LIKE = "REPLACE(COALESCE(p.content,''), ' ', '') LIKE '%\"type\":\"meeting\"%'"
NO_MEETING_BLOCK = (
"REPLACE(COALESCE(p.content,''), ' ', '') NOT LIKE '%\"type\":\"meeting\"%'"
)
@router.get("/meetings")
def library_meetings(
request: Request,
source_type: str = Query(default="all"),
):
"""Onglet Library « AI Meeting Notes » : notes de réunion de l'utilisateur.
Regroupées côté client par date (Today / Yesterday / …). Les notes sont
exclues des onglets de navigation courante (recents/workspace/private).
"""
if source_type not in SOURCE_TYPES:
source_type = "all"
uid = _get_user_id(request)
query = (
f"{BASE_SELECT}, "
"(SELECT mt.status FROM meeting_transcripts mt WHERE mt.page_id = p.id "
" ORDER BY mt.id DESC LIMIT 1) AS meeting_status, "
"(SELECT mt.summary FROM meeting_transcripts mt WHERE mt.page_id = p.id "
" ORDER BY mt.id DESC LIMIT 1) AS meeting_summary "
"FROM pages p LEFT JOIN workspaces w ON w.id = p.workspace_id "
"WHERE p.deleted_at IS NULL AND (w.owner_id = ? OR p.workspace_id IS NULL) "
f"AND {MEETING_BLOCK_LIKE}"
)
params: list = [uid]
query, params = _apply_source_filter(query, params, source_type)
query += " ORDER BY p.updated_at DESC LIMIT 100"
with get_conn() as conn:
rows = conn.execute(query, params).fetchall()
items = _rows_to_items(rows, uid)
for it, r in zip(items, rows, strict=True):
d = dict(r)
st = d.get("meeting_status") or "idle"
it["is_meeting"] = True
it["meeting_status"] = st
it["source_label"] = (
"Summary ready" if (d.get("meeting_summary") or "").strip()
else ("Ready to record" if st == "idle"
else st.replace("_", " ").capitalize())
)
_enrich_children(items)
return {"items": items}
@router.get("/recents")
def library_recents(
request: Request,
@@ -167,7 +275,7 @@ def library_recents(
source_type = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section != 'Trash' AND p.deleted_at IS NULL"
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}"
params: list = []
# Hierarchical view: show only root-level pages at the top
@@ -301,7 +409,8 @@ def library_published(
source_type = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.published = 1 AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
# v7.69.6 : les deux drapeaux (lignes historiques issues d'un seul chemin).
query = f"{BASE_SELECT} FROM pages p WHERE (p.is_published = 1 OR p.published = 1) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
params: list = []
if tree:
query += " AND p.parent_id IS NULL"
@@ -326,7 +435,7 @@ def library_private(
source_type = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section = 'Private' AND p.deleted_at IS NULL"
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section = 'Private' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}"
params: list = []
if tree:
query += " AND p.parent_id IS NULL"
@@ -392,7 +501,7 @@ def library_workspace(
source_type = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE (p.workspace != '' OR p.workspace_id IS NOT NULL) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
query = f"{BASE_SELECT} FROM pages p WHERE (p.workspace != '' OR p.workspace_id IS NOT NULL) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}"
params: list = []
if tree:
+329 -2
View File
@@ -185,6 +185,41 @@ def upcoming_meetings(request: Request, days: int = 30):
# ── meetings ───────────────────────────────────────────────────────────────
@router.post("/api/v2/meetings/transcripts")
def create_transcript(request: Request, body: dict = Body(default={})):
"""Crée une note de réunion vide (état idle) pour une page — §6.5.
Idempotent par occurrence calendrier : renvoie la note existante.
"""
user = _auth_user(request, require_write=True)
try:
page_id = int(body.get("page_id", 0))
except (TypeError, ValueError):
raise HTTPException(400, "page_id required") from None
occ = (body.get("event_occurrence_id") or "").strip()[:200]
with get_conn() as conn:
if occ:
row = conn.execute(
"SELECT * FROM meeting_transcripts WHERE event_occurrence_id=?",
(occ,)).fetchone()
if row:
return {**row_to_dict(row), "already_existed": True}
try:
tid = meet.save_transcript(
page_id, "", (body.get("language") or "fr")[:10],
mode=(body.get("mode") or "mic_only")
if (body.get("mode") or "mic_only") in meet.CAPTURE_MODES else "mic_only",
instruction_id=(body.get("instruction_id") or "auto")[:40],
event_occurrence_id=occ)
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
with get_conn() as conn:
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?", (tid,)).fetchone()
audit_log(user, "meeting.create", "page", page_id, f"transcript={tid}", request)
return JSONResponse(status_code=201,
content={**row_to_dict(row), "already_existed": False})
@router.post("/api/v2/meetings/transcribe")
async def upload_and_transcribe(request: Request):
user = _auth_user(request, require_write=True)
@@ -254,13 +289,305 @@ def set_transcript_text(transcript_id: int, request: Request, body: dict = Body(
@router.post("/api/v2/meetings/transcripts/{transcript_id}/summarize")
async def summarize(transcript_id: int, request: Request):
async def summarize(transcript_id: int, request: Request,
body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
out = await meet.summarize_transcript(transcript_id, user.get("id"))
out = await meet.summarize_transcript(
transcript_id, user.get("id"),
allow_empty=bool((body or {}).get("allow_empty")))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
except RuntimeError as exc:
raise HTTPException(502, str(exc)) from None
audit_log(user, "meeting.summarize", "transcript", transcript_id, "", request)
return out
# ── Bloc Notion v7.1.1 : machine à états, consentement, segments, partage ───
@router.get("/api/v2/meetings/instructions")
def meeting_instructions(request: Request):
_auth_user(request)
return {"instructions": [
{"id": k, "label": v["label"]} for k, v in meet.INSTRUCTIONS.items()
]}
# Une page est une « note de réunion » dès qu'elle contient un bloc
# AI Meeting Notes. Le contenu est du JSON (``"type":"meeting"`` côté
# navigateur, ``"type": "meeting"`` côté Python) → comparaison sans espaces.
MEETING_BLOCK_PREDICATE = (
"REPLACE(COALESCE({col},''), ' ', '') LIKE '%\"type\":\"meeting\"%'"
)
@router.get("/api/v2/meetings/notes")
def meeting_notes(request: Request, limit: int = 50):
"""Notes de réunion : pages contenant un bloc AI Meeting Notes.
Alimente la section Meetings de la sidebar (ces pages sont exclues de
l'arbre Workspace, voir ``_load_workspace_pages``). Inclut les notes
jamais enregistrées (aucune ligne ``meeting_transcripts``) avec
``status = idle``.
"""
user = _auth_user(request)
uid = user.get("id")
lim = max(1, min(int(limit or 50), 200))
pred = MEETING_BLOCK_PREDICATE.format(col="p.content")
with get_conn() as conn:
rows = conn.execute(
f"""SELECT p.id, p.title, p.updated_at,
(SELECT mt.status FROM meeting_transcripts mt
WHERE mt.page_id = p.id ORDER BY mt.id DESC LIMIT 1) AS status,
(SELECT mt.summary FROM meeting_transcripts mt
WHERE mt.page_id = p.id ORDER BY mt.id DESC LIMIT 1) AS summary
FROM pages p
LEFT JOIN workspaces w ON w.id = p.workspace_id
WHERE p.deleted_at IS NULL
AND (w.owner_id = ? OR p.workspace_id IS NULL)
AND {pred}
ORDER BY p.updated_at DESC LIMIT ?""",
(uid, lim),
).fetchall()
notes = []
for r in rows:
d = row_to_dict(r)
notes.append({
"id": d["id"],
"title": d.get("title") or "Untitled",
"updated_at": d.get("updated_at") or "",
"status": d.get("status") or "idle",
"has_summary": bool((d.get("summary") or "").strip()),
})
return {"notes": notes, "count": len(notes)}
@router.get("/api/v2/meetings/history")
def meetings_history(request: Request, query: str = "", limit: int = 50):
"""Historique des notes de réunion (passé + recherche plein texte)."""
user = _auth_user(request)
_ = user
like = f"%{query.strip()}%" if query.strip() else "%"
lim = max(1, min(int(limit or 50), 200))
with get_conn() as conn:
rows = conn.execute(
"""SELECT mt.*, p.title AS page_title FROM meeting_transcripts mt
JOIN pages p ON p.id = mt.page_id
WHERE (p.title LIKE ? OR mt.transcript LIKE ?
OR mt.summary LIKE ? OR mt.generated_title LIKE ?)
ORDER BY mt.id DESC LIMIT ?""",
(like, like, like, like, lim),
).fetchall()
out = []
for r in rows:
d = row_to_dict(r)
d["title"] = d.get("generated_title") or d.get("page_title") or "Meeting"
out.append(d)
return {"meetings": out, "count": len(out)}
@router.get("/api/v2/meetings/transcripts/{transcript_id}")
def get_transcript_row(transcript_id: int, request: Request):
_auth_user(request)
try:
return meet.get_transcript(transcript_id)
except ValueError as exc:
raise HTTPException(404, str(exc)) from None
@router.post("/api/v2/meetings/transcripts/{transcript_id}/consents")
def post_consent(transcript_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
entry = meet.record_consent(
transcript_id, method=(body.get("method") or "start_attestation"),
message_ref=(body.get("message_ref") or "")[:200],
attested_by=user.get("id"),
participants=body.get("participants") or [])
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
audit_log(user, "meeting.consent", "transcript", transcript_id,
entry["method"], request)
return entry
@router.post("/api/v2/meetings/transcripts/{transcript_id}/sessions")
def start_session(transcript_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
out = meet.start_session(
transcript_id, mode=(body.get("mode") or "mic_only"),
channels=body.get("channels"), language=(body.get("language") or "fr"),
instruction_id=(body.get("instruction_id") or "auto"),
user_id=user.get("id"))
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
audit_log(user, "meeting.session.start", "transcript", transcript_id,
out.get("mode", ""), request)
return out
@router.post("/api/v2/meetings/transcripts/{transcript_id}/pause")
def pause_session(transcript_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
out = meet.pause_session(transcript_id,
paused_ms_delta=int(body.get("paused_ms") or 0))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
audit_log(user, "meeting.session.pause", "transcript", transcript_id, "", request)
return out
@router.post("/api/v2/meetings/transcripts/{transcript_id}/resume")
def resume_session(transcript_id: int, request: Request):
user = _auth_user(request, require_write=True)
try:
out = meet.resume_session(transcript_id)
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
audit_log(user, "meeting.session.resume", "transcript", transcript_id, "", request)
return out
@router.post("/api/v2/meetings/transcripts/{transcript_id}/stop")
async def stop_session(transcript_id: int, request: Request,
body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
stopped = meet.stop_session(transcript_id,
duration_ms=int((body or {}).get("duration_ms") or 0))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
audit_log(user, "meeting.session.stop", "transcript", transcript_id, "", request)
# Le traitement démarre aussitôt (étapes Thinking persistées) ; en cas
# d'échec LLM le transcript reste consultable (état failed, retry).
# allow_empty : arrêt sans rien de capté → diagnostic honnête (§12.8),
# jamais une ligne coincée en « processing ».
try:
out = await meet.run_processing(
transcript_id, user.get("id"),
allow_empty=bool((body or {}).get("allow_empty")))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
except RuntimeError as exc:
raise HTTPException(502, str(exc)) from None
return {"session": {"id": stopped["id"], "status": "done"}, **out}
@router.post("/api/v2/meetings/transcripts/{transcript_id}/segments")
def append_segments(transcript_id: int, request: Request,
body: dict = Body(default={})):
_auth_user(request, require_write=True)
try:
return meet.append_segments(transcript_id, body.get("segments") or [])
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
@router.get("/api/v2/meetings/transcripts/{transcript_id}/transcript")
def grouped_transcript(transcript_id: int, request: Request):
_auth_user(request)
try:
return meet.grouped_transcript(transcript_id)
except ValueError as exc:
raise HTTPException(404, str(exc)) from None
@router.patch("/api/v2/meetings/transcripts/{transcript_id}/segments/{seq}/speaker")
def patch_speaker(transcript_id: int, seq: int, request: Request,
body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
return meet.set_segment_speaker(transcript_id, seq,
body.get("speaker") or "",
user.get("id"))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
@router.get("/api/v2/meetings/transcripts/{transcript_id}/processing-steps")
def processing_steps(transcript_id: int, request: Request):
_auth_user(request)
try:
tr = meet.get_transcript(transcript_id)
except ValueError as exc:
raise HTTPException(404, str(exc)) from None
import json as _json
try:
steps = _json.loads(tr.get("processing_steps_json") or "[]")
except (TypeError, ValueError):
steps = []
return {"transcript_id": transcript_id, "status": tr.get("status") or "idle",
"steps": steps}
@router.post("/api/v2/meetings/transcripts/{transcript_id}/processing:retry")
async def retry_processing(transcript_id: int, request: Request,
body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
tr = meet.get_transcript(transcript_id)
except ValueError as exc:
raise HTTPException(404, str(exc)) from None
if tr.get("status") not in ("failed", "processing", "done"):
raise HTTPException(400, "nothing to retry — no failed processing")
try:
return await meet.run_processing(
transcript_id, user.get("id"),
allow_empty=bool((body or {}).get("allow_empty")))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
except RuntimeError as exc:
raise HTTPException(502, str(exc)) from None
@router.patch("/api/v2/meetings/transcripts/{transcript_id}/generated-title")
def patch_title(transcript_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
title = (body.get("title") or "").strip()[:200]
if not title:
raise HTTPException(400, "title required")
with get_conn() as conn:
if not conn.execute("SELECT id FROM meeting_transcripts WHERE id=?",
(transcript_id,)).fetchone():
raise HTTPException(404, "Transcript not found")
conn.execute("UPDATE meeting_transcripts SET generated_title=?,"
" title_source='user' WHERE id=?", (title, transcript_id))
conn.commit()
audit_log(user, "meeting.retitle", "transcript", transcript_id, title, request)
return {"transcript_id": transcript_id, "generated_title": title}
@router.post("/api/v2/meetings/transcripts/{transcript_id}/distributions")
def post_distribution(transcript_id: int, request: Request,
body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
try:
out = meet.record_distribution(
transcript_id, channel=(body.get("channel") or "copy_link"),
actor_id=user.get("id"), recipients=(body.get("recipients") or "")[:500],
status=body.get("status") or "created")
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
audit_log(user, "meeting.share", "transcript", transcript_id,
out["channel"], request)
return out
@router.patch("/api/v2/meetings/transcripts/{transcript_id}/notes")
def patch_notes(transcript_id: int, request: Request, body: dict = Body(default={})):
_auth_user(request, require_write=True)
with get_conn() as conn:
if not conn.execute("SELECT id FROM meeting_transcripts WHERE id=?",
(transcript_id,)).fetchone():
raise HTTPException(404, "Transcript not found")
conn.execute("UPDATE meeting_transcripts SET notes_text=? WHERE id=?",
((body.get("notes") or "")[:50000], transcript_id))
conn.commit()
return {"transcript_id": transcript_id, "saved": True}
+4 -1
View File
@@ -27,7 +27,7 @@ import time
from datetime import UTC, datetime, timedelta
from app.db import get_conn
from app.services import notifications
from app.services import notifications, plugins
from app.services.http_client import shared_client
logger = logging.getLogger(__name__)
@@ -479,6 +479,9 @@ async def automation_scheduler():
"""Background loop: fire due cron automations (checked every 60s)."""
while True:
try:
if not plugins.is_enabled("automations"): # plugin OFF = rien de planifié
await asyncio.sleep(60)
continue
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM automations WHERE trigger_type='cron' AND enabled=1"
+224 -23
View File
@@ -66,28 +66,226 @@ def _block_text(b: dict) -> str:
# ── Source resolution: read real textual content for ANY page type ──
# Extensions whose content is plain text / code / markdown (textual exportable).
# Mirrors static/js/code-highlight.js EXT_TO_LANG (single source of truth for
# the viewer lives client-side; this set gates server-side text handling:
# file viewer, copy-to-clipboard, exports, imports).
_TEXTUAL_EXTS = {
"md", "markdown", "txt", "log", "text",
"py", "js", "ts", "jsx", "tsx", "html", "htm", "css", "json", "xml",
"yaml", "yml", "toml", "ini", "cfg", "conf", "env", "sh", "bash", "zsh",
"ps1", "bat", "cmd", "rb", "go", "rs", "java", "c", "cpp", "h", "hpp",
"php", "swift", "kt", "scala", "sql", "r", "vue", "svelte", "astro",
"properties", "gitignore", "dockerfile", "makefile",
"md", "markdown", "mkd", "mdown", "mdwn", "rmd", "qmd",
"txt", "log", "text", "csv", "tsv", "tab", "nfo", "asc",
"py", "pyw", "pyi", "gyp", "gypi",
"js", "mjs", "cjs", "jsx", "ts", "mts", "cts", "tsx",
"html", "htm", "xhtml", "xht", "xml", "xsd", "xsl", "xslt",
"rss", "atom", "plist", "wsf", "svg", "axml",
"vue", "svelte", "astro",
"css", "scss", "sass", "less",
"json", "json5", "jsonc", "geojson", "webmanifest", "har", "avsc",
"yaml", "yml", "toml", "ini", "cfg", "conf", "env", "properties",
"inf", "reg", "url", "prop",
"gitconfig", "gitmodules", "editorconfig",
"sh", "bash", "zsh", "ksh", "mksh", "yash", "fish",
"ps1", "psm1", "psd1", "bat", "cmd",
"rb", "gemspec", "rake", "rbw",
"go", "rs", "java", "jsp", "kt", "kts",
"scala", "sc", "groovy", "gvy", "gradle",
"c", "cpp", "cc", "cxx", "c++", "hh", "hxx", "hcc", "h", "hpp",
"cs", "csx", "cake", "ino", "pde",
"php", "phtml", "swift", "sql", "pgsql", "psql", "mysql", "mssql",
"r", "jl", "lua", "dart",
"pl", "pm", "pod",
"hs", "lhs", "ex", "exs", "eex", "heex", "leex",
"erl", "hrl", "escript",
"clj", "cljs", "cljc", "edn",
"ml", "mli", "fs", "fsi", "fsx", "fsscript",
"nim", "nims", "d", "di",
"m", "mm", "vb", "vbs", "bas", "pas", "pp", "dpr",
"vhd", "vhdl", "v", "vh", "sv", "svh",
"asm", "s", "nasm", "inc",
"ada", "ads", "adb",
"f", "for", "f90", "f95", "f03", "f08",
"cob", "cbl", "cpy",
"dockerfile", "containerfile", "tf", "tfvars", "hcl",
"cmake", "makefile", "mk", "mak", "ninja",
"gql", "graphql", "graphqls", "proto", "thrift",
"vim", "vimrc", "gvimrc", "awk", "gcode", "gco", "nc", "ngc",
"http", "rest", "nginx", "apache", "htaccess", "wat",
"diff", "patch", "rej",
"tex", "latex", "sty", "cls", "dtx", "bib", "bbl",
"gitignore", "gitattributes", "gitkeep", "dockerignore", "npmignore",
"lock", "mod", "sum",
}
# Well-known extensionless / dotfile basenames that are textual code.
_TEXTUAL_FILENAMES = {
"dockerfile", "containerfile", "makefile", "gnumakefile",
"cmakelists.txt", "rakefile", "gemfile", "vagrantfile",
"berksfile", "brewfile", "dangerfile", "fastfile", "podfile",
"go.mod", "go.sum",
".bashrc", ".bash_profile", ".bash_login", ".bash_logout",
".zshrc", ".zshenv", ".profile", ".shrc", ".kshrc",
".vimrc", ".gvimrc", ".viminfo",
".gitconfig", ".editorconfig", ".htaccess",
}
# Exact basenames → highlight.js id (checked before the extension).
_FILENAME_LANG = {
"dockerfile": "dockerfile", "containerfile": "dockerfile",
"makefile": "makefile", "gnumakefile": "makefile",
"cmakelists.txt": "cmake",
"rakefile": "ruby", "gemfile": "ruby", "vagrantfile": "ruby",
"berksfile": "ruby", "brewfile": "ruby", "dangerfile": "ruby",
"fastfile": "ruby", "podfile": "ruby",
"go.mod": "go",
"nginx.conf": "nginx",
".bashrc": "bash", ".bash_profile": "bash", ".bash_login": "bash",
".bash_logout": "bash", ".zshrc": "bash", ".zshenv": "bash",
".profile": "bash", ".shrc": "bash", ".kshrc": "bash",
".vimrc": "vim", ".gvimrc": "vim",
".gitconfig": "ini", ".editorconfig": "ini", ".htaccess": "apache",
}
_CODE_LANG = {
"py": "python", "js": "javascript", "ts": "typescript", "jsx": "javascript",
"tsx": "typescript", "html": "html", "htm": "html", "css": "css",
"json": "json", "xml": "xml", "yaml": "yaml", "yml": "yaml",
"toml": "toml", "ini": "ini", "cfg": "ini", "conf": "ini", "env": "ini",
"sh": "bash", "bash": "bash", "zsh": "bash", "ps1": "powershell",
"bat": "batch", "cmd": "batch", "rb": "ruby", "go": "go", "rs": "rust",
"java": "java", "c": "c", "cpp": "cpp", "h": "c", "hpp": "cpp",
"php": "php", "swift": "swift", "kt": "kotlin", "scala": "scala",
"sql": "sql", "r": "r", "vue": "html", "svelte": "html",
"astro": "html", "properties": "ini", "md": "markdown",
"markdown": "markdown", "txt": "plaintext", "log": "plaintext",
"text": "plaintext",
"py": "python", "pyw": "python", "pyi": "python",
"gyp": "python", "gypi": "python",
"js": "javascript", "mjs": "javascript", "cjs": "javascript",
"jsx": "javascript",
"ts": "typescript", "mts": "typescript", "cts": "typescript",
"tsx": "typescript",
"html": "xml", "htm": "xml", "xhtml": "xml", "xht": "xml",
"xml": "xml", "xsd": "xml", "xsl": "xml", "xslt": "xml",
"rss": "xml", "atom": "xml", "plist": "xml", "wsf": "xml",
"svg": "xml", "axml": "xml",
"vue": "xml", "svelte": "xml", "astro": "xml",
"css": "css", "scss": "scss", "sass": "scss", "less": "less",
"json": "json", "json5": "json", "jsonc": "json",
"geojson": "json", "webmanifest": "json", "har": "json", "avsc": "json",
"yaml": "yaml", "yml": "yaml",
"toml": "ini", "ini": "ini", "cfg": "ini", "conf": "ini",
"env": "ini", "properties": "ini", "prop": "ini",
"inf": "ini", "reg": "ini", "url": "ini",
"gitconfig": "ini", "gitmodules": "ini", "editorconfig": "ini",
"ninja": "ini",
"md": "markdown", "markdown": "markdown", "mkd": "markdown",
"mdown": "markdown", "mdwn": "markdown", "rmd": "markdown",
"qmd": "markdown",
"tex": "latex", "latex": "latex", "sty": "latex", "cls": "latex",
"dtx": "latex",
"sql": "sql", "pgsql": "sql", "psql": "sql", "mysql": "sql",
"mssql": "sql",
"sh": "bash", "bash": "bash", "zsh": "bash", "ksh": "bash",
"mksh": "bash", "yash": "bash",
"fish": "shell",
"ps1": "powershell", "psm1": "powershell", "psd1": "powershell",
"bat": "dos", "cmd": "dos",
"rb": "ruby", "gemspec": "ruby", "rake": "ruby", "rbw": "ruby",
"go": "go", "rs": "rust",
"java": "java", "jsp": "java",
"kt": "kotlin", "kts": "kotlin",
"scala": "scala", "sc": "scala",
"groovy": "groovy", "gvy": "groovy", "gradle": "groovy",
"c": "c", "h": "c",
"cpp": "cpp", "cc": "cpp", "cxx": "cpp", "c++": "cpp",
"hh": "cpp", "hxx": "cpp", "hcc": "cpp", "hpp": "cpp",
"ino": "cpp", "pde": "cpp",
"cs": "csharp", "csx": "csharp", "cake": "csharp",
"php": "php", "phtml": "php-template",
"swift": "swift", "r": "r", "jl": "julia", "lua": "lua",
"dart": "dart", "pl": "perl", "pm": "perl", "pod": "perl",
"hs": "haskell", "lhs": "haskell",
"ex": "elixir", "exs": "elixir", "eex": "elixir",
"heex": "elixir", "leex": "elixir",
"erl": "erlang", "hrl": "erlang", "escript": "erlang",
"clj": "clojure", "cljs": "clojure", "cljc": "clojure", "edn": "clojure",
"ml": "ocaml", "mli": "ocaml",
"fs": "fsharp", "fsi": "fsharp", "fsx": "fsharp", "fsscript": "fsharp",
"nim": "nim", "nims": "nim",
"d": "d", "di": "d",
"m": "objectivec", "mm": "objectivec",
"vb": "vbnet", "vbs": "vbnet", "bas": "vbnet",
"pas": "delphi", "pp": "delphi", "dpr": "delphi",
"vhd": "vhdl", "vhdl": "vhdl",
"v": "verilog", "vh": "verilog", "sv": "verilog", "svh": "verilog",
"asm": "x86asm", "s": "x86asm", "nasm": "x86asm", "inc": "x86asm",
"ada": "ada", "ads": "ada", "adb": "ada",
"f": "fortran", "for": "fortran", "f90": "fortran", "f95": "fortran",
"f03": "fortran", "f08": "fortran",
"cob": "plaintext", "cbl": "plaintext", "cpy": "plaintext",
"dockerfile": "dockerfile",
"tf": "plaintext", "tfvars": "plaintext", "hcl": "plaintext",
"cmake": "cmake",
"makefile": "makefile", "mk": "makefile", "mak": "makefile",
"gql": "graphql", "graphql": "graphql", "graphqls": "graphql",
"proto": "protobuf", "thrift": "thrift",
"vim": "vim", "vimrc": "vim", "gvimrc": "vim",
"awk": "awk",
"gcode": "gcode", "gco": "gcode", "nc": "gcode", "ngc": "gcode",
"http": "http", "rest": "http",
"nginx": "nginx", "apache": "apache", "htaccess": "apache",
"wat": "wasm",
"diff": "diff", "patch": "diff", "rej": "diff",
"bib": "plaintext", "bbl": "plaintext",
"txt": "plaintext", "log": "plaintext", "text": "plaintext",
"csv": "plaintext", "tsv": "plaintext", "tab": "plaintext",
"nfo": "plaintext", "asc": "plaintext",
"lock": "plaintext",
"gitignore": "plaintext", "gitattributes": "plaintext",
"gitkeep": "plaintext", "dockerignore": "plaintext",
"npmignore": "plaintext",
}
# Aliases users may type in markdown fences / old block labels.
_CODE_ALIASES = {
"py": "python", "js": "javascript", "jsx": "javascript",
"ts": "typescript", "tsx": "typescript",
"html": "xml", "htm": "xml", "markup": "xml",
"yml": "yaml", "md": "markdown",
"sh": "bash", "zsh": "bash", "console": "shell",
"ps1": "powershell", "ps": "powershell",
"bat": "dos", "cmd": "dos", "batch": "dos",
"c++": "cpp", "c#": "csharp", "cs": "csharp",
"kt": "kotlin", "rb": "ruby", "rs": "rust", "pl": "perl",
"toml": "ini", "cfg": "ini", "conf": "ini", "env": "ini",
"tex": "latex", "docker": "dockerfile", "make": "makefile",
"plain text": "plaintext", "plain": "plaintext", "text": "plaintext",
"none": "plaintext", "": "plaintext",
}
# highlight.js ids served by static/js/highlight.min.js + highlight-extra.min.js.
_HLJS_LANGS = frozenset({
"ada", "apache", "armasm", "awk", "bash", "c", "clojure", "cmake", "cpp",
"csharp", "css", "d", "dart", "delphi", "diff", "dockerfile", "dos",
"elixir", "erlang", "fsharp", "fortran", "gcode", "go",
"graphql", "groovy", "haskell", "http", "ini", "java", "javascript",
"json", "julia", "kotlin", "latex", "less", "lua", "makefile",
"markdown", "matlab", "nginx", "nim", "objectivec", "ocaml", "perl",
"php", "php-template", "plaintext", "powershell", "protobuf", "python",
"python-repl", "r", "ruby", "rust", "scala", "scss", "shell", "sql",
"stan", "stata", "swift", "thrift", "typescript", "vbnet", "verilog",
"vhdl", "vim", "wasm", "x86asm", "xml", "yaml",
})
def normalize_code_lang(lang: str | None) -> str:
"""Normalize any code language label to a bundled highlight.js id.
Accepts highlight.js ids, file extensions, markdown fence names and the
legacy ``Plain Text`` label. Unknown values degrade to ``plaintext``.
"""
lang = (lang or "").strip().lower()
if not lang or lang in ("plain text", "none"):
return "plaintext"
if lang in _CODE_ALIASES:
lang = _CODE_ALIASES[lang]
if lang in _CODE_LANG.values() or lang in _HLJS_LANGS:
return lang
return "plaintext"
def code_lang_for_file(filename: str | None) -> str:
"""Best highlight.js id for an uploaded / repo file name."""
name = (filename or "").replace("\\", "/").rsplit("/", 1)[-1].lower()
if not name:
return "plaintext"
if name in _FILENAME_LANG:
return _FILENAME_LANG[name]
ext = name.rsplit(".", 1)[-1] if "." in name else ""
return _CODE_LANG.get(ext, "plaintext")
_MARKDOWN_MIMES = {"text/markdown", "text/x-markdown", "application/octet-stream"}
@@ -119,7 +317,8 @@ def _file_text(page: dict) -> str | None:
name = (rel.rsplit("/", 1)[-1] or "").lower()
ext = name.rsplit(".", 1)[-1] if "." in name else ""
mime = (meta.get("mime_type") or "").lower()
if not (ext in _TEXTUAL_EXTS or mime.startswith("text/")):
if not (ext in _TEXTUAL_EXTS or name in _TEXTUAL_FILENAMES
or mime.startswith("text/")):
return None
try:
full = (_data_root() / rel).resolve()
@@ -161,8 +360,7 @@ def _page_source(page: dict):
mime = (meta.get("mime_type") or "").lower()
if ext in ("md", "markdown") or mime in _MARKDOWN_MIMES or mime.startswith("text/markdown"):
return "md", text
lang = _CODE_LANG.get(ext, "plaintext")
return "code", (text, lang)
return "code", (text, code_lang_for_file(name))
# Unknown format (e.g. legacy) -> try to dump as raw text
if content.strip():
@@ -497,7 +695,7 @@ def blocks_to_markdown(blocks: list) -> str:
out.append("---")
elif t == "code":
lang = b.get("language") or ""
out.append(f"```{lang}\n{c}\n```")
out.append(f"```{normalize_code_lang(lang) if lang else ''}\n{c}\n```")
elif t == "toggle":
out.append(f"### {c}")
if b.get("children"):
@@ -615,8 +813,11 @@ def blocks_to_html(blocks: list) -> str:
parts.append("<hr>")
elif t == "code":
lang = b.get("language") or ""
hljs_lang = normalize_code_lang(lang)
label = f'<div class="code-lang">{_text(lang)}</div>' if lang else ""
parts.append(f"<pre>{label}<code>{c}</code></pre>")
parts.append(
f'<pre>{label}<code class="language-{hljs_lang}">{c}</code></pre>'
)
elif t == "math":
parts.append(f'<div class="math">\\[{c}\\]</div>')
elif t == "table_of_contents":
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
"""FlowDeck — Projection Form builder -> pipeline public /f/ (§16.5).
Le builder ecrit ``form_questions`` + reglages de la vue form ; le
projecteur regenere ``collections.form_config_json`` (format v6.8 inchange)
consomme par GET/POST /f/<token>. Le public ne lit jamais les tables du
builder : zero regression possible sur les liens deja diffuses.
"""
from __future__ import annotations
import json
def project_form_config(view_config: dict, questions: list[dict]) -> dict:
"""Construit le ``form_config_json`` v6.8 depuis le builder."""
cfg = dict(view_config or {})
sharing = cfg.get("sharing") or {}
submit = cfg.get("submit_screen") or {}
fields = []
for q in sorted(questions, key=lambda x: x.get("position", 0)):
fields.append({
"property": q.get("property_name"),
"label": q.get("label_override") or q.get("property_name"),
"required": bool(q.get("required")),
"help": q.get("help_text") or "",
"widget": q.get("widget") or "auto",
"max_selections": q.get("max_selections"),
"conditional": json.loads(q["conditional_json"]) if q.get("conditional_json") else None,
})
return {
"title": cfg.get("title") or "Form",
"description": cfg.get("description") or "",
"fields": fields,
"sharing": {
"audience": sharing.get("audience", "workspace"),
"anonymous": bool(sharing.get("anonymous", False)),
"respondent_access": sharing.get("respondent_access", "none"),
"branding": sharing.get("branding", True),
},
"submit": {
"button_text": submit.get("button_text", "Envoyer"),
"button_color": submit.get("button_color", "blue"),
"confirmation_title": submit.get("confirmation_title", "Merci !"),
"confirmation_body": submit.get("confirmation_body", "Votre réponse a été enregistrée."),
},
"form_view_id": cfg.get("form_view_id"),
}
def default_questions_for(properties: list[dict]) -> list[dict]:
"""Questions initiales depuis les proprietes exposables (hors formules)."""
skip = {"formula", "rollup", "button", "unique_id",
"created_time", "created_by", "last_edited_time", "last_edited_by"}
out = []
for i, p in enumerate(properties):
if p.get("prop_type") in skip:
continue
out.append({
"property_name": p.get("name"),
"position": i,
"label_override": None,
"sync_label": 1,
"help_text": "",
"required": 0,
"widget": "auto",
"max_selections": None,
"conditional_json": None,
})
return out
+154
View File
@@ -0,0 +1,154 @@
"""FlowDeck — Geocoding Service (vue Map §13.4).
Interface de fournisseur unique : search / resolve / reverse.
Implementations : ``none`` (defaut — saisie manuelle uniquement, aucun
appel externe), ``nominatim`` (OpenStreetMap, User-Agent identifie,
<= 1 req/s, cache obligatoire), ``custom`` (endpoint compatible Nominatim).
Le cache SQLite (migration 45, table ``geocode_cache``) est consulte
avant tout appel. Les journaux ne contiennent jamais d'adresses en clair
au-dela de compteurs (le texte reste dans le cache, purgeable).
"""
from __future__ import annotations
import hashlib
import json
import logging
import time
import urllib.parse
import urllib.request
from typing import Any
logger = logging.getLogger(__name__)
_last_call_ts = 0.0
def _norm(query: str) -> str:
return " ".join((query or "").lower().split())
def query_hash(query: str, provider: str) -> str:
return hashlib.sha256(f"{provider}:{_norm(query)}".encode()).hexdigest()
def get_settings(conn=None) -> dict:
"""Lit la config geocodeur (instance). Defauts surs : provider=none."""
try:
from app.config import settings as _s # type: ignore
provider = getattr(_s, "GEOCODER_PROVIDER", None) or "none"
endpoint = getattr(_s, "GEOCODER_ENDPOINT", None) or "https://nominatim.openstreetmap.org"
return {"provider": provider, "endpoint": endpoint.rstrip("/")}
except Exception:
return {"provider": "none", "endpoint": "https://nominatim.openstreetmap.org"}
def cache_get(conn, qhash: str) -> dict | None:
try:
row = conn.execute(
"SELECT result_json, lat, lng, hit_count FROM geocode_cache WHERE query_hash=?",
(qhash,),
).fetchone()
except Exception:
return None
if not row:
return None
try:
conn.execute("UPDATE geocode_cache SET hit_count = hit_count + 1 WHERE query_hash=?", (qhash,))
except Exception:
pass
try:
data = json.loads(row["result_json"]) if row["result_json"] else {}
except (json.JSONDecodeError, TypeError):
data = {}
data.setdefault("lat", row["lat"])
data.setdefault("lng", row["lng"])
return data
def cache_put(conn, qhash: str, provider: str, query_text: str, result: dict) -> None:
try:
conn.execute(
"""INSERT OR REPLACE INTO geocode_cache
(query_hash, provider, query_text, result_json, lat, lng, created_at, hit_count)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), COALESCE(
(SELECT hit_count FROM geocode_cache WHERE query_hash=?), 0))""",
(qhash, provider, query_text[:300], json.dumps(result),
result.get("lat"), result.get("lng"), qhash),
)
except Exception:
pass
def _http_json(url: str, params: dict, timeout: float = 3.0) -> Any:
global _last_call_ts
# Politesse Nominatim : <= 1 req/s.
wait = 1.0 - (time.time() - _last_call_ts)
if wait > 0:
time.sleep(wait)
full = url + "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(full, headers={
"User-Agent": "FlowDeck/1.0 (self-hosted; contact: admin)",
"Accept": "application/json",
})
with urllib.request.urlopen(req, timeout=timeout) as resp:
_last_call_ts = time.time()
return json.loads(resp.read().decode("utf-8", "replace"))
def search(conn, query: str, limit: int = 5) -> list[dict]:
"""Autocompletion d'adresses. Vide quand provider=none."""
cfg = get_settings(conn)
provider = cfg["provider"]
if not query or len(query.strip()) < 3 or provider == "none":
return []
qhash = query_hash(f"search:{query}:{limit}", provider)
cached = cache_get(conn, qhash)
if cached and isinstance(cached.get("suggestions"), list):
return cached["suggestions"][:limit]
base = cfg["endpoint"]
try:
if provider in ("nominatim", "custom"):
data = _http_json(f"{base}/search", {
"q": query, "format": "jsonv2", "limit": limit,
"addressdetails": 1,
})
suggestions = [{
"name": (d.get("display_name") or "").split(",")[0].strip(),
"address": d.get("display_name", ""),
"lat": float(d["lat"]), "lng": float(d["lon"]),
"provider": provider, "place_id": str(d.get("place_id", "")),
} for d in (data or []) if d.get("lat") and d.get("lon")]
else:
suggestions = []
except Exception as exc:
logger.warning("geocode search failed: %s", type(exc).__name__)
return []
try:
cache_put(conn, qhash, provider, query, {"suggestions": suggestions})
conn.commit()
except Exception:
pass
return suggestions
def resolve(conn, query: str) -> dict | None:
"""Adresse -> {lat, lng, ...}. Cache d'abord, None si echec."""
cfg = get_settings(conn)
provider = cfg["provider"]
if not query or provider == "none":
return None
qhash = query_hash(f"resolve:{query}", provider)
cached = cache_get(conn, qhash)
if cached and cached.get("lat") is not None:
return cached
results = search(conn, query, limit=1)
if not results:
return None
first = dict(results[0])
try:
cache_put(conn, qhash, provider, query, first)
conn.commit()
except Exception:
pass
return first
+15 -3
View File
@@ -7,7 +7,11 @@ from __future__ import annotations
from pathlib import Path
from app.services.export import _CODE_LANG, _TEXTUAL_EXTS
from app.services.export import (
_TEXTUAL_EXTS,
_TEXTUAL_FILENAMES,
code_lang_for_file,
)
from app.services.importers.base import ImportPage, ImportResult
_MD_EXTS = {"md", "markdown"}
@@ -17,6 +21,14 @@ def _ext(path: str) -> str:
return Path(path).suffix.lower().lstrip(".")
def _basename(path: str) -> str:
return path.replace("\\", "/").rsplit("/", 1)[-1].lower()
def _is_textual(path: str) -> bool:
return _ext(path) in _TEXTUAL_EXTS or _basename(path) in _TEXTUAL_FILENAMES
def build_repo_result(
files: list[tuple[str, str]],
*,
@@ -34,7 +46,7 @@ def build_repo_result(
if ext in _MD_EXTS:
markdown = content
else:
lang = _CODE_LANG.get(ext, "")
lang = code_lang_for_file(clean)
markdown = f"```{lang}\n{content.rstrip()}\n```"
parts = clean.split("/")
result.pages.append(ImportPage(
@@ -69,7 +81,7 @@ async def fetch_forge_repo(
files: list[tuple[str, str]] = []
for meta in metas:
file_path = meta.get("path") or ""
if _ext(file_path) not in _TEXTUAL_EXTS:
if not _is_textual(file_path):
continue
if int(meta.get("size") or 0) > max_file_bytes:
continue
+9 -3
View File
@@ -11,8 +11,8 @@ from urllib.parse import urlparse
import httpx
from app.services import http_client
from app.services.export import markdown_to_blocks
from app.services.http_client import shared_client
from app.services.importers.base import ImportPage, ImportResult
from app.services.importers.html_notes import _html_to_markdown
@@ -26,7 +26,10 @@ def _is_public_host(host: str) -> bool:
return False
try:
infos = socket.getaddrinfo(host, None)
except socket.gaierror:
except OSError:
# gaierror, socket.timeout, herror… : le garde-fou doit TOUJOURS
# renvoyer un booléen, jamais lever — sinon l'appelant tombe dans son
# `except Exception` (bookmarks) au lieu d'un refus explicite.
return False
for info in infos:
address = info[4][0]
@@ -62,7 +65,10 @@ async def fetch_url_result(url: str, *, transport: httpx.BaseTransport | None =
safe_url = _validate_url(url)
result = ImportResult(source="url")
try:
async with shared_client(
# Résolu à l'APPEL (et non capturé à l'import) : un import tardif de
# ce module pendant qu'un test patche `http_client.shared_client`
# figerait la factory moquée pour tout le reste du processus.
async with http_client.shared_client(
timeout=15, follow_redirects=True, transport=transport,
headers={"User-Agent": "FlowDeck-Importer/1.0"},
) as client:
+8 -3
View File
@@ -37,7 +37,9 @@ logger = logging.getLogger(__name__)
PROVIDERS = {
"openai": ("https://api.openai.com/v1", "gpt-4o"),
"anthropic": ("https://api.anthropic.com/v1", "claude-opus-4-8"),
"mistral": ("https://api.mistral.ai/v1", "mistral-large-latest"),
# mistral-small est servi par tous les plans d'abonnement ;
# mistral-large → 403 tier_not_allowed sur les plans basiques.
"mistral": ("https://api.mistral.ai/v1", "mistral-small-latest"),
"cohere": ("https://api.cohere.ai/compatibility/v1", "command-a-plus-05-2026"),
"google": ("https://generativelanguage.googleapis.com/v1beta/openai", "gemini-2.0-flash"),
"groq": ("https://api.groq.com/openai/v1", "llama-3.3-70b-versatile"),
@@ -94,8 +96,11 @@ PROVIDER_LABELS: dict[str, str] = {
PROVIDER_MODELS: dict[str, list[str]] = {
"openai": ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "o3-mini", "gpt-4-turbo"],
"anthropic": ["claude-opus-4-8", "claude-sonnet-4-5", "claude-3-5-sonnet", "claude-haiku-4-5"],
"mistral": ["mistral-large-latest", "mistral-medium-latest", "mistral-small-latest",
"codestral-latest", "open-mistral-nemo", "pixtral-large-latest"],
# Ordre = ordre de sélection du test de connexion : les modèles servis par
# tous les plans d'abord (large/pixtral-large = 403 tier_not_allowed en plan
# basique), les modèles à plan élevé en fin de liste.
"mistral": ["mistral-small-latest", "mistral-medium-latest", "open-mistral-nemo",
"codestral-latest", "mistral-large-latest", "pixtral-large-latest"],
"cohere": ["command-a-plus-05-2026", "command-r-plus", "command-r", "command-a-03-2025"],
"google": ["gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-1.5-pro", "gemini-1.5-flash"],
"groq": ["llama-3.3-70b-versatile", "llama-3.1-8b-instant",
+4 -1
View File
@@ -22,7 +22,10 @@ from app.services.llm_client import PROVIDER_LABELS, PROVIDER_MODELS, PROVIDERS
# before being exposed as "usable models". NVIDIA exposes all of its catalog
# (embeddings, rerank, image/video/audio gen…) many of which answer 404 on
# chat completions — the exact failure the user hit.
_CHAT_VALIDATED_PROVIDERS = frozenset({"nvidia"})
# Mistral : le /models liste des modèles hors du plan d'abonnement du compte
# (403 « tier_not_allowed » ex. mistral-large sur plan basique) — la sonde
# chat ne garde que ceux réellement servis par la CLÉ de l'utilisateur.
_CHAT_VALIDATED_PROVIDERS = frozenset({"nvidia", "mistral"})
# Markers that identify clearly non-chat models (embeddings, rerank, media gen…).
_NON_CHAT_MARKERS = (
+668 -49
View File
@@ -1,19 +1,25 @@
"""FlowDeck — AI Meeting Notes v2 (v7.1.0).
"""FlowDeck — AI Meeting Notes façon Notion (v7.1.1).
Upload audio → optional server transcription (``STT_COMMAND``, e.g. whisper)
→ AI summary (``AIWritingService.summarize``, offline-capable) → fires
``meeting.summarized`` so custom agents pick it up (Notion 07/2026 pattern).
Refonte alignée sur ``docs/architecture-meeting-notion-flowdeck.md`` (§3A/3B) :
machine à états du bloc, révélation progressive des onglets, consentement
bloquant journalisé, segments groupés par source, étapes Thinking persistées,
résumé structuré avec citations obligatoires, classification de complétude,
titre généré (diagnostic honnête si bref/incomplet), barre de partage tracée.
Without ``STT_COMMAND`` the server stores the audio and accepts a manual
``transcript`` (client-side transcription). Nothing here requires new pip
dependencies. See ``docs/V71_Calendar_Meetings.md``.
Stockage SQLite monolithique : ``meeting_transcripts`` + colonnes JSON
(migration 38) + ``meeting_consent_log`` / ``meeting_distributions``
append-only. Rien n'exige de dépendance pip supplémentaire.
"""
from __future__ import annotations
import json
import logging
import os
import re
import shutil
import subprocess
import time
from datetime import UTC, datetime
from pathlib import Path
from app.config import settings
@@ -24,6 +30,37 @@ logger = logging.getLogger(__name__)
AUDIO_EXTENSIONS = {"mp3", "wav", "m4a", "ogg", "flac", "aac"}
MAX_AUDIO_BYTES = 100 * 1024 * 1024
# États du bloc (§6.5) — transitions toujours via le serveur.
BLOCK_STATES = ("idle", "recording", "paused", "processing", "done", "failed")
CAPTURE_MODES = ("mic_only", "tab_plus_mic", "import")
# Instructions de résumé (§12.4) : auto + presets par type + custom libre.
INSTRUCTIONS = {
"auto": {"label": "Auto", "prompt": "Résumé automatique adapté au contenu détecté."},
"standup": {"label": "Standup", "prompt": "Format standup : hier / aujourd'hui / blocages, par personne."},
"team": {"label": "Team meeting", "prompt": "Format équipe : sujets, décisions, actions avec responsables."},
"sales": {"label": "Sales call", "prompt": "Format commercial : besoin, objections, next steps, montant."},
"one_to_one": {"label": "1:1", "prompt": "Format 1:1 : points du collaborateur, feedback, engagements."},
"interview": {"label": "Interview", "prompt": "Format entretien : parcours, forces, doutes, décision."},
}
# Étapes Thinking exposées (§12.7), dans l'ordre — libellés FR/EN gérés côté UI.
PROCESSING_STEPS = (
("reading_transcript", "Reading transcript…"),
("analyzing_transcript", "Analyzing the transcript"),
("resolving_references", "Thinking about how to refer to people"),
("understanding_content", "Understanding the content"),
("classifying_completeness", "Checking completeness"),
("generating_summary", "Writing the summary"),
("generating_title", "Choosing a title"),
("validating_citations", "Checking citations"),
)
CONSENT_METHODS = (
"start_attestation", "verbal", "chat_text", "audio_message",
"meet_addon", "workspace_enforced",
)
class TranscriptionUnavailable(RuntimeError):
"""Raised when no transcription backend is configured."""
@@ -36,26 +73,609 @@ def meetings_dir() -> Path:
return d
def _now() -> str:
return datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")
def _loads(raw: str, default):
try:
v = json.loads(raw or "")
return v if v is not None else default
except (TypeError, ValueError):
return default
def get_transcript(transcript_id: int) -> dict:
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM meeting_transcripts WHERE id=?", (transcript_id,)
).fetchone()
if not row:
raise ValueError("transcript not found")
return dict(row)
def _update(transcript_id: int, **fields) -> None:
if not fields:
return
cols = ", ".join(f"{k}=?" for k in fields)
with get_conn() as conn:
conn.execute(
f"UPDATE meeting_transcripts SET {cols} WHERE id=?",
(*fields.values(), transcript_id),
)
conn.commit()
def save_transcript(page_id: int, transcript: str, language: str = "fr",
audio_path: str = "") -> int:
audio_path: str = "", **kw) -> int:
"""Crée une note de réunion (état ``idle``). Rétro-compatible v7.1.0."""
with get_conn() as conn:
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
raise ValueError("page not found")
occ = (kw.get("event_occurrence_id") or "").strip()
if occ and conn.execute(
"SELECT id FROM meeting_transcripts WHERE event_occurrence_id=?", (occ,)
).fetchone():
raise ValueError("a meeting note already exists for this calendar occurrence")
cur = conn.execute(
"""INSERT INTO meeting_transcripts (page_id, audio_path, transcript, language)
VALUES (?,?,?,?)""",
(page_id, audio_path, transcript or "", language or "fr"))
"""INSERT INTO meeting_transcripts
(page_id, audio_path, transcript, language, status, mode,
instruction_id, event_occurrence_id, notes_text)
VALUES (?,?,?,?,?,?,?,?,?)""",
(page_id, audio_path, transcript or "", language or "fr",
"idle", kw.get("mode") or "mic_only",
(kw.get("instruction_id") or "auto")[:40], occ,
kw.get("notes_text") or ""),
)
conn.commit()
return cur.lastrowid
def transcribe_audio(audio_path: str, language: str = "fr") -> str:
"""Transcribe with ``STT_COMMAND`` (``{cmd} {file}` → stdout text).
# ── Consentement (§14.1) : porte bloquante, journal append-only ──────────────
Example: ``STT_COMMAND="whisper --language fr --output_format txt --output_dir /tmp"``
(command must print or be adapted — stdout is preferred). Raises
:class:`TranscriptionUnavailable` when unconfigured.
def record_consent(transcript_id: int, *, method: str = "start_attestation",
message_ref: str = "", attested_by: int | None = None,
participants: list | None = None) -> dict:
if method not in CONSENT_METHODS:
raise ValueError(f"unknown consent method: {method}")
get_transcript(transcript_id) # 404 si inconnu
entry = {
"method": method,
"message_ref": message_ref or "",
"attested_by": attested_by,
"participants": participants or [],
"attested_at": _now(),
}
with get_conn() as conn:
conn.execute(
"""INSERT INTO meeting_consent_log
(transcript_id, method, message_ref, attested_by, participants_json)
VALUES (?,?,?,?,?)""",
(transcript_id, method, message_ref or "",
attested_by, json.dumps(participants or [], ensure_ascii=False)),
)
conn.execute(
"UPDATE meeting_transcripts SET consent_json=? WHERE id=?",
(json.dumps(entry, ensure_ascii=False), transcript_id),
)
conn.commit()
return entry
def require_consent(tr: dict) -> dict:
consent = _loads(tr.get("consent_json") or "", None)
if consent:
return consent
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM meeting_consent_log WHERE transcript_id=? "
"ORDER BY id DESC LIMIT 1", (tr["id"],)
).fetchone()
if not row:
raise ValueError("consent required — record consent before capturing")
return {
"method": row["method"], "message_ref": row["message_ref"],
"attested_by": row["attested_by"],
"participants": _loads(row["participants_json"], []),
}
# ── Sessions de capture : start / pause / resume / stop ─────────────────────
def start_session(transcript_id: int, *, mode: str = "mic_only",
channels: list | None = None, language: str = "fr",
instruction_id: str = "auto", user_id: int | None = None) -> dict:
if mode not in CAPTURE_MODES:
raise ValueError(f"unknown capture mode: {mode}")
tr = get_transcript(transcript_id)
if tr.get("status") in ("recording", "processing"):
raise ValueError(f"meeting is {tr['status']} — stop it first")
consent = require_consent(tr)
instr = (instruction_id or "auto").strip()[:40]
snapshot = {
"instruction_id": instr if instr in INSTRUCTIONS else "custom",
"instruction_raw": instr,
"definition": INSTRUCTIONS.get(instr, {"label": instr, "prompt": instr}),
"frozen_at": _now(),
}
chans = channels or (
[{"kind": "self", "label": "Microphone"}] if mode == "mic_only"
else [{"kind": "self", "label": "My microphone"},
{"kind": "remote", "label": "Tab audio"}] if mode == "tab_plus_mic"
else []
)
_update(transcript_id, status="recording", mode=mode,
channels_json=json.dumps(chans, ensure_ascii=False),
language=(language or tr.get("language") or "fr")[:10],
instruction_id=instr,
instruction_snapshot=json.dumps(snapshot, ensure_ascii=False),
segments_json="[]", processing_steps_json="[]", summary="",
summary_json="", generated_title="", completeness_class="",
duration_ms=0, paused_ms=0, started_at=_now(), stopped_at=None)
return get_transcript(transcript_id) | {"_consent": consent}
def pause_session(transcript_id: int, *, paused_ms_delta: int = 0) -> dict:
tr = get_transcript(transcript_id)
if tr.get("status") != "recording":
raise ValueError("only a recording session can be paused")
_update(transcript_id, status="paused",
paused_ms=int(tr.get("paused_ms") or 0) + max(0, int(paused_ms_delta or 0)))
return get_transcript(transcript_id)
def resume_session(transcript_id: int) -> dict:
tr = get_transcript(transcript_id)
if tr.get("status") != "paused":
raise ValueError("only a paused session can be resumed")
require_consent(tr)
_update(transcript_id, status="recording")
return get_transcript(transcript_id)
def stop_session(transcript_id: int, *, duration_ms: int = 0) -> dict:
tr = get_transcript(transcript_id)
if tr.get("status") not in ("recording", "paused"):
raise ValueError("nothing to stop — no live session")
require_consent(tr)
_update(transcript_id, status="processing", stopped_at=_now(),
duration_ms=max(int(duration_ms or 0), int(tr.get("duration_ms") or 0)))
return get_transcript(transcript_id)
# ── Segments : append (temps réel), correction du locuteur ───────────────────
def _next_seq(segments: list) -> int:
return (max((s.get("seq", 0) for s in segments), default=0) + 1) if segments else 1
def append_segments(transcript_id: int, segments: list) -> dict:
"""Persiste les segments finaux du client (STT navigateur / import).
Chaque item : {channel, source_label, speaker, text, start_ms, end_ms,
starts_mid, ends_mid}. Le transcript texte est reconstruit pour compat.
"""
tr = get_transcript(transcript_id)
# Retry/regen après un échec : le client reverse le manuel collé
# entre-temps — l'ajout reste possible hors session live.
if tr.get("status") not in ("recording", "paused", "processing",
"failed", "done"):
raise ValueError("session is not live — start recording first")
require_consent(tr)
if not isinstance(segments, list) or not segments:
raise ValueError("segments must be a non-empty list")
if len(segments) > 200:
raise ValueError("too many segments per batch (max 200)")
current = _loads(tr.get("segments_json") or "[]", [])
seq = _next_seq(current)
for item in segments:
if not isinstance(item, dict) or not (item.get("text") or "").strip():
raise ValueError("each segment needs a non-empty text")
text = item["text"].strip()[:4000]
current.append({
"seq": seq,
"channel": (item.get("channel") or "self")[:12],
"source_label": (item.get("source_label") or "")[:120],
"speaker": (item.get("speaker") or "")[:120],
"start_ms": max(0, int(item.get("start_ms") or 0)),
"end_ms": max(0, int(item.get("end_ms") or 0)),
"text": ("--" if item.get("starts_mid") else "") + text,
"is_final": True,
"starts_mid_utterance": bool(item.get("starts_mid")),
"ends_mid_utterance": bool(item.get("ends_mid")),
})
seq += 1
flat = "\n".join(
f"[{s['start_ms'] // 60000}:{(s['start_ms'] // 1000) % 60:02d}] "
f"{s['speaker'] or s['channel']} : {s['text']}" for s in current
)
if tr.get("transcript"):
flat = (tr["transcript"] + "\n" + flat) if flat else tr["transcript"]
_update(transcript_id, segments_json=json.dumps(current, ensure_ascii=False),
transcript=flat[:200000])
return {"transcript_id": transcript_id, "segments": len(current)}
def set_segment_speaker(transcript_id: int, seq: int, speaker: str,
user_id: int | None = None) -> dict:
tr = get_transcript(transcript_id)
segments = _loads(tr.get("segments_json") or "[]", [])
found = False
for s in segments:
if int(s.get("seq") or 0) == int(seq):
s["speaker"] = (speaker or "").strip()[:120]
s["speaker_corrected_by"] = user_id
found = True
if not found:
raise ValueError("segment not found")
_update(transcript_id, segments_json=json.dumps(segments, ensure_ascii=False))
return {"transcript_id": transcript_id, "seq": int(seq), "speaker": speaker}
def grouped_transcript(transcript_id: int) -> dict:
"""Transcript regroupé par piste source (§3A.7) + rail temporel."""
tr = get_transcript(transcript_id)
segments = _loads(tr.get("segments_json") or "[]", [])
groups: dict[str, list] = {}
order: list[str] = []
for s in sorted(segments, key=lambda x: (x.get("seq") or 0)):
key = s.get("source_label") or s.get("channel") or "self"
if key not in groups:
groups[key] = []
order.append(key)
groups[key].append(s)
# Repli : transcript texte brut découpé en un groupe unique.
if not groups and (tr.get("transcript") or "").strip():
groups = {"audio": [{"seq": 1, "channel": "self", "source_label": "",
"speaker": "", "start_ms": 0, "end_ms": 0,
"text": tr["transcript"][:4000]}]}
order = ["audio"]
return {"transcript_id": transcript_id,
"groups": [{"source": k, "segments": groups[k]} for k in order]}
# ── Classification de complétude + titre (§12.8) ─────────────────────────────
def classify_completeness(*, text: str, segments: list, notes: str,
duration_ms: int = 0) -> str:
words = len(re.findall(r"\S+", text or ""))
n_seg = len(segments or [])
truncated = any(s.get("starts_mid_utterance") or s.get("ends_mid_utterance")
for s in (segments or []))
has_agenda = bool((notes or "").strip())
if n_seg == 0 or words < 8:
return "fragmentary"
if words < 60 or (duration_ms and duration_ms < 45_000):
return "brief" if not truncated else "incomplete"
if truncated or not has_agenda and words < 150:
return "incomplete"
if words < 150:
return "brief"
return "complete"
def _first_topic(text: str) -> str:
sentences = re.split(r"(?<=[.!?])\s+", re.sub(r"\s+", " ", text or "").strip())
for s in sentences:
s = s.strip(" -–—\"'«»")
if len(s) > 25:
return s[:90].rstrip(" ,;:")
return ""
def generate_title(*, text: str, completeness: str, notes: str = "") -> str:
if completeness in ("brief", "incomplete", "fragmentary"):
topic = _first_topic(text)
if not topic or len(re.findall(r"\S+", text or "")) < 25:
return "Brief or Incomplete Meeting Recording"
return topic[:90]
topic = _first_topic(notes + " " + text)
return topic[:90] or "Meeting notes"
# ── Résumé structuré (§12.4) : JSON contraint + citations ────────────────────
_SUMMARY_SCHEMA_HINT = (
"Réponds STRICTEMENT par un objet JSON : "
'{"overview": ["..."], "decisions": ["..."], "action_items": '
'[{"title": "...", "owner": null, "due_hint": null}], '
'"open_questions": ["..."], "limits": ["..."]}. '
"Chaque puce overview/decisions/action_items DOIT citer le transcript "
"(suffixe entre parenthèses avec un extrait repris mot pour mot). "
"N'invente ni responsable ni échéance : owner/due_hint null si absents. "
"Si le contenu est insuffisant, overview décrit les manques et "
"action_items vaut []."
)
def _cite(text: str, segments: list) -> str:
"""Trouve un segment source pour une affirmation (citation vérifiable)."""
words = [w.lower() for w in re.findall(r"[A-Za-zÀ-ÿa-zà-ÿ']{4,}", text or "")]
best, best_score = None, 0
for s in segments or []:
hay = (s.get("text") or "").lower()
score = sum(1 for w in words[:12] if w in hay)
if score > best_score:
best, best_score = s, score
if best and best_score > 0:
return f" ⁠¹ [seg {best.get('seq')}]"
if segments:
return f" ⁠¹ [seg {segments[0].get('seq')}]"
return " ⁠¹"
def _offline_structured(*, text: str, notes: str, segments: list,
completeness: str) -> dict:
sentences = [s.strip() for s in
re.split(r"(?<=[.!?])\s+", re.sub(r"\s+", " ", text).strip()) if s.strip()]
overview: list[str] = []
if completeness == "fragmentary":
overview = [
"The recording captured only a short, fragmentary exchange with no "
f"discernible meeting topic, agenda, or context{_cite(text, segments)}",
"The content appears to be mid-conversation and lacks the beginning "
f"of the discussion{_cite(text, segments)}",
]
if not (notes or "").strip():
overview.append("The user's notes were empty, providing no additional "
"context about the meeting's purpose")
else:
for s in sentences[:4]:
overview.append(f"{s[:220]}{_cite(s, segments)}")
if not overview:
overview = [f"No meaningful content could be extracted{_cite(text, segments)}"]
if completeness in ("brief", "incomplete"):
overview.append(
"This was a brief or incomplete recording — treat this summary "
f"as partial{_cite(text, segments)}")
decisions = []
for s in sentences:
if re.search(r"\b(decid|décid|agreed|approuv|retain|retenu|chosen|choisi)\b", s, re.I):
decisions.append(f"{s[:220]}{_cite(s, segments)}")
actions = []
for s in sentences:
m = re.search(r"\b([A-ZÀ-Þ][a-zà-ÿ'\-]+)\s+(will|va|doit|should|must|s'occupe)\b", s)
if re.search(r"\b(action|todo|to-?do|next step|prochaine étape|faire|envoyer|préparer)\b", s, re.I) or m:
owner = m.group(1) if m and len(m.group(1)) > 2 else None
actions.append({"title": s[:180], "owner": owner,
"due_hint": None, "evidence": _cite(s, segments)})
return {"overview": overview[:6], "decisions": decisions[:8],
"action_items": actions[:10], "open_questions": [],
"limits": [] if completeness == "complete" else overview[-2:]}
def _render_markdown(struct: dict, completeness: str) -> str:
L = ["## Overview", ""]
for b in struct.get("overview") or []:
L.append(f"• {b}")
L += ["", "## Decisions", ""]
if struct.get("decisions"):
for b in struct["decisions"]:
L.append(f"• {b}")
else:
L.append("• No decisions could be identified from the available transcript")
L += ["", "## Action Items", ""]
if struct.get("action_items"):
for a in struct["action_items"]:
if isinstance(a, dict):
owner = f" — owner: {a['owner']}" if a.get("owner") else ""
L.append(f"• {a.get('title', '')}{owner}{a.get('evidence', ' ¹')}")
else:
L.append(f"• {a}")
else:
L.append("• No action items could be identified from the available transcript")
if struct.get("open_questions"):
L += ["", "## Open questions", ""]
for b in struct["open_questions"]:
L.append(f"• {b}")
if completeness in ("brief", "incomplete", "fragmentary"):
L += ["", f"_Summary class: {completeness} — partial recording, verify against the transcript._"]
return "\n".join(L).strip()
def _write_steps(transcript_id: int, states: dict) -> None:
steps = [{"key": k, "label": label,
"state": states.get(k, "pending"),
"at": _now() if states.get(k) in ("running", "done", "failed") else ""}
for k, label in PROCESSING_STEPS]
_update(transcript_id, processing_steps_json=json.dumps(steps, ensure_ascii=False))
async def _llm_structured(*, text: str, notes: str, instruction: str,
user_id: int | None) -> tuple[dict | None, bool, str]:
from app.services.ai_writing import AIWritingService
svc = AIWritingService(user_id=user_id)
if svc._offline_hint():
return None, True, ""
prompt = (
"Rédige le résumé structuré de cette réunion (format AI Meeting Note). "
f"Instructions de l'utilisateur : {instruction or 'Auto'}. "
"Notes humaines (contexte prioritaire, signale tout conflit) : "
f"{(notes or '(vides)')[:2000]}. " + _SUMMARY_SCHEMA_HINT
)
try:
res = await svc.run("summarize", context=text[:18000], prompt=prompt)
except Exception as exc: # noqa: BLE001
logger.warning("meeting LLM summary failed: %s", exc)
return None, False, str(exc)
if not res.get("ok") or not (res.get("text") or "").strip():
return None, bool(res.get("offline")), res.get("error") or "empty"
parsed = AIWritingService._parse_json_object(res["text"])
if not parsed or not isinstance(parsed.get("overview"), list):
# Le modèle a répondu en texte libre : on le conserve en overview sourcé.
return {"overview": [res["text"][:1500]], "decisions": [],
"action_items": [], "open_questions": [], "limits": []}, False, ""
return parsed, False, ""
async def run_processing(transcript_id: int, user_id: int | None = None,
allow_empty: bool = False) -> dict:
"""Pipeline §12 : étapes Thinking → classification → résumé → titre.
Jamais de refus sur contenu insuffisant : résumé dégradé + classe +
titre-diagnostic (§12.8). Avec ``allow_empty`` (arrêt sans rien de
capté), un diagnostic honnête est produit au lieu d'une erreur —
l'utilisateur ne perd ni l'enregistrement ni le transcript.
Sans ``allow_empty``, un transcript vide reste une 400 (l'appelant
affiche alors un guide de reprise, pas un dump HTTP).
"""
tr = get_transcript(transcript_id)
segments = _loads(tr.get("segments_json") or "[]", [])
notes = tr.get("notes_text") or ""
text = (tr.get("transcript") or "").strip()
snapshot = _loads(tr.get("instruction_snapshot") or "", {})
instruction = (snapshot.get("instruction_raw")
or tr.get("instruction_id") or "auto")
order = [k for k, _ in PROCESSING_STEPS]
states: dict[str, str] = {}
try:
for key in ("reading_transcript", "analyzing_transcript",
"resolving_references", "understanding_content"):
states[key] = "running"
_write_steps(transcript_id, states)
time.sleep(0) # point de cession : étapes réellement exécutées
states[key] = "done"
_write_steps(transcript_id, states)
states["classifying_completeness"] = "running"
_write_steps(transcript_id, states)
completeness = classify_completeness(
text=text, segments=segments, notes=notes,
duration_ms=int(tr.get("duration_ms") or 0))
states["classifying_completeness"] = "done"
_write_steps(transcript_id, states)
if not text:
if not allow_empty:
raise ValueError("transcript is empty — transcribe first")
# Arrêt sans rien de capté (micro coupé, aucun mot reconnu) :
# diagnostic honnête au lieu d'une erreur — rien n'est perdu,
# l'utilisateur peut coller un transcript et régénérer.
for key in ("generating_summary", "generating_title",
"validating_citations"):
states[key] = "running"
_write_steps(transcript_id, states)
states[key] = "done"
_write_steps(transcript_id, states)
struct = {
"overview": [
"No audio was captured during this session — no speech "
"reached the transcript. Check that the microphone is "
"allowed for this site and that the correct input is selected.",
("If the call ran in another tab, restart with “Tab + "
"microphone” and tick “Share audio” when choosing the tab."),
],
"decisions": [], "action_items": [], "open_questions": [],
"limits": ["empty session — paste a transcript, then Regenerate"],
}
md = _render_markdown(struct, "fragmentary")
_update(transcript_id, status="done", summary=md,
summary_json=json.dumps(struct, ensure_ascii=False),
generated_title="Brief or Incomplete Meeting Recording",
title_source="ai", completeness_class="fragmentary")
return await _finish_processing(transcript_id, offline=True)
states["generating_summary"] = "running"
_write_steps(transcript_id, states)
struct, offline, _err = await _llm_structured(
text=text, notes=notes, instruction=instruction, user_id=user_id)
if struct is None:
struct = _offline_structured(text=text, notes=notes,
segments=segments,
completeness=completeness)
offline = True
else:
# Garantir les citations même sur réponse LLM.
for section in ("overview", "decisions"):
fixed = []
for b in struct.get(section) or []:
s = b if isinstance(b, str) else str(b)
fixed.append(s if "¹" in s or "[seg" in s else f"{s}{_cite(s, segments)}")
struct[section] = fixed
acts = []
for a in struct.get("action_items") or []:
if isinstance(a, dict):
if "evidence" not in a:
a["evidence"] = _cite(a.get("title", ""), segments)
acts.append(a)
else:
acts.append({"title": str(a), "owner": None,
"due_hint": None,
"evidence": _cite(str(a), segments)})
struct["action_items"] = acts
states["generating_summary"] = "done"
_write_steps(transcript_id, states)
states["generating_title"] = "running"
_write_steps(transcript_id, states)
title = generate_title(text=text, completeness=completeness, notes=notes)
states["generating_title"] = "done"
_write_steps(transcript_id, states)
states["validating_citations"] = "running"
_write_steps(transcript_id, states)
seqs = {int(s.get("seq") or 0) for s in segments}
# Toute citation [seg N] pointe un segment réel, sinon repli seg 1.
md = _render_markdown(struct, completeness)
def _fix_seg(m: re.Match) -> str:
try:
n = int(m.group(1))
except (TypeError, ValueError):
return "[seg 1]"
return m.group(0) if n in seqs else "[seg 1]"
md = re.sub(r"\[seg (\d+)\]", _fix_seg, md)
states["validating_citations"] = "done"
_write_steps(transcript_id, states)
_update(transcript_id, status="done", summary=md,
summary_json=json.dumps(struct, ensure_ascii=False),
generated_title=title, title_source="ai",
completeness_class=completeness)
except Exception as exc: # noqa: BLE001 — état failed récupérable, segments conservés
for k in order:
states.setdefault(k, "pending")
# Marquer l'étape courante en échec.
for k in order:
if states.get(k) == "running":
states[k] = "failed"
break
_write_steps(transcript_id, states)
# Toujours sortir de « processing » : une ligne coincée est pire
# qu'une erreur affichée (l'utilisateur peut réessayer).
_update(transcript_id, status="failed")
msg = str(exc) or "processing failed"
if "transcript is empty" in msg:
raise ValueError(msg) from exc
logger.warning("meeting processing %s failed: %s", transcript_id, exc)
raise RuntimeError(msg) from exc
return await _finish_processing(transcript_id, offline)
async def _finish_processing(transcript_id: int, offline: bool) -> dict:
"""Événement + réponse du pipeline (chemin nominal et diagnostic)."""
out = get_transcript(transcript_id)
try:
from app.services.automations import fire_event
await fire_event("meeting.summarized", {
"page_id": out["page_id"], "transcript_id": transcript_id,
"language": out.get("language") or "fr",
"completeness": out.get("completeness_class") or "",
})
except Exception as exc: # noqa: BLE001
logger.debug("meeting.summarized dispatch failed: %s", exc)
return {"transcript_id": transcript_id, "summary": out["summary"],
"completeness": out.get("completeness_class") or "",
"generated_title": out.get("generated_title") or "",
"offline": bool(offline)}
def transcribe_audio(audio_path: str, language: str = "fr") -> str:
"""Transcribe with ``STT_COMMAND`` (``{cmd} {file}` → stdout text)."""
cmd_template = os.environ.get("STT_COMMAND", "").strip()
if not cmd_template:
raise TranscriptionUnavailable(
@@ -74,40 +694,39 @@ def transcribe_audio(audio_path: str, language: str = "fr") -> str:
return text
async def summarize_transcript(transcript_id: int, user_id: int | None = None) -> dict:
"""Summarize a stored transcript + fire ``meeting.summarized``.
async def summarize_transcript(transcript_id: int, user_id: int | None = None,
allow_empty: bool = False) -> dict:
"""Point d'entrée historique (v7.1.0) — bascule sur le pipeline Notion."""
tr = get_transcript(transcript_id)
if tr.get("status") in ("recording", "paused"):
raise ValueError("stop the recording before generating the summary")
if not ((tr.get("transcript") or "").strip()
or _loads(tr.get("segments_json") or "[]", [])):
if not allow_empty:
raise ValueError("transcript is empty — transcribe first")
if tr.get("status") not in ("processing", "done"):
_update(transcript_id, status="processing")
out = await run_processing(transcript_id, user_id=user_id,
allow_empty=allow_empty)
return {"transcript_id": transcript_id, "summary": out["summary"],
"offline": out["offline"]}
Returns {transcript_id, summary, offline}. Emits the automation event so
custom agents (update tracker, post recap, file tickets) trigger.
"""
from app.services.ai_writing import AIWritingService
# ── Partage tracé (§3A.5 : Copy link / Email / Slack) ─────────────────────────
def record_distribution(transcript_id: int, *, channel: str,
actor_id: int | None = None,
recipients: str = "", status: str = "created") -> dict:
if channel not in ("copy_link", "email", "slack"):
raise ValueError("channel must be copy_link|email|slack")
get_transcript(transcript_id)
with get_conn() as conn:
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?",
(transcript_id,)).fetchone()
if not row:
raise ValueError("transcript not found")
tr = dict(row)
if not (tr.get("transcript") or "").strip():
raise ValueError("transcript is empty — transcribe first")
svc = AIWritingService(user_id=user_id)
res = await svc.run("summarize", context=tr["transcript"])
summary = (res.get("text") or "").strip() if res.get("ok") else ""
if not summary:
raise RuntimeError(f"summarization failed: {res.get('error', 'unknown')}")
with get_conn() as conn:
conn.execute("UPDATE meeting_transcripts SET summary=? WHERE id=?",
(summary, transcript_id))
page = conn.execute("SELECT id FROM pages WHERE id=?", (tr["page_id"],)).fetchone()
cur = conn.execute(
"""INSERT INTO meeting_distributions
(transcript_id, channel, actor_id, recipients_json, status)
VALUES (?,?,?,?,?)""",
(transcript_id, channel, actor_id, recipients or "", status),
)
conn.commit()
try:
from app.services.automations import fire_event
await fire_event("meeting.summarized", {
"page_id": tr["page_id"] if page else 0,
"transcript_id": transcript_id,
"language": tr.get("language") or "fr",
})
except Exception as exc: # noqa: BLE001 — summary stands even if dispatch fails
logger.debug("meeting.summarized dispatch failed: %s", exc)
return {"transcript_id": transcript_id, "summary": summary,
"offline": bool(res.get("offline"))}
return {"id": cur.lastrowid, "transcript_id": transcript_id,
"channel": channel, "status": status}
+77
View File
@@ -0,0 +1,77 @@
"""Plugins de l'instance — on/off à **effet réel** (v7.58.0).
Chaque plugin câble une chose concrète, jamais un simple drapeau :
- ``web-tools`` → retiré du registre d'outils de l'agent (schéma + exécution)
- ``web-clipper`` → routers ``/extensions`` et ``/api/v2/web-clipper/*`` refusés
- ``automations`` → routes ``/workspace/automations*`` refusées + scheduler
Le garde de route est une **dépendance FastAPI posée à l'`include_router`**
(``main.py``) : 3 lignes, aucun fichier de router touché.
"""
from __future__ import annotations
from fastapi import HTTPException, Request
from app.db import get_conn
# slug → (nom affiché, description affichée)
CATALOG: list[tuple[str, str, str]] = [
("web-tools", "Outils web de l'agent",
"Retire web_search et fetch_url du registre d'outils de l'agent"),
("web-clipper", "Web Clipper",
"Coupe la page /extensions et l'API /api/v2/web-clipper/*"),
("automations", "Automations",
"Coupe /workspace/automations* et le scheduler en arrière-plan"),
]
# slug → outils LLM retirés du registre quand le plugin est OFF
PLUGIN_TOOLS: dict[str, tuple[str, ...]] = {"web-tools": ("web_search", "fetch_url")}
def is_enabled(slug: str) -> bool:
"""Ligne absente = activé (défaut sûr : ne rien couper par accident)."""
with get_conn() as conn:
row = conn.execute("SELECT enabled FROM plugins WHERE slug=?", (slug,)).fetchone()
return True if row is None else bool(row["enabled"])
def list_plugins() -> list[dict]:
with get_conn() as conn:
rows = {r["slug"]: bool(r["enabled"])
for r in conn.execute("SELECT slug, enabled FROM plugins")}
return [{"slug": slug, "name": name, "description": desc,
"enabled": rows.get(slug, True)}
for slug, name, desc in CATALOG]
def set_enabled(slug: str, enabled: bool) -> dict:
if not any(s == slug for s, _, _ in CATALOG):
raise ValueError(f"Plugin inconnu: {slug}")
name = next(n for s, n, _ in CATALOG if s == slug)
desc = next(d for s, _, d in CATALOG if s == slug)
with get_conn() as conn:
# SQLite vérifie NOT NULL AVANT l'upsert : il faut fournir name/description.
conn.execute(
"INSERT INTO plugins (slug, name, description, enabled) VALUES (?,?,?,?) "
"ON CONFLICT(slug) DO UPDATE SET enabled=excluded.enabled",
(slug, name, desc, 1 if enabled else 0))
conn.commit()
return next(p for p in list_plugins() if p["slug"] == slug)
def plugin_required(slug: str):
"""Dépendance FastAPI : 404 dès que le plugin est désactivé.
`Request` doit être importé au **module** : les annotations sont des
chaînes (`from __future__ import annotations`) et FastAPI les résout dans
les globales du module — sinon il lit un query param → 422.
"""
def dep(request: Request) -> None:
# annotation Request OBLIGATOIRE : sans elle FastAPI lit un query param → 422
if not is_enabled(slug):
raise HTTPException(status_code=404, detail=f"Plugin désactivé: {slug}")
return dep
+45 -5
View File
@@ -97,14 +97,20 @@ PROPERTY_TYPES: dict[str, dict] = {
"default": None,
},
"button": {
"storage": "none — runs linked automation (button_automation_id)",
"storage": "none - runs linked automation (button_automation_id)",
"default": None,
},
"place": {
"storage": "json object {name, address, lat, lng, provider, place_id, resolved_at, manual}",
"validation": "name or address required, lat in [-90,90], lng in [-180,180]",
"default": None,
},
}
# CSV-friendly subset (no relation/rollup/formula)
SIMPLE_TYPES = ["title", "text", "number", "select", "multi_select", "status",
"date", "person", "checkbox", "url", "email", "phone", "files"]
"date", "person", "checkbox", "url", "email", "phone", "files",
"place"]
# Auto-property types (computed server-side)
AUTO_TYPES = ["created_time", "created_by", "last_edited_time", "last_edited_by"]
@@ -136,10 +142,19 @@ def validate_property_value(prop_type: str, value: Any, options: list | None = N
return False, f"'{v}' is not a valid option"
elif prop_type == "date":
if isinstance(value, str) and value:
try:
datetime.fromisoformat(value.replace("Z", "+00:00"))
except (ValueError, TypeError):
# Les plages « start...end » (Timeline, auto-shift) sont valides :
# chaque borne est controlee separement.
parts = value.split("...") if "..." in value else [value]
if len(parts) > 2 or not any(p.strip() for p in parts):
return False, f"'{value}' is not a valid ISO 8601 date"
for part in parts:
part = part.strip()
if not part:
continue
try:
datetime.fromisoformat(part.replace("Z", "+00:00"))
except (ValueError, TypeError):
return False, f"'{value}' is not a valid ISO 8601 date"
elif prop_type == "person":
if not isinstance(value, list):
return False, "Person must be a list of workspace members"
@@ -152,6 +167,31 @@ def validate_property_value(prop_type: str, value: Any, options: list | None = N
elif prop_type == "phone":
if not isinstance(value, str):
return False, "Phone must be a string"
elif prop_type == "place":
if value in ("", {}):
return True, ""
if isinstance(value, str):
# Plain-text address (unresolved place, e.g. CSV import) is valid.
return True, ""
if not isinstance(value, dict):
return False, "Place must be an object {name, address, lat, lng}"
if not (value.get("name") or value.get("address")):
return False, "Place requires a name or an address"
lat, lng = value.get("lat"), value.get("lng")
if lat is not None and lat != "":
try:
lat = float(lat)
except (ValueError, TypeError):
return False, "Place latitude must be a number"
if not -90 <= lat <= 90:
return False, "Place latitude must be between -90 and 90"
if lng is not None and lng != "":
try:
lng = float(lng)
except (ValueError, TypeError):
return False, "Place longitude must be a number"
if not -180 <= lng <= 180:
return False, "Place longitude must be between -180 and 180"
return True, ""
+14 -6
View File
@@ -9,10 +9,14 @@ Les trois surfaces divergeaient avant cette passe :
- `/api/v2/pages/{id}/publish` : slug fourni par le corps ou aléatoire,
`is_shared=1` en bonus (alors que v2 le remet à 0 quand aucun partage)
Canonical (comportement du front) : `is_published` + `publish_slug` seulement,
404 si la page n'existe pas. `share_mode`/`is_shared`/`published` restent la
propriété du share dialog (`/board/api/share/{pid}`) : dépublier ne révoque
donc pas un partage manuel.
Canonical (comportement du front) : `is_published` + `published` +
`publish_slug`, maintenus ensemble par toutes les écritures (v7.69.6 —
avant, chaque surface n'écrivait qu'un drapeau et chaque lecteur ne
regardait qu'une colonne, donc l'état publié ne « tenait » nulle part).
`share_mode`/`is_shared` restent la propriété du share dialog
(`/board/api/share/{pid}`) : dépublier ne révoque donc pas un partage manuel.
Les lecteurs acceptent `is_published=1 OR published=1` (lignes historiques
issues d'un seul chemin).
"""
from __future__ import annotations
@@ -58,8 +62,12 @@ def publish(page_id: int, explicit_slug: str | None = None) -> tuple[str, str]:
if not page:
raise HTTPException(404, "Page not found")
slug = explicit_slug or _unique_slug(conn, page_id, page["title"])
# v7.69.6 : les deux drapeaux sont maintenus ensemble — le share
# dialog écrit ``published``, les routes /publish écrivaient seulement
# ``is_published``, et chaque lecteur ne regardait qu'une colonne :
# l'état publié ne « tenait » nulle part.
conn.execute(
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?", (slug, page_id)
"UPDATE pages SET is_published=1, published=1, publish_slug=? WHERE id=?", (slug, page_id)
)
conn.commit()
return slug, page["title"] or ""
@@ -71,7 +79,7 @@ def unpublish(page_id: int) -> None:
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
raise HTTPException(404, "Page not found")
conn.execute(
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?", (page_id,)
"UPDATE pages SET is_published=0, published=0, publish_slug='' WHERE id=?", (page_id,)
)
conn.commit()
+126 -18
View File
@@ -703,32 +703,134 @@ class AddDependency(Tool):
class ApplyTemplate(Tool):
name = "apply_template"
description = "Applique un template de page dans une collection (crée une page)."
description = (
"Applique un template du registre unifié (crée une page, une ligne, "
"une base ou des blocs). Même service que l'UI (TemplateService)."
)
parameters = {
"type": "object",
"properties": {"collection_id": {"type": "integer"}, "template_id": {"type": "integer"}, "title": {"type": "string"}},
"required": ["collection_id", "template_id"],
"properties": {
"collection_id": {"type": "integer"},
"template_id": {"type": ["integer", "string"]},
"title": {"type": "string"},
"variables": {"type": "object"},
},
"required": ["template_id"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid, tid = args.get("collection_id"), args.get("template_id")
from app.services.fd_templates import TemplateService, registry_ready
tid = args.get("template_id")
cid = args.get("collection_id")
# Compat legacy : un id numérique de page_templates.
template_ref: str = str(tid)
with get_conn() as conn:
tmpl = conn.execute("SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, cid)).fetchone()
if not tmpl:
return ToolResult(status="error", tool=self.name, message="Template introuvable")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", (cid,)).fetchone()[0]
cur = conn.execute(
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
(cid, args.get("title", "New Page"), max_pos, tmpl["property_values_json"]),
)
conn.commit()
actor = _actor_for(conn, user_id)
if registry_ready(conn) and isinstance(tid, int):
legacy = conn.execute(
"SELECT id FROM templates WHERE source_kind='legacy_page' AND source_id=?",
(str(tid),),
).fetchone()
if legacy:
template_ref = legacy["id"]
variables = dict(args.get("variables") or {})
if args.get("title"):
variables.setdefault("title", args["title"])
context: dict = {}
if cid:
context["collection_id"] = cid
try:
result = TemplateService.instantiate(
conn, actor, template_ref, variables, context,
run_source="agent",
)
except LookupError as exc:
return ToolResult(status="error", tool=self.name, message=str(exc))
except (ValueError, PermissionError) as exc:
return ToolResult(status="error", tool=self.name, message=str(exc))
except Exception as exc: # noqa: BLE001 — l'agent doit recevoir l'erreur
logger.exception("apply_template agent")
return ToolResult(status="error", tool=self.name, message=str(exc))
created_id = result.get("created_id") or None
try:
target_id = int(created_id) if created_id else 0
except (TypeError, ValueError):
target_id = 0
undo = None
if result.get("created_type") in ("page", "row", "collection") and target_id:
table = {"page": "pages", "row": "collection_pages",
"collection": "collections"}[result["created_type"]]
undo = {"action": "delete", "table": table, "id": target_id}
return ToolResult(
status="success", tool=self.name, target_type="page", target_id=cur.lastrowid,
data={"page_id": cur.lastrowid},
undo={"action": "delete", "table": "collection_pages", "id": cur.lastrowid},
status="success", tool=self.name,
target_type=result.get("created_type") or "page",
target_id=target_id,
data={"page_id": target_id, "url": result.get("url"),
"warnings": result.get("warnings", []),
"run_id": result.get("run_id")},
undo=undo,
)
class ListTemplates(Tool):
name = "list_templates"
description = "Liste les templates visibles par l'utilisateur (registre unifié)."
parameters = {
"type": "object",
"properties": {
"kind": {"type": "string"},
"query": {"type": "string"},
"target_collection_id": {"type": "integer"},
},
"required": [],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.services.fd_templates import TemplateService
with get_conn() as conn:
actor = _actor_for(conn, user_id)
try:
items = TemplateService.list_templates(
conn, actor, kind=args.get("kind"),
query=args.get("query"),
target_collection_id=args.get("target_collection_id"),
)
except Exception as exc: # noqa: BLE001
logger.exception("list_templates agent")
return ToolResult(status="error", tool=self.name, message=str(exc))
summary = [
{"id": t["id"], "kind": t["kind"], "name": t["name"],
"scope": t["scope"], "is_default": t.get("is_default", False)}
for t in items
]
return ToolResult(
status="success", tool=self.name, target_type="templates",
data={"templates": summary},
)
def _actor_for(conn, user_id: int | None) -> dict:
"""Acteur minimal pour le service (l'agent n'agit jamais au-delà de
l'utilisateur appelant, §6.1 de l'architecture)."""
actor: dict = {"id": user_id}
if user_id:
try:
row = conn.execute(
"SELECT id, login, full_name, is_admin FROM users WHERE id=?",
(user_id,),
).fetchone()
if row:
actor.update(
{"login": row["login"], "full_name": row["full_name"],
"is_admin": bool(row["is_admin"])}
)
except Exception:
pass
return actor
# ══════════════════════════ Destructive tools ══════════════════════════
@@ -1149,7 +1251,7 @@ TOOL_CLASSES = [
SearchWorkspace, ReadCollection, ReadPage, ReadWorkspaces, ReadDocument,
CreateCollection, CreateView, AddProperty, CreatePage, CreateDocument,
UpdatePage, WriteBlocks,
AddRelation, CreateSubItem, AddDependency, ApplyTemplate,
AddRelation, CreateSubItem, AddDependency, ApplyTemplate, ListTemplates,
DeletePage, DeleteCollection,
ReadGiteaIssues, SyncGitea, CreateGiteaIssue,
DeleteDocument,
@@ -1172,10 +1274,16 @@ class ToolRegistry:
tools = dict(self.tools)
try:
from app.services import mcp_client
from app.services import plugins as plugins_service
for entry in mcp_client.cached_tools():
tools[entry["name"]] = McpTool(entry)
# plugin « web-tools » OFF → outils web retirés du schéma ET de execute()
for slug, names in plugins_service.PLUGIN_TOOLS.items():
if not plugins_service.is_enabled(slug):
for name in names:
tools.pop(name, None)
except Exception: # noqa: BLE001 — la base ne doit jamais casser un run
logger.exception("MCP dynamic tools load failed")
logger.exception("dynamic tools load failed")
return tools
def list(self, scope: dict | None = None) -> list[str]:
+221
View File
@@ -0,0 +1,221 @@
"""FlowDeck — Aggregation Service (vues Notion §8.3).
Un seul moteur d'agregation cote serveur pour Chart, KPI, widgets de
Dashboard et drilldown : grouper / sous-grouper / mesurer / cumuler /
limiter, avec contrat de sortie normalise.
Mesures : count | sum | average | median | min | max (definitions alignees
sur le Rollup Engine). Groupes : ordre du schema pour select/status/
multi_select (couleurs des options incluses), chronologique pour les dates.
"""
from __future__ import annotations
import json
from typing import Any
from .view_query import (
CHART_MAX_GROUPS,
CHART_MAX_SUBGROUPS,
apply_filters,
apply_sorts,
prop_value,
text_of,
)
NON_GROUPABLE = {"rollup", "button", "unique_id", "files"}
NON_MEASURABLE_AXES = {"rollup", "button", "unique_id", "files"}
def _schema_options(prop: dict) -> list[dict]:
try:
opts = json.loads(prop.get("options_json") or "[]")
except (json.JSONDecodeError, TypeError):
opts = []
out = []
for o in opts or []:
if isinstance(o, str):
out.append({"name": o, "color": "gray"})
elif isinstance(o, dict):
out.append({"name": o.get("name", ""), "color": o.get("color", "gray")})
return out
def _measure_values(rows: list[dict], measure_prop: dict | None) -> list[float]:
vals: list[float] = []
for p in rows:
if measure_prop is None:
vals.append(1.0)
continue
v = prop_value(p, measure_prop)
if v is None or v == "":
continue
try:
vals.append(float(v) if not isinstance(v, bool) else float(v))
except (ValueError, TypeError):
continue
return vals
def _reduce(kind: str, vals: list[float], count: int) -> float:
if kind == "count":
return float(count)
if not vals:
return 0.0
if kind == "sum":
return float(sum(vals))
if kind in ("average", "avg"):
return float(sum(vals) / len(vals))
if kind == "median":
s = sorted(vals)
mid = len(s) // 2
return float(s[mid] if len(s) % 2 else (s[mid - 1] + s[mid]) / 2)
if kind == "min":
return float(min(vals))
if kind == "max":
return float(max(vals))
return float(count)
def _date_bucket(value: Any, span_days: int) -> str:
s = str(value or "")[:10]
if len(s) < 10:
return s or "(empty)"
if span_days <= 62:
return s
if span_days <= 371:
# week bucket: keep ISO week label
try:
from datetime import date as _d
d = _d.fromisoformat(s)
iso = d.isocalendar()
return f"{iso.year}-W{iso.week:02d}"
except ValueError:
return s[:7]
return s[:7]
def aggregate(pages: list[dict], properties: list[dict], spec: dict) -> dict:
"""Calcule l'agregat normalise ``{groups, total, truncated, scanned}``."""
by_name = {p["name"]: p for p in properties}
by_id = {str(p["id"]): p for p in properties}
group_prop = by_name.get(spec.get("group_by")) or by_id.get(str(spec.get("group_by", "")))
sub_prop = None
if spec.get("sub_group_by"):
sub_prop = by_name.get(spec["sub_group_by"]) or by_id.get(str(spec["sub_group_by"]))
measure = spec.get("measure") or {"kind": "count"}
kind = measure.get("kind", "count")
measure_prop = None
if kind != "count" and measure.get("property"):
measure_prop = by_name.get(measure["property"]) or by_id.get(str(measure["property"]))
filters = spec.get("filters") or []
rows = apply_filters(pages, properties, filters, spec.get("filter_conjunction", "and"))
rows = apply_sorts(rows, properties, spec.get("sorts"))
scanned = len(rows)
hidden = set(spec.get("hidden_groups") or [])
omit_zero = spec.get("omit_zero", True)
cumulative = bool(spec.get("cumulative"))
order_cfg = spec.get("order") or {"by": "group_order", "direction": "asc"}
if isinstance(order_cfg, str):
order_cfg = {"by": order_cfg, "direction": "asc"}
# Date grouping needs the span first.
is_date = group_prop is not None and group_prop.get("prop_type") == "date"
span_days = 0
if is_date:
dates = sorted(text_of(group_prop, prop_value(p, group_prop))[:10]
for p in rows if text_of(group_prop, prop_value(p, group_prop)))
if len(dates) >= 2:
try:
from datetime import date as _d
span_days = (_d.fromisoformat(dates[-1][:10]) - _d.fromisoformat(dates[0][:10])).days
except ValueError:
span_days = 0
buckets: dict[str, list[dict]] = {}
for p in rows:
if group_prop is None:
key = "All"
elif group_prop.get("prop_type") == "multi_select":
v = prop_value(p, group_prop)
keys = v if isinstance(v, list) and v else ["(empty)"]
for k in keys:
buckets.setdefault(str(k or "(empty)"), []).append(p)
continue
elif is_date:
key = _date_bucket(prop_value(p, group_prop), span_days)
else:
t = text_of(group_prop, prop_value(p, group_prop))
key = t if t else "(empty)"
buckets.setdefault(key, []).append(p)
# Empty schema options (omit_zero=False) for select/status.
if group_prop is not None and not omit_zero and group_prop.get("prop_type") in ("select", "status"):
for o in _schema_options(group_prop):
buckets.setdefault(o["name"], [])
schema_order = [o["name"] for o in _schema_options(group_prop)] if group_prop else []
opt_colors = {o["name"]: o.get("color", "gray") for o in _schema_options(group_prop)} if group_prop else {}
def sort_key(item: tuple[str, list]) -> Any:
k, members = item
by = order_cfg.get("by", "group_order")
if by == "value":
mvals = _measure_values(members, measure_prop)
return _reduce(kind, mvals, len(members))
if by == "label":
return k.lower()
if group_prop is not None and group_prop.get("prop_type") == "date":
return k
if k in schema_order:
return (0, schema_order.index(k))
return (1, k.lower())
items = sorted(buckets.items(), key=sort_key)
if str(order_cfg.get("direction", "asc")).lower() in ("desc", "descending"):
items = list(reversed(items))
truncated = False
if len(items) > CHART_MAX_GROUPS:
items = items[:CHART_MAX_GROUPS]
truncated = True
groups = []
running = 0.0
for key, members in items:
if key in hidden:
continue
mvals = _measure_values(members, measure_prop)
value = _reduce(kind, mvals, len(members))
if cumulative and kind in ("count", "sum"):
running += value
value = running
sub = []
if sub_prop is not None:
sub_buckets: dict[str, list] = {}
for p in members:
sk = text_of(sub_prop, prop_value(p, sub_prop)) or "(empty)"
sub_buckets.setdefault(sk, []).append(p)
for sk, sp in list(sub_buckets.items())[:CHART_MAX_SUBGROUPS]:
svals = _measure_values(sp, measure_prop)
sub.append({"key": sk, "label": sk, "value": _reduce(kind, svals, len(sp))})
sub.sort(key=lambda g: -g["value"])
groups.append({
"key": key,
"label": key,
"color": opt_colors.get(key, "gray"),
"value": value,
"count": len(members),
"row_ids": [p.get("id") for p in members[:500]],
"sub": sub,
})
total_vals = _measure_values(rows, measure_prop)
total = _reduce(kind, total_vals, len(rows))
grand = sum(g["value"] for g in groups) or 1
for g in groups:
g["percent"] = g["value"] / grand if grand else 0
return {"groups": groups, "total": total, "truncated": truncated,
"scanned": scanned, "measure": kind}
+166
View File
@@ -0,0 +1,166 @@
"""FlowDeck — View Query Service (vues Notion §8.1).
Extraction partagee de la chaine de rendu des vues : charger le schema,
appliquer filtres (and/or) -> tris -> projection des proprietes visibles.
Toutes les vues clientes (Table/Board/... + chart/timeline/feed/map/
dashboard/form) en sont des consommatrices.
ACL : le filtrage des proprietes se fait AVANT tout calcul — une vue
n'agrege / ne geocode / n'affiche jamais une propriete invisible.
En v1 on s'appuie sur les ACL de collection (le granulair propriete est
applique quand `property_acls` est disponible, sinon tout est visible).
"""
from __future__ import annotations
import json
from typing import Any
CHART_MAX_GROUPS = 200
CHART_MAX_SUBGROUPS = 50
VIEW_ROWS_MAX = 1000
MAP_POINTS_MAX = 100
TIMELINE_LOAD_MAX = 200
FEED_PAGE_SIZE = 20
def parse_values(page: dict) -> dict:
try:
v = page.get("property_values_json", "{}")
return json.loads(v) if isinstance(v, str) else (v or {})
except (json.JSONDecodeError, TypeError):
return {}
def prop_value(page: dict, prop: dict) -> Any:
if prop.get("prop_type") == "title":
return page.get("title", "")
vals = parse_values(page)
v = vals.get(str(prop.get("id")))
if v is None:
v = vals.get(prop.get("name"))
return v
def text_of(prop: dict, value: Any) -> str:
if value is None:
return ""
if isinstance(value, dict):
# place / person-single / user ref
return str(value.get("name") or value.get("address")
or value.get("login") or value.get("full_name") or "")
if isinstance(value, list):
parts = []
for x in value:
if isinstance(x, dict):
parts.append(str(x.get("login") or x.get("full_name")
or x.get("name") or x.get("url") or ""))
else:
parts.append(str(x))
return ", ".join(parts)
return str(value)
def _match_op(actual: Any, op: str, expected: Any, prop: dict) -> bool:
actual_text = text_of(prop, actual).lower()
if op in ("is", "equals", "is exactly"):
if isinstance(actual, list):
return str(expected) in [str(x.get("name", x) if isinstance(x, dict) else x) for x in actual]
return actual_text == str(expected or "").lower()
if op in ("is_not", "is not", "not_equals"):
return actual_text != str(expected or "").lower()
if op == "contains":
return str(expected or "").lower() in actual_text
if op in ("not_contains", "does_not_contain"):
return str(expected or "").lower() not in actual_text
if op == "is_empty":
return actual in (None, "", []) or actual_text == ""
if op == "is_not_empty":
return not (actual in (None, "", []) or actual_text == "")
if op in ("gt", ">", "after"):
try:
return float(actual_text) > float(expected) if actual_text else False
except (ValueError, TypeError):
return str(actual_text) > str(expected or "")
if op in ("lt", "<", "before"):
try:
return float(actual_text) < float(expected) if actual_text else False
except (ValueError, TypeError):
return str(actual_text) < str(expected or "")
if op == "is_resolved":
return isinstance(actual, dict) and actual.get("lat") not in (None, "")
if op == "is_not_resolved":
return not (isinstance(actual, dict) and actual.get("lat") not in (None, ""))
return True
def apply_filters(pages: list[dict], properties: list[dict],
filters: list[dict] | None,
conjunction: str = "and") -> list[dict]:
if not filters:
return list(pages)
by_name = {p["name"]: p for p in properties}
by_id = {str(p["id"]): p for p in properties}
out = []
for page in pages:
results = []
for f in filters:
prop = by_name.get(f.get("property")) or by_id.get(str(f.get("property", "")))
if prop is None:
continue
actual = prop_value(page, prop)
results.append(_match_op(actual, f.get("operator", "contains"), f.get("value"), prop))
if not results:
out.append(page)
elif (conjunction or "and").lower() == "or":
if any(results):
out.append(page)
elif all(results):
out.append(page)
return out
def apply_sorts(pages: list[dict], properties: list[dict],
sorts: list[dict] | None) -> list[dict]:
if not sorts:
return list(pages)
by_name = {p["name"]: p for p in properties}
by_id = {str(p["id"]): p for p in properties}
rows = list(pages)
for s in reversed(sorts):
prop = by_name.get(s.get("property")) or by_id.get(str(s.get("property", "")))
if prop is None:
continue
reverse = str(s.get("direction", "asc")).lower() in ("desc", "descending")
rows.sort(key=lambda p: text_of(prop, prop_value(p, prop)).lower(), reverse=reverse)
return rows
def query_view_rows(pages: list[dict], properties: list[dict],
config: dict, extra_filters: list[dict] | None = None,
limit: int = VIEW_ROWS_MAX,
cursor: str | None = None) -> tuple[list[dict], str | None]:
"""Filtre + tri + projection + pagination par curseur (id)."""
filters = list(config.get("filters") or [])
if extra_filters:
filters.extend(extra_filters)
conjunction = config.get("filter_conjunction", "and")
rows = apply_filters(pages, properties, filters, conjunction)
rows = apply_sorts(pages and rows, properties, config.get("sorts"))
start = 0
if cursor:
for i, p in enumerate(rows):
if str(p.get("id")) == str(cursor):
start = i + 1
break
window = rows[start:start + limit]
next_cursor = str(window[-1]["id"]) if len(rows) > start + limit and window else None
return window, next_cursor
def visible_props(properties: list[dict], config: dict) -> list[dict]:
vis = config.get("visible_properties")
if not vis:
return list(properties)
wanted = {str(v) for v in vis}
ordered = [p for p in properties if str(p.get("id")) in wanted or p.get("name") in wanted]
return ordered or list(properties)
+11 -1
View File
@@ -37,7 +37,7 @@
{% if not hide_hamburger %}
<button class="hamburger-btn"
:class="{ 'hamburger-desktop-show': sidebarCollapsed }"
@click="{{ hamburger_click|default('sidebarCollapsed ? toggleSidebar() : (mobileSidebarOpen = true, sidebarCollapsed = false)') }}"
@click="{{ hamburger_click|default('fdHamburgerClick()') }}"
title="Toggle sidebar">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"/>
@@ -181,6 +181,16 @@ document.addEventListener('alpine:init', function () {
return String(c.id) === id ? Object.assign({}, c, { label: d.title || 'Untitled' }) : c;
});
});
// Navigation in-place (ex. /local-workspace qui descend dans ses
// dossiers sans recharger) : le chemin du header est recalé sur la
// pile de dossiers courante. Même contrat que le rendu serveur.
window.addEventListener('flowdeck:breadcrumb-changed', function (e) {
var d = e && e.detail;
if (!d || !d.crumbs || !d.crumbs.length) return;
self.crumbs = d.crumbs.map(function (c, i) { c.origIndex = i; return c; });
if (d.workspace_id) self.wsId = d.workspace_id;
self.closeAll();
});
},
get display() {
+17 -1
View File
@@ -16,6 +16,9 @@
<style>
.fd-icon-picker{position:fixed;top:0;left:0;width:344px;background:var(--bg-modal);border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,.28);z-index:2200;display:flex;flex-direction:column;max-height:440px;overflow:hidden;padding:0;}
.fd-icon-picker .fdip-title{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--border);}
.fd-icon-picker .fdip-title-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:600;color:var(--text-primary);}
.fd-icon-picker .fdip-title-grip{opacity:.6;font-size:14px;}
.fd-icon-picker .fdip-tabs{display:flex;align-items:center;gap:2px;padding:8px 10px 6px;border-bottom:1px solid var(--border);}
.fd-icon-picker .fdip-tab{background:transparent;border:none;color:var(--text-secondary);font-size:13px;padding:5px 10px;border-radius:6px;cursor:pointer;}
.fd-icon-picker .fdip-tab:hover{background:var(--bg-hover);}
@@ -70,7 +73,11 @@
@click.outside="$store.fdIconPicker.customModal || $store.fdIconPicker.close()"
@keydown.escape.window="$store.fdIconPicker.close()"
:style="'top:' + $store.fdIconPicker.y + 'px;left:' + $store.fdIconPicker.x + 'px'">
<div class="fdip-tabs">
<div class="fdip-title" x-show="$store.fdIconPicker.title" x-cloak>
<span class="fdip-title-text" x-text="$store.fdIconPicker.title"></span>
<span class="fdip-title-grip" title="Drag">✋</span>
</div>
<div class="fdip-tabs" x-show="!$store.fdIconPicker.title">
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'emoji' }" @click="$store.fdIconPicker.setTab('emoji')">Emoji</button>
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'icons' }" @click="$store.fdIconPicker.setTab('icons')">Icons</button>
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'upload' }" @click="$store.fdIconPicker.setTab('upload')">Upload</button>
@@ -93,6 +100,15 @@
<div class="fdip-body">
<template x-if="$store.fdIconPicker.tab === 'emoji'">
<div>
<!-- v7.64 : mode réaction — les récents au-dessus de la grille (façon Notion) -->
<div x-show="$store.fdIconPicker.title && $store.fdIconPicker.category !== 'recent' && !$store.fdIconPicker.query && $store.fdIconPicker.recent.length">
<div class="fdip-section">Recent</div>
<div class="fdip-grid">
<template x-for="e in $store.fdIconPicker.recent.slice(0, 8)" :key="'rt' + e">
<button class="fdip-emoji" @click="$store.fdIconPicker.pick($store.fdIconPicker.withTone(e))" x-text="$store.fdIconPicker.withTone(e)"></button>
</template>
</div>
</div>
<div class="fdip-section" x-show="$store.fdIconPicker.category === 'recent' && !$store.fdIconPicker.query">Recent</div>
<div class="fdip-grid">
<template x-for="e in $store.fdIconPicker.items()" :key="e">
+206 -78
View File
@@ -510,6 +510,9 @@
<div class="more-menu-item" @click="moreSaveTemplate()">
📑 Save as template
</div>
<div class="more-menu-item" @click="openTplPicker()">
{{ fd_icon("file",14) }} Apply template…
</div>
<div class="more-menu-sep"></div>
<div class="more-menu-item danger" @click="deletePage">
{{ fd_icon("trash",14) }} Move to Trash
@@ -578,74 +581,132 @@
background:var(--bg-modal,#1f1f1f);border:1px solid var(--border,#333);border-radius:10px;
box-shadow:var(--shadow-modal);padding:6px;"></div>
<!-- ═══════════ Inline comment trigger on selection (v4.9.0) ═══════════ -->
<button type="button" id="_commentSelBtn"
style="display:none;position:fixed;z-index:1100;padding:7px 12px;font-size:13px;font-weight:600;
color:#fff;background:var(--accent,#2383E2);border:none;border-radius:8px;cursor:pointer;
box-shadow:0 4px 16px rgba(0,0,0,.35);" title="Comment on selection"
@click="edCall('commentOnSelection')">
💬 Comment
</button>
<!-- ═══════════ Comments drawer (v4.9.0) ═══════════ -->
<!-- ═══════════ Comments drawer (v4.9.0 · refonte v7.64 façon Notion) ═══════════ -->
<div class="comments-drawer" x-show="commentsOpen" x-cloak x-transition
style="position:fixed;top:var(--topbar-height,44px);right:0;bottom:0;width:320px;max-width:92vw;
style="position:fixed;top:var(--topbar-height,44px);right:0;bottom:0;width:340px;max-width:92vw;
background:var(--bg-primary,#1c1c1c);border-left:1px solid var(--border,#333);
box-shadow:-8px 0 30px rgba(0,0,0,.25);z-index:900;display:flex;flex-direction:column;">
<div class="comments-drawer-header"
style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;
border-bottom:1px solid var(--border,#333);font-weight:600;font-size:14px;">
<span>Comments</span>
<span>Comments <span style="color:var(--text-dim);font-weight:400;" x-text="'(' + commentCount + ')'"></span></span>
<button type="button" class="topbar-btn" @click="toggleComments()" title="Close">✕</button>
</div>
<div class="comments-drawer-list" style="flex:1;overflow-y:auto;padding:12px 16px;">
<div x-show="comments.length === 0" style="text-align:center;color:var(--text-dim,#999);padding:32px 0;">
<!-- v7.67 : les réactions emoji du texte vivent aussi ici (✕ = retirer) -->
<div class="ct-reactions" x-show="reactionList().length" x-cloak style="margin-bottom:14px;">
<div class="ct-section-title">Reactions</div>
<template x-for="r in reactionList()" :key="r.key">
<div class="ct-reaction">
<span class="ct-re-emoji" x-text="r.emoji"></span>
<span class="ct-re-count" x-show="r.count > 1" x-text="r.count"></span>
<span class="ct-re-text" x-text="r.text"></span>
<button type="button" class="ct-act" title="Remove reaction"
x-show="r.mine" @click="removeReaction(r)">✕</button>
</div>
</template>
</div>
<div x-show="comments.length === 0 && !reactionList().length" style="text-align:center;color:var(--text-dim,#999);padding:32px 0;">
No comments yet. Select some text and click 💬 Comment.
</div>
<template x-for="c in comments" :key="c.id">
<div class="comment-thread" style="margin-bottom:18px;"
:style="c.anchor_block_id ? 'border-left:3px solid var(--accent);padding-left:10px;' : ''">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
<div style="width:22px;height:22px;border-radius:50%;background:#3A3A3A;color:#fff;
display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;"
<div class="comment-thread" :data-thread="c.id"
:class="{ 'is-resolved': c.resolved, 'has-anchor': !!c.anchor_block_id }">
<div class="ct-head">
<div class="ct-avatar"
:style="{ background: c.author && c.author.avatar_color ? c.author.avatar_color : '#3A3A3A' }">
<span x-text="(c.author ? (c.author.full_name || c.author.login || '?') : '?').charAt(0).toUpperCase()"></span>
</div>
<span style="font-size:12px;font-weight:600;" x-text="c.author ? (c.author.full_name || c.author.login) : 'Unknown'"></span>
<span style="font-size:11px;color:var(--text-dim,#999);margin-left:auto;" x-text="fmtTime(c.created_at)"></span>
</div>
<div style="font-size:13px;white-space:pre-wrap;margin-bottom:6px;" x-text="c.body"></div>
<div style="display:flex;gap:8px;align-items:center;">
<button class="btn-sm" style="font-size:11px;" @click="resolveComment(c.id)"
x-text="c.resolved ? '↪ Reopen' : '✔ Resolve'"></button>
<button class="btn-sm" style="font-size:11px;" x-show="c.user_id === currentUserId"
@click="deleteComment(c.id)">🗑 Delete</button>
<span class="ct-name" x-text="c.author ? (c.author.full_name || c.author.login) : 'Unknown'"></span>
<span class="ct-time" x-text="fmtTime(c.created_at)"></span>
<div class="ct-actions">
<button type="button" class="ct-act" :title="c.resolved ? 'Reopen' : 'Resolve'"
@click="resolveComment(c)"
:class="{ 'is-on': c.resolved }">✓</button>
<button type="button" class="ct-act" title="More"
x-show="c.user_id === currentUserId"
@click.stop="commentMenu = commentMenu === c.id ? null : c.id">⋯</button>
</div>
<div class="ct-menu" x-cloak x-show="commentMenu === c.id" @click.outside="commentMenu = null">
<button type="button" class="ct-menu-item" @click="copyCommentLink(c.id)">🔗 Copy link</button>
<button type="button" class="ct-menu-item is-danger" @click="deleteComment(c.id)">🗑 Delete</button>
</div>
</div>
<div class="ct-body" x-text="c.body"></div>
<div class="ct-anchor-label" x-show="!!c.anchor_block_id && !c.resolved">💬 on selected text</div>
</div>
</template>
</div>
<div class="comments-drawer-input" style="border-top:1px solid var(--border,#333);padding:12px 16px;">
<textarea x-model="commentDraft" rows="2"
placeholder="Add a comment... Use @ to mention someone."
style="width:100%;background:var(--bg-secondary,#2a2a2a);border:1px solid var(--border,#333);
border-radius:8px;color:var(--text);font-size:13px;padding:8px 10px;resize:none;outline:none;"></textarea>
<div style="display:flex;justify-content:flex-end;margin-top:8px;">
<button class="btn btn-primary" style="font-size:12px;padding:6px 14px;" @click="addPageComment()">Comment</button>
<div class="comments-drawer-input">
<div class="ct-input-row">
<textarea x-model="commentDraft" rows="1"
placeholder="Add a comment..."
@keydown.enter.exact.prevent="addPageComment()"></textarea>
<div class="ct-input-actions">
<button type="button" class="ct-act" title="Mention" @click="commentAt()">@</button>
<button type="button" class="ct-send" title="Send"
:disabled="!(commentDraft || '').trim()" @click="addPageComment()">↑</button>
</div>
</div>
</div>
</div>
<!-- ═══════════ Menu contextuel au highlight de texte (v7.62.0, façon Notion) ═══════════ -->
<div
class="format-toolbar"
x-show="fmt.open"
x-cloak
:style="{top:fmt.top+'px',left:fmt.left+'px'}"
@mousedown.prevent
>
<button @click="fmtApply('bold')"><strong>B</strong></button>
<button @click="fmtApply('italic')"><em>I</em></button>
<button @click="fmtApply('underline')"><u>U</u></button>
<button @click="fmtApply('strikeThrough')"><s>S</s></button>
<button @click="fmtLink()">{{ fd_icon("link",14) }}</button>
<!-- Ligne 1 : style de texte -->
<div class="ft-row">
<button type="button" class="ft-item ft-style"
@click="fmtStyle($event)"
data-tooltip-html="<em>To be the foremost driver…</em><br>Just start writing with plain text">
<span class="ft-style-ico">T</span>
<span class="ft-style-name" x-text="fmtStyleName()"></span>
<span class="ft-caret">›</span>
</button>
</div>
<!-- Ligne 2 : formatage de base -->
<div class="ft-row">
<button type="button" class="ft-item ft-a" @click="fmtColor($event)" data-tooltip="Color — text and background colors"><span class="ft-a-letter">A</span></button>
<button type="button" class="ft-item" @click="fmtApply('bold')" data-tooltip="Bold — Ctrl+B"><strong>B</strong></button>
<button type="button" class="ft-item" @click="fmtApply('italic')" data-tooltip="Italic — Ctrl+I"><em>I</em></button>
<button type="button" class="ft-item" @click="fmtApply('underline')" data-tooltip="Underline"><u>U</u></button>
<button type="button" class="ft-item" @click="fmtApply('strikeThrough')" data-tooltip="Strikethrough"><s>T</s></button>
<button type="button" class="ft-item ft-tx" @click="fmtCode()" data-tooltip="Inline code">Tx</button>
</div>
<!-- Ligne 3 : outils avancés -->
<div class="ft-row">
<button type="button" class="ft-item" @click="fmtLink()" data-tooltip="Link">{{ fd_icon("link",15) }}</button>
<button type="button" class="ft-item" @click="fmtHighlight()" data-tooltip="Highlight"><span class="ft-hl">S</span></button>
<button type="button" class="ft-item ft-code-ico" @click="fmtCodeBlock()" data-tooltip="Code block">&lt;/&gt;</button>
<button type="button" class="ft-item" @click="fmtEquation()" data-tooltip="Equation">√x</button>
<button type="button" class="ft-item ft-more" @click="fmtMore($event)" data-tooltip="More actions">…</button>
</div>
<!-- Ligne 4 : Comment à gauche, réaction à droite (le ✨ Ask AI sauté :
doublon exact du bouton du pied, même action) -->
<div class="ft-row ft-row-split">
<button type="button" class="ft-item ft-comment" @click="fmtComment()" data-tooltip="Comment — Ctrl+⇧+M">{{ fd_icon("message-square",15) }}<span>Comment</span></button>
<button type="button" class="ft-item ft-react" @click="fmtReact()" data-tooltip="React to selected text"><span class="ft-react-ico">🙂<span class="ft-react-plus">+</span></span></button>
</div>
<!-- Section « Skills » -->
<div class="ft-sep"></div>
<div class="ft-group ft-skills-head">Skills</div>
<div class="ft-skills">
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Improve the writing of this text (wording, clarity, style). Keep the meaning, language and format. Reply ONLY with the improved text.')" data-tooltip="Run on this block">Improve writing</button>
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Proofread this text: fix spelling and grammar mistakes only. Reply ONLY with the corrected text.')" data-tooltip="Run on this block">Proofread</button>
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Explain this text in simpler terms, in the same language. Reply ONLY with the explanation.')" data-tooltip="Run on this block">Explain</button>
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Reformat this text for readability (structure, spacing, lists where useful) without changing its meaning. Reply ONLY with the reformatted text.')" data-tooltip="Run on this block">Reformat</button>
</div>
<!-- Pied de page -->
<div class="ft-sep"></div>
<button type="button" class="ft-item ft-foot" @click="fmtEditAI()" data-tooltip="Edit with AI">
<span>Edit with AI</span>
<kbd>Alt+⇧+E</kbd>
</button>
</div>
<!-- ═══════════ Copy Link Toast ═══════════ -->
@@ -682,51 +743,17 @@
</button>
<button
class="gs-pill"
@click="createForm()"
@click="openFormModal()"
>
<span class="gs-icon">{{ fd_icon("copy",14) }}</span> Form
</button>
<button
class="gs-pill"
@click="gsMoreOpen = !gsMoreOpen"
@click="openTplPicker()"
>
<span class="gs-icon">{{ fd_icon("file",14) }}</span> Templates
</button>
<div style="position: relative">
<button class="gs-pill" @click="gsMoreOpen = !gsMoreOpen">
<span>⋯</span>
</button>
<div
class="gs-more-dropdown"
x-show="gsMoreOpen"
@click.outside="gsMoreOpen = false"
x-transition
>
<div class="gs-more-item" @click="gsMoreOpen=false">
{{ fd_icon("bar-chart",14) }} Table
</div>
<div class="gs-more-item" @click="gsMoreOpen=false">
{{ fd_icon("copy",14) }} Board
</div>
<div class="gs-more-item" @click="gsMoreOpen=false">
{{ fd_icon('list',14) }} List
</div>
<div class="gs-more-item" @click="gsMoreOpen=false">
{{ fd_icon("calendar",14) }} Timeline
</div>
<div class="gs-more-item" @click="gsMoreOpen=false">
{{ fd_icon("calendar",14) }} Calendar
</div>
<div class="gs-more-item" @click="gsMoreOpen=false">
{{ fd_icon("image",14) }} Gallery
</div>
<div class="gs-more-sep"></div>
<div class="gs-more-item" @click="gsMoreOpen=false">
{{ fd_icon("download",14) }} Import
</div>
</div>
</div>
</div>
<div class="editor-statusbar">
<span x-show="saving">Saving...</span>
@@ -739,8 +766,8 @@
<!-- ═══════════ Database template picker (v5.3.0) ═══════════ -->
<div class="fd-dbtpl-overlay" id="fd-dbtpl" x-show="dbTplOpen"
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2100;
align-items:flex-start;justify-content:center;padding-top:10vh;opacity:0;transition:opacity .15s ease;"
style="position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2100;display:flex;align-items:center;justify-content:center;padding:20px;"
x-transition.opacity
@click.self="closeDbTemplatePicker()">
<div class="fd-dbtpl" style="width:620px;max-width:94vw;max-height:76vh;display:flex;flex-direction:column;
background:var(--bg-secondary,#232323);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:12px;
@@ -775,6 +802,84 @@
</div>
</div>
<!-- ═══════════ Unified template picker (registre unifié, §7.1) ═══════════
Toujours une issue visible (P5) : liste, état vide avec création,
ou erreur explicite avec Réessayer — jamais un no-op silencieux. -->
<div class="fd-tpl-overlay" id="fd-tpl-picker" data-testid="tpl-picker" x-show="tplOpen"
style="position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2200;"
x-transition.opacity
@click.self="closeTplPicker()">
<!-- Enveloppe flex SÉPARÉE du x-show : Alpine restaure display:block
sur l'overlay (pas le flex inline) — sans cette enveloppe, le
dialogue reste collé en haut à gauche au lieu d'être centré. -->
<div style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;padding:20px;box-sizing:border-box;">
<div class="fd-tpl" style="width:760px;max-width:96vw;max-height:80vh;display:flex;flex-direction:column;
background:var(--bg-secondary,#232323);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:12px;
box-shadow:0 24px 70px rgba(0,0,0,.6);overflow:hidden;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;border-bottom:1px solid var(--border);">
<h3 style="margin:0;font-size:16px;color:var(--text,#fff);">Appliquer un template</h3>
<button class="flowdeck-modal-close" @click="closeTplPicker()">&times;</button>
</div>
<div style="padding:12px 20px 0;">
<input type="text" x-model="tplQuery" @input.debounce.300ms="loadTplList()" placeholder="🔍 Rechercher un template…"
style="width:100%;padding:9px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);font-size:14px;outline:none;box-sizing:border-box;">
<div class="fd-gtpl-filters" style="margin-top:10px;">
<template x-for="k in tplKindOptionsList" :key="k">
<button type="button" class="fd-tpl-filter" :class="tplKind === k ? 'active' : ''"
@click="setTplKind(k)" x-text="tplKindLabel(k)"></button>
</template>
<a href="/templates" style="margin-left:auto;font-size:12px;color:var(--text-dim);align-self:center;text-decoration:none;padding-right:6px;">Gérer les templates…</a>
</div>
</div>
<div style="display:flex;gap:0;flex:1;min-height:0;padding:0 20px 12px;">
<div style="flex:1;overflow-y:auto;min-height:120px;max-height:44vh;padding-right:8px;">
<div x-show="tplLoading" style="color:var(--text-dim,#999);font-size:13px;padding:20px 0;text-align:center;">Chargement…</div>
<div x-show="tplError" style="color:#e5484d;font-size:13px;padding:12px;background:rgba(229,72,77,.1);border-radius:8px;margin-bottom:8px;">
<span x-text="tplError"></span>
<button @click="loadTplList()" style="margin-left:8px;">Réessayer</button>
</div>
<template x-for="t in tplItems" :key="t.id">
<div data-testid="tpl-item" @click="selectTpl(t)" :style="tplSelected && tplSelected.id === t.id ? 'border-color:var(--accent);' : ''"
style="display:flex;align-items:center;gap:10px;padding:9px 12px;border:1px solid var(--border);border-radius:8px;margin-bottom:8px;cursor:pointer;">
<span style="font-size:18px;" x-text="t.icon || '📄'"></span>
<div style="flex:1;min-width:0;">
<div style="font-weight:600;color:var(--text,#fff);font-size:13px;" x-text="t.name"></div>
<div style="font-size:11px;color:var(--text-dim,#999);" x-text="(t.kind + ' · ' + t.scope)"></div>
</div>
</div>
</template>
<div x-show="!tplLoading && !tplError && !tplItems.length" style="color:var(--text-dim,#999);font-size:13px;padding:16px 0;text-align:center;">
Aucun template pour ce filtre — <a href="/templates">créez-en un</a> ou choisissez un preset système.
</div>
</div>
<div style="flex:1;overflow-y:auto;min-height:120px;max-height:44vh;border-left:1px solid var(--border);padding-left:12px;" x-show="tplSelected">
<div style="font-weight:600;color:var(--text,#fff);font-size:14px;margin-bottom:4px;" x-text="tplPreviewTitle"></div>
<div x-show="tplPreviewWarnings.length" style="color:#f5a524;font-size:12px;margin-bottom:6px;" x-text="tplPreviewWarnings.join(' · ')"></div>
<template x-for="b in tplPreviewBlocks.slice(0,8)" :key="b.id">
<div style="font-size:12px;color:var(--text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" x-text="('• ' + (b.content || ('[' + (b.type||'?') + ']')))"></div>
</template>
<div x-show="tplVarDefs.length" style="margin-top:8px;">
<div style="font-size:12px;font-weight:600;color:var(--text,#fff);margin-bottom:4px;">Variables</div>
<template x-for="v in tplVarDefs" :key="v.name">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span style="font-size:12px;color:var(--text-dim);min-width:80px;" x-text="v.label || v.name"></span>
<input type="text" :placeholder="v.default || ''" @input="setTplVar(v.name, $event.target.value)"
style="flex:1;padding:5px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);font-size:12px;">
</div>
</template>
</div>
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;padding:12px 20px;border-top:1px solid var(--border);">
<button class="flowdeck-modal-close" @click="closeTplPicker()" style="padding:8px 16px;position:static;">Annuler</button>
<button data-testid="tpl-apply" @click="applyTpl()" :disabled="!tplSelected || tplApplying"
style="padding:8px 20px;border-radius:8px;border:none;background:var(--accent);color:#fff;font-weight:600;cursor:pointer;opacity:.95;"
x-text="tplApplying ? 'Application…' : 'Utiliser'"></button>
</div>
</div>
</div>
</div>
<!-- ═══════════ v5.4.0: Version History Popover ═══════════ -->
<div class="versions-popover"
x-show="versionsOpen"
@@ -871,4 +976,27 @@
</div>
</div>
</div>
<!-- ═══════════ Get Started Form name modal ═══════════ -->
<div class="flowdeck-modal-overlay"
x-show="formOpen"
style="position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2100;display:flex;align-items:center;justify-content:center;padding:20px;"
@click.self="formOpen = false"
x-transition.opacity>
<div class="flowdeck-modal" @keydown.escape="formOpen = false">
<div class="flowdeck-modal-header">
<h3>{{ fd_icon("copy",16) }} Create form</h3>
<button class="flowdeck-modal-close" @click="formOpen = false">&times;</button>
</div>
<div class="flowdeck-modal-body">
<p>Enter a name for the new form.</p>
<input class="flowdeck-modal-input" type="text" x-model="formName" x-ref="formNameInput"
placeholder="Form name" @keydown.enter="confirmCreateForm()">
</div>
<div class="flowdeck-modal-footer">
<button class="flowdeck-modal-btn cancel" @click="formOpen = false">Cancel</button>
<button class="flowdeck-modal-btn confirm" @click="confirmCreateForm()">Create</button>
</div>
</div>
</div>
</div>
+6
View File
@@ -1,4 +1,10 @@
<script type="application/json" id="page-data" nonce="{{ csp_nonce() }}">
{{ page_data | tojson }}
</script>
<link rel="stylesheet" href="/static/css/highlight.css?v={{ asset_version }}">
<script data-cfasync="false" src="/static/js/highlight.min.js?v={{ asset_version }}"></script>
<script data-cfasync="false" src="/static/js/highlight-extra.min.js?v={{ asset_version }}"></script>
<script data-cfasync="false" src="/static/js/code-highlight.js?v={{ asset_version }}"></script>
<script data-cfasync="false" src="/static/js/page_editor_scripts.js?v={{ asset_version }}"></script>
<link rel="stylesheet" href="/static/css/meeting.css?v={{ asset_version }}">
<script data-cfasync="false" src="/static/js/meeting_block.js?v={{ asset_version }}"></script>
+482 -129
View File
@@ -4,15 +4,27 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlowDeck — {% block title_prefix %}Home{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="icon" href="/static/favicon.ico" sizes="48x48">
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#191919">
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<!-- v7.69 : identité visuelle (logo + bannière du projet) -->
<meta name="description" content="FlowDeck — espace de travail style Notion auto-hébergé, intégré à Gitea/GitHub.">
<meta property="og:type" content="website">
<meta property="og:site_name" content="FlowDeck">
<meta property="og:title" content="FlowDeck — votre espace Notion auto-hébergé">
<meta property="og:description" content="Éditeur de blocs, Kanban, bases de données et intégration Gitea/GitHub, sur votre propre serveur.">
<meta property="og:image" content="{{ app_base_url() }}/static/img/logo-512.jpg">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="FlowDeck — votre espace Notion auto-hébergé">
<meta name="twitter:description" content="Éditeur de blocs, Kanban, bases de données et intégration Gitea/GitHub.">
<meta name="twitter:image" content="{{ app_base_url() }}/static/img/logo-512.jpg">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="/static/css/app.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/design-tokens.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/components.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/settings.css?v={{ asset_version }}">
<!-- My Tasks + page « /db » : lies dans le SHELL et non dans la zone
swappée. Un <link> à l'intérieur de `.main-wrapper` peut être retiré par
htmx lors d'une navigation partielle : la page revenait alors sans
@@ -317,16 +329,18 @@
</div>
</div>
<!-- Repository (Gitea) — only visible for Gitea workspaces -->
<!-- Repository (forge) — same navigation structure as Workspace-local -->
{% if gitea_workspace %}
<div class="sidebar-section" x-show="isSectionVisible('gitea')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('gitea')" @contextmenu.prevent="openSectionMenu($event, 'gitea')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.gitea }">▶</span>
<span class="section-icon">{{ fd_icon("link",14) }}</span>
<span class="section-label">Repository (Gitea)</span>
<span class="section-label">Repository{% if forge %} ({{ forge|capitalize }}){% elif gitea_owner and gitea_repo %} (Gitea){% endif %}</span>
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="New file in local mirror" @click.stop="fdGwNewFile()">{{ fd_icon("file",16) }}</button>
<button class="section-action-btn" title="New folder in local mirror" @click.stop="fdGwNewFolder()">{{ fd_icon("folder",16) }}</button>
<button class="section-action-btn" title="Refresh" @click.stop="fdGwRefresh()">{{ fd_icon("refresh",16) }}</button>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'gitea')">⋮</button>
</div>
@@ -349,13 +363,28 @@
</div>
<div class="sidebar-section-actions">
{% if has_active_workspace %}
<button class="section-action-btn" title="Add" @click.stop="fdCreatePage()">+</button>
<button class="section-action-btn" title="New meeting note" @click.stop="fdCreateMeeting()">+</button>
{% endif %}
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'meetings')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.meetings" x-transition>
<ul class="sidebar-items"><li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("calendar",16) }}</span><span class="page-name text-dim">No meetings</span></li></ul>
<ul class="sidebar-items" id="sidebar-meetings-items">
<template x-for="n in meetingNotes" :key="'mn-' + n.id">
<li class="sidebar-item" @click="meetingNoteOpen(n)"
@contextmenu.prevent="showWsContext($event, n.id, n.title, false)">
<span class="page-icon page-icon-svg">{{ fd_icon("calendar",14) }}</span>
<span style="flex:1;min-width:0;">
<span class="page-name" style="display:block;" x-text="n.title"></span>
<span class="sb-item-sub" x-text="n.has_summary ? 'Summary ready' : (n.status && n.status !== 'idle' ? n.status : 'Ready to record')"></span>
</span>
</li>
</template>
<li class="sidebar-item empty-hint" x-show="!meetingNotes.length">
<span class="page-icon page-icon-svg">{{ fd_icon("calendar",16) }}</span>
<span class="page-name text-dim">No meetings</span>
</li>
</ul>
</div>
</div>
@@ -579,6 +608,9 @@
<a href="/library" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;">
<span class="page-icon page-icon-svg">{{ fd_icon("book",16) }}</span><span class="page-name" style="font-size:14px;display:flex;align-items:center;gap:4px;">Library</span>
</a>
<a href="/templates" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;">
<span class="page-icon page-icon-svg">{{ fd_icon("file",16) }}</span><span class="page-name" style="font-size:14px;display:flex;align-items:center;gap:4px;">Templates</span>
</a>
<a href="/my-tasks" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;">
<span class="page-icon page-icon-svg">{{ fd_icon("check-square",16) }}</span><span class="page-name" style="font-size:14px;display:flex;align-items:center;gap:4px;">My Tasks</span>
</a>
@@ -650,6 +682,10 @@
<div class="sb-filter-row">
<button type="button" class="sb-filter-chip" :class="{ active: meetingFilter === 'upcoming' }" @click="meetingFilter = 'upcoming'">Upcoming</button>
<button type="button" class="sb-filter-chip" :class="{ active: meetingFilter === 'today' }" @click="meetingFilter = 'today'">Today</button>
<button type="button" class="sb-filter-chip" :class="{ active: meetingFilter === 'past' }" @click="meetingFilter = 'past'; loadMeetingHistory()">Past</button>
</div>
<div class="sb-search-row">
<input type="search" class="sb-search-input" placeholder="Search meetings…" x-model="meetingSearch" @input="if (meetingFilter === 'past') loadMeetingHistory()" aria-label="Search meetings">
</div>
<div class="sb-quick-actions">
@@ -659,15 +695,18 @@
</div>
<ul class="sidebar-items">
<template x-for="ev in meetingVisible" :key="ev.collection_id + '-' + ev.id">
<template x-for="ev in meetingVisible" :key="(ev.collection_id || 'h') + '-' + ev.id">
<li class="sidebar-item" @click="meetingOpen(ev)">
<span class="page-icon page-icon-svg">{{ fd_icon("calendar",14) }}</span>
<span class="page-name" x-text="ev.title"></span>
<span class="sb-item-meta" x-text="ev.today ? 'Today' : ev.date"></span>
<span style="flex:1;min-width:0;">
<span class="page-name" style="display:block;" x-text="ev.title"></span>
<span class="sb-item-sub" x-text="meetingCta(ev)"></span>
</span>
<span class="sb-item-meta" x-text="ev.today ? 'Today' : (ev.date || '')"></span>
</li>
</template>
<li class="sidebar-item empty-hint" x-show="!meetingsLoading && !meetingVisible.length">
<span class="page-name text-dim" x-text="meetingFilter === 'today' ? 'Nothing scheduled today' : 'No upcoming meetings'"></span>
<span class="page-name text-dim" x-text="meetingEmptyHint()"></span>
</li>
<li class="sidebar-item empty-hint" x-show="meetingsLoading">
<span class="page-name text-dim">Loading…</span>
@@ -707,18 +746,38 @@
</ul>
</div>
<!-- Footer -->
<!-- Footer : bouton "New" + popover "Create new" (vers le haut) -->
<div class="sidebar-footer">
<div style="display:flex; gap:6px;">
<button class="sidebar-footer-btn" style="flex:1; justify-content:center; background:var(--bg-hover); border-radius:20px;">
<span style="margin-right:4px;">{{ fd_icon("message-square",14) }}</span>New chat
<span class="shortcut">Ctrl+O</span>
<div class="fd-new-wrap" @click.outside="closeNewMenu()">
<div class="fd-new-popover" x-show="newMenuOpen" x-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
role="menu" aria-label="Create new">
<div class="fd-new-pop-header">Create new</div>
<button type="button" class="fd-new-pop-item" role="menuitem" @click="newMenuAction('page')">
<span>{{ fd_icon("file",16) }}</span><span>Page</span>
</button>
<button type="button" class="fd-new-pop-item" role="menuitem" @click="newMenuAction('template')">
<span>{{ fd_icon("copy",16) }}</span><span>From template…</span>
</button>
<button type="button" class="fd-new-pop-item" role="menuitem" @click="newMenuAction('chat')">
<span>{{ fd_icon("message-square",16) }}</span><span>Chat</span>
</button>
<button type="button" class="fd-new-pop-item" role="menuitem" @click="newMenuAction('meeting')">
<span><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="17" x2="12" y2="22"/><line x1="8" y1="22" x2="16" y2="22"/></svg></span><span>AI Meeting Notes</span>
</button>
<button type="button" class="fd-new-pop-item" role="menuitem" @click="newMenuAction('database')">
<span>{{ fd_icon("grid",16) }}</span><span>Database</span>
</button>
</div>
<button type="button" class="fd-new-btn" :class="{ open: newMenuOpen }"
@click="toggleNewMenu()" :aria-expanded="newMenuOpen" title="Create new">
<span>{{ fd_icon("edit",16) }}</span><span>New</span>
</button>
{% if has_active_workspace %}
<button class="sidebar-footer-btn" style="width:36px; justify-content:center; background:var(--bg-hover); border-radius:8px;" onclick="window.FlowDeck.createPage()" title="New page">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
</button>
{% endif %}
</div>
</div>
</div><!-- /sidebar-scroll -->
@@ -887,6 +946,9 @@
</div>
</template>
<div class="context-menu-section">
<div class="context-menu-item" @click="wsCtxAction('open')">
<span class="menu-icon">{{ fd_icon("external-link",14) }}</span> Open
</div>
<div class="context-menu-item" @click="wsCtxAction('rename')">
<span class="menu-icon">{{ fd_icon("edit",14) }}</span> Rename
</div>
@@ -1109,105 +1171,189 @@
},
/**
* Create a new page: opens the v5.12.0 template picker (built-in + the
* user's saved templates), then instantiates the chosen one.
* Create a new page: **blank by default** (Notion behaviour — the "New"
* button creates an empty document immediately). Template-based creation
* lives in {@link createPageFromTemplate}.
*/
createPage: function(parentId) {
var self = this;
// Explicit folder (row/context-menu button) wins; otherwise create in the
// folder the user is currently browsing on the local-workspace page.
if (parentId == null || parentId === '') parentId = this._currentFolderId();
this._createParentId = parentId || 0;
// Direct creation path (fallback / no-template choice).
this._createPlainPage = function() {
var ctx = self._getContext();
if (ctx.wsId > 0) {
var body = { name: '', type: 'page' };
if (ctx.isGitea) body.workspace_id = ctx.wsId;
if (self._createParentId) body.parent_id = self._createParentId;
fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': self.getCsrfToken() },
body: JSON.stringify(body)
})
.then(function(r) { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(function(data) { window.location.href = '/pages/' + data.id; })
.catch(function(err) { self._toast('Failed: ' + err.message, 'error'); });
this._createPlainPage();
},
/** Create an empty page and navigate to it. */
_createPlainPage: function() {
var self = this;
var ctx = this._getContext();
if (ctx.wsId > 0) {
var body = { name: '', type: 'page' };
if (ctx.isGitea) body.workspace_id = ctx.wsId;
if (this._createParentId) body.parent_id = this._createParentId;
fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify(body)
})
.then(function(r) { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(function(data) { window.location.href = '/pages/' + data.id; })
.catch(function(err) { self._toast('Failed: ' + err.message, 'error'); });
return;
}
var project = ctx.workspaceKey;
var url = '/board/api/pages?section=Private&project=' + encodeURIComponent(project);
fetch(url, {
method: 'POST',
headers: { 'X-CSRF-Token': this.getCsrfToken() }
})
.then(function(r) { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
.then(function(data) { window.location.href = '/pages/' + data.id; })
.catch(function(err) { self._toast('Failed to create page: ' + err.message, 'error'); });
},
/** Open the unified template picker (registry-backed) to create from a template. */
createPageFromTemplate: function(parentId, kind) {
if (parentId == null || parentId === '') parentId = this._currentFolderId();
this._createParentId = parentId || 0;
this._openTemplatePicker(kind);
},
/**
* Unified template picker (registre `templates`, §7.1) — self-contained DOM
* modal usable on ANY page (sidebar, workspace local, library). Always shows
* a visible state (list / empty / error) and a working Cancel + ✕ (P5).
*/
_openTemplatePicker: function(kindFilter) {
var self = this;
var existing = document.getElementById('fd-global-tpl-picker');
if (existing) existing.remove();
var state = { kind: kindFilter || 'page', query: '', items: [], selected: null };
var ov = document.createElement('div');
ov.id = 'fd-global-tpl-picker';
ov.className = 'fd-gtpl-overlay open';
ov.innerHTML =
'<div class="fd-gtpl" role="dialog" aria-label="Nouveau depuis un template">'
+ '<div class="fd-gtpl-head"><span class="fd-gtpl-title">Nouveau depuis un template</span>'
+ '<button type="button" class="fd-gtpl-x" data-gtpl="close" aria-label="Fermer">&times;</button></div>'
+ '<div class="fd-gtpl-body">'
+ '<input type="text" class="fd-gtpl-search" data-gtpl="search" placeholder="🔍 Rechercher un template…">'
+ '<div class="fd-gtpl-filters" data-gtpl="filters"></div>'
+ '<div class="fd-gtpl-list" data-gtpl="list"><div class="fd-gtpl-empty">Chargement…</div></div>'
+ '</div>'
+ '<div class="fd-gtpl-foot">'
+ '<button type="button" class="btn-sm" data-gtpl="cancel" style="padding:8px 16px">Annuler</button>'
+ '<button type="button" class="btn-sm" data-gtpl="use" style="padding:8px 20px;background:var(--accent);color:#fff;border:none;font-weight:600;cursor:pointer" disabled>Utiliser</button>'
+ '</div></div>';
document.body.appendChild(ov);
var KINDS = [
{ k: 'page', label: 'Pages' },
{ k: 'database', label: 'Bases' },
{ k: 'row', label: 'Lignes' },
{ k: 'blocks', label: 'Blocs' },
{ k: '', label: 'Tous' },
];
var labels = { page: 'Page', row: 'Ligne', database: 'Base', blocks: 'Blocs' };
function esc(s) { return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
var elFilters = ov.querySelector('[data-gtpl="filters"]');
var elList = ov.querySelector('[data-gtpl="list"]');
var elSearch = ov.querySelector('[data-gtpl="search"]');
var elUse = ov.querySelector('[data-gtpl="use"]');
function renderFilters() {
elFilters.innerHTML = KINDS.map(function(k) {
var active = state.kind === k.k ? ' active' : '';
return '<button type="button" class="fd-tpl-filter' + active + '" data-kind="' + k.k + '">' + k.label + '</button>';
}).join('') + '<a href="/templates" style="margin-left:auto;align-self:center;font-size:12px;color:var(--text-dim);text-decoration:none;">Gérer…</a>';
elFilters.querySelectorAll('[data-kind]').forEach(function(b) {
b.onclick = function() { state.kind = b.getAttribute('data-kind'); renderFilters(); load(); };
});
}
function renderList() {
if (!state.items.length) {
elList.innerHTML = '<div class="fd-gtpl-empty">Aucun template pour ce filtre — <a href="/templates">créez-en un</a>.</div>';
return;
}
var project = ctx.workspaceKey;
var url = '/board/api/pages?section=Private&project=' + encodeURIComponent(project);
fetch(url, {
method: 'POST',
headers: { 'X-CSRF-Token': self.getCsrfToken() }
})
.then(function(r) { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
.then(function(data) { window.location.href = '/pages/' + data.id; })
.catch(function(err) { self._toast('Failed to create page: ' + err.message, 'error'); });
};
this._openTemplatePicker();
elList.innerHTML = state.items.map(function(t, i) {
var sel = state.selected && state.selected.id === t.id ? ' sel' : '';
return '<div class="fd-gtpl-card' + sel + '" data-idx="' + i + '">'
+ '<span class="fd-gtpl-ico">' + esc(t.icon || '📄') + '</span>'
+ '<span style="flex:1;min-width:0;"><span class="fd-gtpl-name">' + esc(t.name) + '</span>'
+ '<div class="fd-gtpl-meta">' + esc((labels[t.kind] || t.kind) + ' · ' + t.scope) + '</div></span></div>';
}).join('');
elList.querySelectorAll('[data-idx]').forEach(function(card) {
card.onclick = function() {
state.selected = state.items[parseInt(card.getAttribute('data-idx'), 10)];
elUse.disabled = false;
renderList();
};
});
}
function load() {
elList.innerHTML = '<div class="fd-gtpl-empty">Chargement…</div>';
var p = new URLSearchParams();
if (state.kind) p.set('kind', state.kind);
if (state.query) p.set('q', state.query);
fetch('/api/templates?' + p.toString(), { credentials: 'same-origin' })
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function(d) { state.items = d.templates || []; renderList(); })
.catch(function(e) {
elList.innerHTML = '<div class="fd-gtpl-empty" style="color:var(--danger)">Chargement impossible : ' + esc(e.message)
+ ' <button type="button" class="btn-sm" data-retry style="margin-left:8px">Réessayer</button></div>';
var rb = elList.querySelector('[data-retry]');
if (rb) rb.onclick = load;
});
}
function close() { ov.remove(); }
ov.addEventListener('click', function(e) { if (e.target === ov) close(); });
ov.querySelector('[data-gtpl="close"]').onclick = close;
ov.querySelector('[data-gtpl="cancel"]').onclick = close;
document.addEventListener('keydown', function esc(ev) {
if (ev.key === 'Escape') { close(); document.removeEventListener('keydown', esc); }
});
elSearch.oninput = function() { state.query = elSearch.value; };
elSearch.onkeydown = function(ev) { if (ev.key === 'Enter') load(); };
elUse.onclick = function() { self._applyGlobalTemplate(state.selected, elUse, close); };
renderFilters();
load();
setTimeout(function() { elSearch.focus(); }, 50);
},
_openTemplatePicker: function() {
_applyGlobalTemplate: function(t, btn, close) {
if (!t) return;
var self = this;
function escAttr(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
if (document.getElementById('fd-tpl-picker')) { document.getElementById('fd-tpl-picker').style.display = 'flex'; return; }
var ov = document.createElement('div');
ov.id = 'fd-tpl-picker';
ov.className = 'tpl-picker-overlay';
ov.innerHTML = '<div class="tpl-picker-modal"><div class="tpl-picker-title">New page from template</div>'
+ '<div class="tpl-picker-sub">Pick a starting point — or start blank.</div>'
+ '<div class="tpl-grid" id="fd-tpl-grid"><div style="color:var(--text-dim);font-size:13px;padding:12px">Loading…</div></div>'
+ '<div style="text-align:right;margin-top:14px"><button class="btn-sm" id="fd-tpl-close" style="cursor:pointer;padding:6px 14px">Cancel</button></div></div>';
document.body.appendChild(ov);
ov.addEventListener('click', function(e){ if(e.target===ov) ov.style.display='none'; });
document.getElementById('fd-tpl-close').onclick = function(){ ov.style.display='none'; };
fetch('/board/api/page-templates', {credentials:'same-origin'})
.then(function(r){ return r.json(); })
.then(function(d){
var list = d.templates || [];
var h = '';
list.forEach(function(t){
var use = t.builtin
? "key:"+t.key
: "id:"+t.id;
h += '<div class="tpl-card" data-use="'+escAttr(use)+'">'
+ '<span class="tpl-card-icon">'+escAttr(t.icon||'📄')+'</span>'
+ '<span><span class="tpl-card-name">'+escAttr(t.name)+'</span>'
+ '<div class="tpl-card-desc">'+escAttr(t.description||'')+'</div></span></div>';
});
document.getElementById('fd-tpl-grid').innerHTML = h || '<div style="color:var(--text-dim)">No templates</div>';
document.querySelectorAll('#fd-tpl-grid .tpl-card').forEach(function(card){
card.onclick = function(){ self._useTemplate(card.dataset.use); };
});
}).catch(function(){ document.getElementById('fd-tpl-grid').innerHTML='<div style="color:var(--text-dim)">Failed to load templates</div>'; });
},
_useTemplate: function(use){
var ov = document.getElementById('fd-tpl-picker');
if (ov) ov.style.display = 'none';
var self = this;
if (!use || use === 'key:empty') { this._createPlainPage(); return; }
var parts = use.split(':');
var body = {};
var url;
// Pass the current workspace context so the created page is persisted
// in the active local/Gitea workspace (mirrors _createPlainPage()).
var ctx = self._getContext();
if (ctx.wsId > 0) { body.workspace_id = ctx.wsId; }
else if (ctx.workspaceKey) { body.workspace = ctx.workspaceKey; }
if (self._createParentId) { body.parent_id = self._createParentId; }
if (parts[0] === 'key') { body.key = parts.slice(1).join(':'); url = '/board/api/page-templates/0/use'; }
else { url = '/board/api/page-templates/' + parts[1] + '/use'; }
fetch(url, {
var ctx = this._getContext();
var context = {};
if (this._createParentId) context.parent_id = this._createParentId;
if (ctx.wsId > 0) context.workspace_id = ctx.wsId;
if (btn) { btn.disabled = true; btn.textContent = 'Application…'; }
fetch('/api/templates/' + encodeURIComponent(t.id) + '/instantiate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify(body)
body: JSON.stringify({ variables: {}, context: context })
})
.then(function(r){ if (!r.ok) throw new Error('Template failed'); return r.json(); })
.then(function(d){ window.location.href = '/pages/' + d.id; })
.catch(function(){ self._createPlainPage(); });
.then(function(r) {
if (!r.ok) return r.text().then(function(txt) { throw new Error('HTTP ' + r.status + ' ' + txt.slice(0, 160)); });
return r.json();
})
.then(function(d) {
(d.warnings || []).forEach(function(w) { self._toast(w, 'error'); });
if (close) close();
if (d.url) {
if (window.fdNavigate) window.fdNavigate(d.url); else window.location.href = d.url;
} else {
self._toast('Créé depuis « ' + t.name + ' »', 'success');
}
})
.catch(function(e) {
if (btn) { btn.disabled = false; btn.textContent = 'Utiliser'; }
self._toast('Échec : ' + e.message, 'error');
});
},
/** Show the global New Folder modal (parentId = target folder, optional). */
@@ -1282,6 +1428,8 @@
this.loadTeamspaces();
// Badge counts on the collapsed tabs (lazy fetch, no blocking).
this.loadInbox();
// v7.1.1 : notes de réunion (section Meetings, pas Workspace).
this.loadMeetingNotes();
this.initSidebarWidth();
document.addEventListener('fd-toggle-sidebar', () => this.toggleSidebar());
this.startClipAutoRefresh();
@@ -1298,7 +1446,7 @@
});
document.addEventListener('flowdeck:workspace-changed', () => {
clearTimeout(self._clipRefreshTimer);
self._clipRefreshTimer = setTimeout(() => self.refreshSidebarTree(), 400);
self._clipRefreshTimer = setTimeout(() => { self.refreshSidebarTree(); self.loadMeetingNotes(); }, 400);
});
// Also refresh when tab becomes visible (user returns to FlowDeck after clipping)
document.addEventListener('visibilitychange', () => {
@@ -1385,6 +1533,7 @@
giteaWorkspace: {{ 'true' if gitea_workspace else 'false' }},
giteaOwner: '{{ gitea_owner|default("") }}',
giteaRepo: '{{ gitea_repo|default("") }}',
forge: '{{ forge|default("gitea") }}',
giteaTree: [], // loaded client-side for sidebar
currentView: 'home',
@@ -1439,15 +1588,37 @@
// ── Meeting tab ──
meetings: [],
meetingPast: [],
meetingsLoading: false,
meetingFilter: 'upcoming',
meetingSearch: '',
meetingHistoryLoaded: false,
get meetingVisible() {
if (this.meetingFilter === 'today') {
return this.meetings.filter(function(e) { return e.today; });
var q = (this.meetingSearch || '').trim().toLowerCase();
function matches(e) {
if (!q) return true;
return ((e.title || '') + ' ' + (e.date || '')).toLowerCase().indexOf(q) >= 0;
}
return this.meetings.filter(function(e) { return !e.today; });
if (this.meetingFilter === 'today') {
return this.meetings.filter(function(e) { return e.today && matches(e); });
}
if (this.meetingFilter === 'past') {
return this.meetingPast.filter(matches);
}
return this.meetings.filter(function(e) { return !e.today && matches(e); });
},
get meetingToday() { return this.meetings.filter(function(e) { return e.today; }); },
meetingEmptyHint() {
if (this.meetingFilter === 'today') return 'Nothing scheduled today';
if (this.meetingFilter === 'past') return 'No past meeting notes yet';
return 'No upcoming meetings';
},
meetingCta(ev) {
if (ev.status === 'done' || ev.summary) return 'View summary';
if (ev.today) return 'Join & transcribe';
if (ev.page_id) return 'Open note';
return 'Prepare';
},
loadMeetings(force) {
var self = this;
if (!force && this.meetings.length) return Promise.resolve();
@@ -1460,8 +1631,38 @@
})
.catch(function() { self.meetings = []; self.meetingsLoading = false; });
},
loadMeetingHistory() {
var self = this;
this.meetingsLoading = true;
var q = encodeURIComponent((this.meetingSearch || '').trim());
return fetch('/api/v2/meetings/history?query=' + q + '&limit=50', { credentials: 'same-origin' })
.then(function(r) { return r.ok ? r.json() : { meetings: [] }; })
.then(function(d) {
self.meetingPast = (d && d.meetings) || [];
self.meetingHistoryLoaded = true;
self.meetingsLoading = false;
})
.catch(function() { self.meetingPast = []; self.meetingsLoading = false; });
},
meetingOpen(ev) {
var target = ev.url || ('/db/' + ev.collection_id);
var target = ev.url || (ev.page_id ? ('/pages/' + ev.page_id) : ('/db/' + ev.collection_id));
if (ev.today) {
try { this.toast('Opening today\u2019s meeting — press Start transcribing in the note.'); } catch (e) {}
}
if (window.fdNavigate) window.fdNavigate(target);
else window.location.href = target;
},
// ── v7.1.1 : notes avec bloc AI Meeting Notes → section Meetings ──
meetingNotes: [],
loadMeetingNotes() {
var self = this;
return fetch('/api/v2/meetings/notes?limit=50', { credentials: 'same-origin' })
.then(function(r) { return r.ok ? r.json() : { notes: [] }; })
.then(function(d) { self.meetingNotes = (d && d.notes) || []; })
.catch(function() { self.meetingNotes = []; });
},
meetingNoteOpen(n) {
var target = '/pages/' + n.id;
if (window.fdNavigate) window.fdNavigate(target);
else window.location.href = target;
},
@@ -1602,6 +1803,15 @@
this.wsCtx.visible = false;
if (!id) return;
switch(action) {
case 'open':
if (this.wsCtx.isFolder) {
if (typeof this.navigateToFolder === 'function') this.navigateToFolder(id);
} else {
var target = '/pages/' + id;
if (window.fdNavigate) window.fdNavigate(target);
else window.location.href = target;
}
break;
case 'new-file':
this.newPageInFolder(id);
break;
@@ -1620,6 +1830,7 @@
if (r.ok) {
if (window.FlowDeck && window.FlowDeck.syncPageTitle) window.FlowDeck.syncPageTitle(id, newName.trim());
self.refreshSidebarTree();
if (typeof self.loadMeetingNotes === 'function') self.loadMeetingNotes();
document.dispatchEvent(new CustomEvent('flowdeck:workspace-changed'));
if (window._wsData && window._wsData._reloadAfterAction) {
window._wsData._reloadAfterAction();
@@ -1632,6 +1843,7 @@
if (!id) break;
if (!confirm('Delete "' + name + '"?')) break;
this.deleteWorkspacePage(id, true);
if (typeof this.loadMeetingNotes === 'function') this.loadMeetingNotes();
break;
}
},
@@ -1673,20 +1885,63 @@
});
},
// ── Sections collapsible ──
// ── Sections collapsible (accordéon v7.70 : une seule ouverte à la fois) ──
sectionsOpen: (function() {
var def = { recents: true, private: true, meetings: true, favorites: true, agents: true, teamspaces: true, shared: true, published: true, apps: true, workspace: true, gitea: true };
var order = ['workspace','gitea','meetings','recents','favorites','agents','teamspaces','shared','published','private','apps'];
var def = { recents: false, private: false, meetings: false, favorites: false, agents: false, teamspaces: false, shared: false, published: false, apps: false, workspace: true, gitea: false };
try {
var saved = JSON.parse(localStorage.getItem('fd_sections') || '{}');
return Object.assign(def, saved);
var merged = Object.assign({}, def, saved);
// Normalise : si plusieurs sections ouvertes (ancien état), ne garder que la première.
var first = null;
for (var i = 0; i < order.length; i++) {
if (merged[order[i]]) { first = order[i]; break; }
}
if (first) {
for (var j = 0; j < order.length; j++) merged[order[j]] = (order[j] === first);
} else {
merged.workspace = true;
}
return merged;
} catch(e) { return def; }
})(),
toggleSection(key) {
this.sectionsOpen[key] = !this.sectionsOpen[key];
var isOpen = !!this.sectionsOpen[key];
var order = ['workspace','gitea','meetings','recents','favorites','agents','teamspaces','shared','published','private','apps'];
if (isOpen) {
this.sectionsOpen[key] = false;
} else {
for (var i = 0; i < order.length; i++) this.sectionsOpen[order[i]] = false;
this.sectionsOpen[key] = true;
}
this.sectionsOpen = Object.assign({}, this.sectionsOpen);
try { localStorage.setItem('fd_sections', JSON.stringify(this.sectionsOpen)); }
catch(e) {}
},
// ── Menu "New" (popover "Create new" vers le haut) ──
newMenuOpen: false,
toggleNewMenu() { this.newMenuOpen = !this.newMenuOpen; },
closeNewMenu() { this.newMenuOpen = false; },
newMenuAction(kind) {
this.newMenuOpen = false;
if (kind === 'page') {
if (window.FlowDeck && window.FlowDeck.createPage) window.FlowDeck.createPage();
} else if (kind === 'template') {
if (window.FlowDeck && window.FlowDeck.createPageFromTemplate) window.FlowDeck.createPageFromTemplate();
} else if (kind === 'chat') {
if (typeof this.chatNewConversation === 'function') this.chatNewConversation();
else if (typeof this.agentNew === 'function') this.agentNew();
} else if (kind === 'meeting') {
if (typeof this.fdCreateMeeting === 'function') this.fdCreateMeeting();
else if (typeof this.meetingNote === 'function') this.meetingNote();
} else if (kind === 'database') {
if (window.FlowDeck && window.FlowDeck.createPageFromTemplate) {
window.FlowDeck.createPageFromTemplate(null, 'database');
} else if (typeof this.createDatabase === 'function') {
this.createDatabase();
}
}
},
// ── Sidebar customization ──
customizing: false,
sidebarConfig: {},
@@ -1921,8 +2176,61 @@
});
},
fdCreatePage() { window.FlowDeck.createPage(); },
fdCreateMeeting() {
// v7.1.1 : crée une note de réunion vide avec son bloc
// AI Meeting Notes déjà inséré (même action que +, New, /meet).
var self = this;
var iso = new Date().toISOString().slice(0, 10);
var title = 'Meeting @' + iso;
fetch('/api/local-workspace/items', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify({ name: title, type: 'page' })
})
.then(function(r) { if (!r.ok) throw new Error('page creation (' + r.status + ')'); return r.json(); })
.then(function(page) {
var block = {
id: 'b1_' + Date.now().toString(36), type: 'meeting', content: '',
meeting: {
v: 2, title: title, date: iso, state: 'idle', mode: 'mic_only',
instruction: 'auto', lang: 'fr', consent: null, consentAudio: false,
tab: 'notes', channels: [], segments: [], segSeq: 0, notes: '',
manual: '', interim: '', startedAt: 0, elapsedBase: 0, pausedMs: 0,
steps: [], summaryMd: '', completeness: '', genTitle: '',
titleEdited: false, shareHidden: false, bannerHidden: false,
transcriptId: null, error: '', remoteSilentWarned: false,
menuOpen: false, settingsOpen: false
}
};
// Amorçage du bloc : en cas d'échec la page s'ouvre quand même
// (l'utilisateur ajoute le bloc via /meet).
return fetch('/board/api/pages/' + page.id + '/blocks', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': self.getCsrfToken() },
body: JSON.stringify({ title: title, blocks: [block] })
}).then(function() { return page; }).catch(function() { return page; });
})
.then(function(page) {
if (typeof self.loadMeetingNotes === 'function') self.loadMeetingNotes();
if (typeof self.refreshSidebarTree === 'function') self.refreshSidebarTree();
if (window.fdNavigate) window.fdNavigate('/pages/' + page.id);
else window.location.href = '/pages/' + page.id;
})
.catch(function(err) { self.toast('Meeting note creation failed: ' + err.message, 'error'); });
},
fdCreateFolder() { window.FlowDeck.showCreateFolderModal(); },
fdGwRefresh() { if (window._gwData) window._gwData.refreshTree(); },
fdGwRefresh() { if (window._gwData) window._gwData.refreshTree(); else this.loadGiteaTree(); },
forgeTreeBase() {
// Gitea and GitHub share the same sidebar tree shape; only the API
// prefix differs. Defaults to Gitea for existing cookies/pages.
var f = (this.forge || '').toLowerCase();
if (f === 'github') return '/api/github/projects/' + this.giteaOwner + '/' + this.giteaRepo;
return '/api/gitea/projects/' + this.giteaOwner + '/' + this.giteaRepo;
},
fdGwNewFile() { if (window._gwData && window._gwData.showNewFile !== undefined) { window._gwData.showNewFile = true; } else this.newPageInWorkspace(); },
fdGwNewFolder() { if (window._gwData && window._gwData.createFolderAtRoot) window._gwData.createFolderAtRoot(); else this.newFolderInWorkspace(); },
sidebarSections() {
if (Object.keys(this.sidebarConfig).length) return this.sidebarConfig;
return {
@@ -1944,6 +2252,17 @@
this.sidebarCollapsed = !this.sidebarCollapsed;
},
// Hamburger: mobile opens the drawer (assignment expressions are illegal
// in the Alpine CSP build, hence this method), desktop toggles collapse.
fdHamburgerClick() {
if (window.matchMedia('(max-width: 768px)').matches) {
this.mobileSidebarOpen = true;
this.sidebarCollapsed = false;
} else {
this.toggleSidebar();
}
},
closeSidebar() {
if (this.sidebarPeek) { this.sidebarPeek = false; return; }
this.sidebarCollapsed = true;
@@ -2138,19 +2457,44 @@
.then(data => { window.location.href = `/pages/${data.id}`; })
.catch(err => { this.toast('Failed to create sub-page: ' + err.message, 'error'); });
},
fdRepoTreeHtml(item, depth) {
// Shared Repository row — same DOM contract as _workspace_tree_macro:
// .sidebar-item.ws-tree-item + data-is-folder/depth/name + chevron +
// .page-icon/.page-name. Depth indent matches local (4 + depth*12px).
var isFolder = item.type === 'folder' || item.type === 'dir';
var safe = (item.name || '').replace(/'/g, "\\'");
var pad = 4 + (depth || 0) * 12;
var chev = isFolder
? '<button class="tree-chevron" data-repo-toggle="' + item.path + '" title="Toggle folder">▶</button>'
: '<span style="width:12px;flex-shrink:0;"></span>';
var icon = isFolder ? getSvgIcon('folder', 14) : getSvgIcon('file', 14);
var label = isFolder
? '<span class="page-name tree-folder-link" title="Open folder">' + item.name + '</span>'
: '<span class="page-name">' + item.name + '</span>';
return '<li class="sidebar-item ws-tree-item" data-gitea-path="' + item.path + '"'
+ ' data-gitea-type="' + (isFolder ? 'folder' : 'file') + '"'
+ ' data-gitea-sha="' + (item.sha || '') + '"'
+ ' data-is-folder="' + (isFolder ? 'true' : 'false') + '"'
+ ' data-depth="' + (depth || 0) + '" data-name="' + safe + '"'
+ ' style="padding-left:' + pad + 'px;">'
+ chev + '<span class="page-icon">' + icon + '</span>' + label + '</li>';
},
loadGiteaTree() {
if (!this.giteaWorkspace || !this.giteaOwner || !this.giteaRepo) {
console.log('loadGiteaTree: skipped (giteaWorkspace=' + this.giteaWorkspace + ', owner=' + this.giteaOwner + ', repo=' + this.giteaRepo + ')');
return;
}
// Skip if giteaWorkspace component already loaded the tree (has data-gitea-path items)
var container = document.getElementById('sidebar-workspace-items');
// Fallback loader for the Repository section on pages where the
// forge-workspace bundle is not mounted. MUST target
// #sidebar-gitea-items — never #sidebar-workspace-items (local mirror).
var container = document.getElementById('sidebar-gitea-items');
if (container && container.querySelector('[data-gitea-path]')) {
console.log('loadGiteaTree: skipped (tree already loaded by giteaWorkspace component)');
console.log('loadGiteaTree: skipped (tree already loaded by workspace component)');
return;
}
console.log('loadGiteaTree: fetching /api/gitea/projects/' + this.giteaOwner + '/' + this.giteaRepo + '/tree');
fetch('/api/gitea/projects/' + this.giteaOwner + '/' + this.giteaRepo + '/tree')
var base = this.forgeTreeBase();
console.log('loadGiteaTree: fetching ' + base + '/tree');
fetch(base + '/tree')
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status + ': ' + r.statusText);
return r.json();
@@ -2158,7 +2502,7 @@
.then(function(data) {
this.giteaTree = data.tree || [];
if (!container) {
console.log('loadGiteaTree: #sidebar-workspace-items not found');
console.log('loadGiteaTree: #sidebar-gitea-items not found');
return;
}
container.innerHTML = '';
@@ -2166,13 +2510,13 @@
container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">'+getSvgIcon('file',16)+'</span><span class="page-name text-dim">No files yet</span></li>';
return;
}
this.giteaTree.forEach(function(item) {
var li = document.createElement('li');
li.className = 'sidebar-item';
var icon = item.type === 'dir' ? '<span class="page-icon-svg">'+getSvgIcon('folder',14)+'</span>' : '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
li.innerHTML = '<span class="page-icon">' + icon + '</span><span class="page-name">' + item.name + '</span>';
container.appendChild(li);
var self = this;
var html = '';
self.giteaTree.forEach(function(item) {
var norm = { name: item.name, path: item.path, sha: item.sha, type: (item.type === 'dir' ? 'folder' : item.type) };
html += self.fdRepoTreeHtml(norm, 0);
});
container.innerHTML = html;
console.log('loadGiteaTree: rendered ' + this.giteaTree.length + ' items');
}.bind(this))
.catch(function(err) {
@@ -2269,10 +2613,18 @@
.then(html => {
const ul = document.getElementById('sidebar-workspace-items');
if (!ul) return;
// Preserve the "Manage Workspaces" link (last <li>)
const manageLink = ul.querySelector('li:last-child');
// Preserve the "Manage Workspaces" link (direct last <li> child only —
// plain 'li:last-child' grabs the last file of a nested folder sublist
// when the tree has folders, splicing it back in → duplicated entry)
const manageLink = ul.querySelector(':scope > li:last-child');
ul.innerHTML = html;
if (manageLink) ul.appendChild(manageLink);
// Le HTML injecté porte des directives Alpine (@contextmenu,
// x-show des dossiers, drag&drop) : les monter explicitement,
// sinon l'arbre perd menu contextuel et toggles après refresh.
try {
if (window.Alpine && window.Alpine.initTree) window.Alpine.initTree(ul);
} catch (e) { console.error('Sidebar Alpine remount failed:', e); }
})
.catch(e => console.error('Sidebar refresh failed:', e));
},
@@ -2503,6 +2855,7 @@
if (data.contextMenu) data.contextMenu.visible = false;
if (data.wsCtx) data.wsCtx.visible = false;
if (data.showNewPageMenu) data.showNewPageMenu = false;
if (data.newMenuOpen) data.newMenuOpen = false;
}
}
else if (e.ctrlKey && e.key === 's') {
+368
View File
@@ -0,0 +1,368 @@
{% extends "base.html" %}
{% block page_icon %}{{ fd_icon("file",18) }}{% endblock %}
{% block page_title %}Templates{% endblock %}
{% block title_prefix %}Templates{% endblock %}
{% block content %}
<div class="page-title-area">
<div class="page-title">
<span class="page-icon-lg">{{ fd_icon("file",24) }}</span>
<h1>Templates</h1>
</div>
<p style="color:var(--text-dim);font-size:13px;margin:4px 0 0;">
Modèles réutilisables : pages, lignes de base, bases entières, blocs.
<span x-show="loading">Chargement…</span>
</p>
</div>
<div x-data="fdTemplatesManager()" x-init="init()" class="lib-container">
<!-- Toolbar -->
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap;">
<div style="position:relative;flex:1;min-width:180px;">
<span style="position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--text-dim);">{{ fd_icon("search",14) }}</span>
<input type="text" placeholder="Rechercher un template…" x-model="search" @input.debounce.300ms="reload()"
style="width:100%;padding:8px 12px 8px 36px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);font-size:14px;outline:none;box-sizing:border-box;">
</div>
<select x-model="kindFilter" @change="reload()" style="padding:8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);">
<option value="">Tous genres</option>
<option value="page">Pages</option>
<option value="row">Lignes</option>
<option value="database">Bases</option>
<option value="blocks">Blocs</option>
</select>
<select x-model="scopeFilter" @change="reload()" style="padding:8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);">
<option value="">Toutes portées</option>
<option value="personal">Personnel</option>
<option value="workspace">Workspace</option>
<option value="teamspace">Teamspace</option>
<option value="system">Système</option>
</select>
<label style="display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text-dim);">
<input type="checkbox" x-model="showArchived" @change="reload()" style="accent-color:var(--accent);"> Archivés
</label>
<button class="btn-sm" @click="createOpen = !createOpen" style="padding:8px 14px;">+ Nouveau ▾</button>
</div>
<!-- Panneau création -->
<div x-show="createOpen" @click.outside="createOpen = false" x-transition
style="background:var(--bg-secondary,#232323);border:1px solid var(--border);border-radius:10px;padding:14px 16px;margin-bottom:12px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;max-width:640px;">
<input x-model="form.name" placeholder="Nom du template"
style="padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);">
<select x-model="form.kind" style="padding:8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);">
<option value="page">Template de page</option>
<option value="row">Template de ligne</option>
<option value="database">Template de base</option>
<option value="blocks">Template de blocs</option>
</select>
<input x-model="form.target_collection_id" x-show="form.kind === 'row'" placeholder="ID de la base cible"
style="padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);">
<input x-model="form.from_page_id" placeholder="Depuis la page n°… (optionnel)"
style="padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);">
</div>
<div x-show="formError" x-text="formError" style="color:#e5484d;font-size:13px;margin-top:8px;"></div>
<div style="display:flex;gap:8px;margin-top:10px;">
<button class="btn-sm" @click="create()" style="padding:8px 16px;">Créer</button>
<button class="btn-sm" @click="createOpen = false" style="padding:8px 16px;">Annuler</button>
<label style="margin-left:auto;font-size:13px;color:var(--text-dim);cursor:pointer;">
Importer un .json <input type="file" accept=".json,application/json" @change="importFile($event)" style="display:none;">
</label>
</div>
</div>
<!-- Erreur globale -->
<div x-show="error" style="background:rgba(229,72,77,.12);border:1px solid #e5484d;border-radius:8px;padding:10px 14px;margin-bottom:12px;font-size:13px;">
<span x-text="error"></span>
<button @click="reload()" style="margin-left:8px;">Réessayer</button>
</div>
<!-- Liste -->
<div x-show="!loading && !items.length" style="color:var(--text-dim);font-size:13px;padding:24px 0;text-align:center;">
Aucun template. Créez-en un avec « + Nouveau », ou dupliquez un preset système.
</div>
<div style="display:flex;flex-direction:column;gap:8px;">
<template x-for="t in items" :key="t.id">
<div @click="select(t)" :class="{selected: selected && selected.id === t.id}"
style="display:flex;align-items:center;gap:12px;padding:10px 14px;border:1px solid var(--border);border-radius:10px;cursor:pointer;background:var(--bg-secondary,#232323);">
<span style="font-size:20px;" x-text="t.icon || '📄'"></span>
<div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:14px;color:var(--text,#fff);display:flex;align-items:center;gap:8px;">
<span x-text="t.name"></span>
<span x-show="t.is_system" style="font-size:11px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;padding:1px 6px;color:var(--text-dim);">système</span>
<span x-show="t.is_default" style="font-size:11px;color:#f5a524;">★ défaut</span>
<span x-show="t.recurrence && t.recurrence.enabled" style="font-size:11px;color:var(--text-dim);">↻ <span x-text="t.recurrence.rrule"></span></span>
</div>
<div style="font-size:12px;color:var(--text-dim);" x-text="(kindLabel(t.kind) + ' · ' + t.scope + (t.usage_count ? ' · ' + t.usage_count + ' utilisations' : ''))"></div>
</div>
<button class="btn-sm" @click.stop="useTemplate(t)" style="padding:6px 12px;">Utiliser</button>
</div>
</template>
</div>
<!-- Side peek : détail + réglages -->
<div x-show="selected" x-transition
style="position:fixed;top:0;right:0;bottom:0;width:min(480px,94vw);background:var(--bg-secondary,#232323);border-left:1px solid var(--border);z-index:1200;padding:18px;overflow-y:auto;box-shadow:-16px 0 50px rgba(0,0,0,.5);">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<span style="font-size:24px;" x-text="selected.icon"></span>
<input x-model="selected.name" @change="saveMeta()" :disabled="selected.is_system"
style="flex:1;font-size:17px;font-weight:700;background:transparent;border:none;color:var(--text,#fff);outline:none;">
<button @click="selected = null" style="background:none;border:none;color:var(--text-dim);font-size:18px;cursor:pointer;">✕</button>
</div>
<div style="font-size:12px;color:var(--text-dim);margin-bottom:10px;" x-text="(kindLabel(selected.kind) + ' · portée ' + selected.scope)"></div>
<textarea x-model="selected.description" @change="saveMeta()" :disabled="selected.is_system" rows="2" placeholder="Description…"
style="width:100%;padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);font-size:13px;box-sizing:border-box;"></textarea>
<div x-show="detailError" x-text="detailError" style="color:#e5484d;font-size:13px;margin-top:8px;"></div>
<h4 style="margin:16px 0 6px;font-size:13px;">Aperçu</h4>
<div style="background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;padding:10px 12px;font-size:13px;">
<div style="font-weight:600;margin-bottom:6px;" x-text="detail.title || selected.name"></div>
<template x-for="b in (detail.blocks_preview || []).slice(0,6)" :key="b.id">
<div style="color:var(--text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" x-text="('• ' + (b.content || ('[' + (b.type||'?') + ']')))"></div>
</template>
<div x-show="!(detail.blocks_preview || []).length" style="color:var(--text-dim);">Aucun contenu.</div>
<div x-show="(detail.warnings || []).length" style="color:#f5a524;font-size:12px;margin-top:6px;" x-text="detail.warnings.join(' · ')"></div>
</div>
<h4 style="margin:16px 0 6px;font-size:13px;">Variables déclarées (JSON)</h4>
<textarea x-model="variablesText" rows="4" :disabled="selected.is_system"
style="width:100%;font-family:monospace;font-size:12px;padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);box-sizing:border-box;"></textarea>
<div style="display:flex;gap:8px;margin-top:6px;">
<button class="btn-sm" @click="saveVariables()" x-show="!selected.is_system">Enregistrer</button>
</div>
<div x-show="selected.kind === 'row'">
<h4 style="margin:16px 0 6px;font-size:13px;">Propriétés par défaut (JSON, par nom)</h4>
<textarea x-model="defaultsText" rows="4" :disabled="selected.is_system"
style="width:100%;font-family:monospace;font-size:12px;padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);box-sizing:border-box;"></textarea>
<div style="display:flex;gap:8px;margin-top:6px;flex-wrap:wrap;">
<button class="btn-sm" @click="saveDefaults()" x-show="!selected.is_system">Enregistrer</button>
<button class="btn-sm" @click="toggleDefault()" x-show="!selected.is_system" x-text="selected.is_default ? 'Retirer le défaut' : 'Définir comme défaut'"></button>
</div>
<h4 style="margin:16px 0 6px;font-size:13px;">Récurrence (RRULE)</h4>
<div style="display:flex;gap:8px;">
<input x-model="rrule" placeholder="FREQ=WEEKLY;BYDAY=MO" :disabled="selected.is_system"
style="flex:1;padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);font-size:13px;">
<button class="btn-sm" @click="saveRecurrence()" x-show="!selected.is_system">OK</button>
<button class="btn-sm" @click="clearRecurrence()" x-show="!selected.is_system && selected.recurrence">Arrêter</button>
</div>
<div x-show="selected.recurrence" style="font-size:12px;color:var(--text-dim);margin-top:4px;"
x-text="('Prochain passage : ' + (selected.recurrence.next_run_at || '—'))"></div>
<h4 style="margin:16px 0 6px;font-size:13px;">Journal récent</h4>
<div style="font-size:12px;color:var(--text-dim);">
<template x-for="r in runs" :key="r.id">
<div x-text="(r.created_at + ' · ' + r.run_source + ' · ' + r.status + (r.created_id ? ' → ' + r.created_id : ''))"></div>
</template>
<div x-show="!runs.length">Aucune instanciation journalisée.</div>
</div>
</div>
<h4 style="margin:16px 0 6px;font-size:13px;">Actions</h4>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="btn-sm" @click="useTemplate(selected)">Utiliser</button>
<button class="btn-sm" @click="duplicate(selected)" x-text="selected.is_system ? 'Dupliquer pour modifier' : 'Dupliquer'"></button>
<button class="btn-sm" @click="exportJson(selected)">Exporter</button>
<button class="btn-sm" @click="archive(selected)" x-show="!selected.is_system" x-text="selected.archived_at ? 'Restaurer' : 'Archiver'"></button>
<button class="btn-sm btn-sm-danger" @click="remove(selected)" x-show="!selected.is_system">Supprimer</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
if (window.Alpine) { Alpine.data('fdTemplatesManager', fdTemplatesManager); }
else document.addEventListener('alpine:init', function () { Alpine.data('fdTemplatesManager', fdTemplatesManager); });
function fdTemplatesManager() {
return {
items: [], selected: null, detail: { blocks_preview: [], warnings: [] },
runs: [], loading: true, error: '', detailError: '',
search: '', kindFilter: '', scopeFilter: '', showArchived: false,
createOpen: false, form: { name: '', kind: 'page', target_collection_id: '', from_page_id: '' },
formError: '', variablesText: '[]', defaultsText: '{}', rrule: '',
csrf() { return (window.getCsrf && window.getCsrf()) || ''; },
headers(json) {
const h = { 'X-CSRF-Token': this.csrf() };
if (json) h['Content-Type'] = 'application/json';
return h;
},
kindLabel(k) { return { page: 'Page', row: 'Ligne', database: 'Base', blocks: 'Blocs' }[k] || k; },
async init() { await this.reload(); },
async reload() {
this.loading = true; this.error = '';
try {
const p = new URLSearchParams();
if (this.kindFilter) p.set('kind', this.kindFilter);
if (this.scopeFilter) p.set('scope', this.scopeFilter);
if (this.search) p.set('q', this.search);
if (this.showArchived) p.set('include_archived', 'true');
const r = await fetch('/api/templates?' + p.toString(), { credentials: 'same-origin' });
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
this.items = d.templates || [];
} catch (e) { this.error = 'Chargement impossible : ' + e.message; this.items = []; }
this.loading = false;
},
async select(t) {
this.selected = t; this.detailError = ''; this.runs = [];
this.variablesText = JSON.stringify(t.variables || [], null, 1);
this.defaultsText = JSON.stringify((t.manifest || {}).property_defaults || {}, null, 1);
this.rrule = (t.recurrence && t.recurrence.rrule) || '';
try {
const r = await fetch('/api/templates/' + encodeURIComponent(t.id), { credentials: 'same-origin' });
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
this.detail = { title: d.name, blocks_preview: d.blocks_preview || [], warnings: [] };
const pv = await fetch('/api/templates/' + encodeURIComponent(t.id) + '/preview',
{ method: 'POST', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify({}) });
if (pv.ok) { const pd = await pv.json(); this.detail = { title: pd.title, blocks_preview: pd.blocks_preview || [], warnings: pd.warnings || [] }; }
if (t.kind === 'row') {
const rr = await fetch('/api/templates/' + encodeURIComponent(t.id) + '/runs', { credentials: 'same-origin' });
if (rr.ok) this.runs = (await rr.json()).runs || [];
}
} catch (e) { this.detailError = 'Aperçu impossible : ' + e.message; }
},
async create() {
this.formError = '';
if (!this.form.name.trim()) { this.formError = 'Nom requis.'; return; }
if (this.form.kind === 'row' && !this.form.target_collection_id && !this.form.from_page_id) {
this.formError = 'Un template de ligne exige une base cible (ID).'; return;
}
const body = { name: this.form.name.trim(), kind: this.form.kind, scope: 'personal' };
if (this.form.target_collection_id) body.target_collection_id = parseInt(this.form.target_collection_id, 10);
if (this.form.from_page_id) body.from_page_id = parseInt(this.form.from_page_id, 10);
try {
const r = await fetch('/api/templates', { method: 'POST', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify(body) });
if (!r.ok) { const t = await r.text(); throw new Error('HTTP ' + r.status + ' ' + t.slice(0, 200)); }
this.createOpen = false; this.form = { name: '', kind: 'page', target_collection_id: '', from_page_id: '' };
await this.reload();
window.showToast && window.showToast('Template créé');
} catch (e) { this.formError = 'Création impossible : ' + e.message; }
},
async saveMeta() {
this.detailError = '';
try {
const r = await fetch('/api/templates/' + encodeURIComponent(this.selected.id),
{ method: 'PATCH', headers: this.headers(true), credentials: 'same-origin',
body: JSON.stringify({ name: this.selected.name, description: this.selected.description }) });
if (!r.ok) throw new Error('HTTP ' + r.status);
Object.assign(this.selected, await r.json());
const i = this.items.findIndex(x => x.id === this.selected.id);
if (i >= 0) this.items[i] = this.selected;
} catch (e) { this.detailError = 'Enregistrement impossible : ' + e.message; }
},
async saveVariables() {
this.detailError = '';
try {
const v = JSON.parse(this.variablesText);
const r = await fetch('/api/templates/' + encodeURIComponent(this.selected.id) + '/variables',
{ method: 'PUT', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify({ variables: v }) });
if (!r.ok) throw new Error('HTTP ' + r.status);
Object.assign(this.selected, await r.json());
} catch (e) { this.detailError = 'Variables invalides ou enregistrement impossible : ' + e.message; }
},
async saveDefaults() {
this.detailError = '';
try {
const v = JSON.parse(this.defaultsText);
const r = await fetch('/api/templates/' + encodeURIComponent(this.selected.id) + '/property-defaults',
{ method: 'PUT', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify(v) });
if (!r.ok) throw new Error('HTTP ' + r.status);
Object.assign(this.selected, await r.json());
} catch (e) { this.detailError = 'JSON invalide ou enregistrement impossible : ' + e.message; }
},
async toggleDefault() {
const url = '/api/templates/' + encodeURIComponent(this.selected.id) +
(this.selected.is_default ? '/unset-default' : '/set-default');
const r = await fetch(url, { method: 'POST', headers: this.headers(), credentials: 'same-origin' });
if (r.ok) { Object.assign(this.selected, await r.json()); this.reload(); }
else this.detailError = 'Défaut : HTTP ' + r.status;
},
async saveRecurrence() {
const r = await fetch('/api/templates/' + encodeURIComponent(this.selected.id) + '/recurrence',
{ method: 'PUT', headers: this.headers(true), credentials: 'same-origin',
body: JSON.stringify({ rrule: this.rrule }) });
if (r.ok) { const d = await r.json(); this.selected.recurrence = d.recurrence; }
else this.detailError = 'Récurrence : HTTP ' + r.status;
},
async clearRecurrence() {
const r = await fetch('/api/templates/' + encodeURIComponent(this.selected.id) + '/recurrence',
{ method: 'PUT', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify({}) });
if (r.ok) { this.selected.recurrence = null; this.rrule = ''; }
},
async useTemplate(t) {
try {
const r = await fetch('/api/templates/' + encodeURIComponent(t.id) + '/instantiate',
{ method: 'POST', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify({}) });
if (!r.ok) { const txt = await r.text(); throw new Error('HTTP ' + r.status + ' ' + txt.slice(0, 300)); }
const d = await r.json();
(d.warnings || []).forEach(w => window.showToast && window.showToast(w, 'error'));
window.showToast && window.showToast('Créé depuis « ' + t.name + ' »');
if (d.url) { window.fdNavigate ? window.fdNavigate(d.url) : (window.location = d.url); }
else this.reload();
} catch (e) { this.detailError = 'Application impossible : ' + e.message; window.showToast && window.showToast('Échec : ' + e.message, 'error'); }
},
async duplicate(t) {
const r = await fetch('/api/templates/' + encodeURIComponent(t.id) + '/duplicate',
{ method: 'POST', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify({}) });
if (r.ok) { await this.reload(); window.showToast && window.showToast('Template dupliqué'); }
else this.detailError = 'Duplication : HTTP ' + r.status;
},
async archive(t) {
const action = t.archived_at ? 'restore' : 'archive';
const r = await fetch('/api/templates/' + encodeURIComponent(t.id) + '/' + action,
{ method: 'POST', headers: this.headers(), credentials: 'same-origin' });
if (r.ok) { Object.assign(t, await r.json()); this.reload(); }
},
async remove(t) {
if (!confirm('Supprimer « ' + t.name + ' » ? (corbeille)')) return;
const r = await fetch('/api/templates/' + encodeURIComponent(t.id),
{ method: 'DELETE', headers: this.headers(), credentials: 'same-origin' });
if (r.ok) { this.selected = null; this.reload(); }
else this.detailError = 'Suppression : HTTP ' + r.status;
},
async exportJson(t) {
const r = await fetch('/api/templates/' + encodeURIComponent(t.id), { credentials: 'same-origin' });
if (!r.ok) return;
const d = await r.json();
const manifest = {
format: 'flowdeck-template', version: 1, kind: d.kind, name: d.name,
description: d.description, icon: d.icon, category: d.category,
variables: d.variables, manifest: d.manifest, content: { blocks: d.blocks_preview || [] },
};
const blob = new Blob([JSON.stringify(manifest, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = 'template-' + d.name + '.json'; a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
},
async importFile(ev) {
const f = ev.target.files && ev.target.files[0];
if (!f) return;
try {
const text = await f.text();
const m = JSON.parse(text);
if (m.format !== 'flowdeck-template') throw new Error('format inconnu');
const body = {
kind: m.kind, name: m.name, description: m.description || '', icon: m.icon || '📄',
category: m.category || '', scope: 'personal', blocks: ((m.content || {}).blocks) || [],
manifest: m.manifest || {}, variables: m.variables || [],
target_collection_id: ((m.target || {}).collection_id) || undefined,
};
const r = await fetch('/api/templates', { method: 'POST', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify(body) });
if (!r.ok) throw new Error('HTTP ' + r.status);
this.createOpen = false; await this.reload();
window.showToast && window.showToast('Template importé : ' + m.name);
} catch (e) { this.formError = 'Import impossible : ' + e.message; }
ev.target.value = '';
},
};
}
</script>
{% endblock %}
+218 -53
View File
@@ -4,39 +4,96 @@
{% block page_icon %}{{ fd_icon("link",18) }}{% endblock %}
{% block topbar %}
{% set breadcrumb_items = [{"label": owner ~ "/" ~ repo, "url": None}] %}
{% set page_icon = "link" %}
{% set right_actions %}<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">{{ fd_icon("file",14) }}+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">{{ fd_icon("upload",14) }}</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">{{ fd_icon("refresh",14) }}</button><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
{% set page_title = workspace_name %}
{% include '_header.html' %}
{% endblock %}
{% block content %}
<!-- Prism.js syntax highlighting -->
<link rel="stylesheet" href="/static/css/prism.css">
<script src="/static/js/prism.min.js"></script>
<!-- Shared forge layout — identical structure to /local-workspace:
ws-split > ws-main, breadcrumb-row + Library-style toolbar
(View/Sort/Filter/Search, New file), search bar, filter chips,
then tree / list / details / cards / content views bound to the
forge tree (filteredTreeItems). File view + editor + commit bar
preserved below. -->
<link rel="stylesheet" href="/static/css/highlight.css?v={{ asset_version }}">
<script data-cfasync="false" src="/static/js/highlight.min.js?v={{ asset_version }}"></script>
<script data-cfasync="false" src="/static/js/highlight-extra.min.js?v={{ asset_version }}"></script>
<script data-cfasync="false" src="/static/js/code-highlight.js?v={{ asset_version }}"></script>
<style>
.gw-main{display:flex;flex-direction:column;height:calc(100vh - 48px);overflow:hidden;}
.gw-content{padding:24px;overflow-y:auto;flex:1;}
.gw-content h3{font-size:16px;margin-bottom:12px;color:var(--text-dim);}
.gw-content pre{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;padding:16px;overflow-x:auto;font-size:13px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:70vh;overflow-y:auto;}
.gw-content pre[class*="language-"]{background:transparent;border:none;padding:0;margin:0;white-space:pre;word-break:normal;}
.gw-content code[class*="language-"]{font-size:13px;line-height:1.5;tab-size:4;}
.gw-content .empty-state{text-align:center;padding:60px 20px;color:var(--text-dim);}
.gw-content .empty-state h2{font-size:18px;margin-bottom:8px;color:var(--text);}
.gw-content textarea{width:100%;min-height:50vh;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;padding:12px;font-family:monospace;resize:vertical;outline:none;}
.gw-content textarea:focus{border-color:var(--accent);}
.ws-split{display:flex;max-width:100%;margin:0 auto;padding:0 16px 0;gap:0;min-height:calc(100vh - var(--topbar-height));}
.ws-main{flex:1;min-width:0;overflow:auto;}
.breadcrumb-row{display:flex;align-items:center;gap:4px;padding:2px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px 8px 0 0;margin-bottom:0;flex-wrap:wrap;}
.breadcrumb-row .nav-arrow{width:22px;height:22px;padding:0;font-size:13px;}
.toolbar-row{display:flex;align-items:center;gap:4px;padding:2px 8px;background:var(--bg-secondary);border:1px solid var(--border);border-top:none;border-radius:0 0 8px 8px;margin-bottom:2px;flex-wrap:wrap;}
.nav-arrow{width:28px;height:28px;font-size:13px;flex-shrink:0;}
.unified-breadcrumb{flex:1;display:flex;align-items:center;gap:2px;min-width:0;font-size:13px;overflow:hidden;}
.unified-breadcrumb .bc-root{color:var(--text-secondary);text-decoration:none;white-space:nowrap;font-weight:500;flex-shrink:0;}
.unified-breadcrumb .bc-root:hover{color:var(--accent);}
.unified-breadcrumb .bc-sep{color:var(--text-tertiary);margin:0 2px;flex-shrink:0;}
.unified-breadcrumb a{color:var(--text-secondary);text-decoration:none;white-space:nowrap;}
.unified-breadcrumb a:hover{color:var(--accent);}
.ws-toolbar{display:flex;align-items:center;gap:2px;flex-shrink:0;margin-left:auto;}
.ws-icon-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;background:transparent;border:none;color:var(--text-secondary);cursor:pointer;}
.ws-icon-btn:hover,.ws-icon-btn.active{background:var(--bg-hover);color:var(--text-primary);}
.ws-icon-btn svg{width:16px;height:16px;}
.ws-dropdown{position:absolute;top:calc(100% + 4px);right:0;min-width:210px;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:var(--shadow-popover);z-index:200;padding:6px;}
.ws-dropdown .dd-label{padding:6px 8px 2px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.3px;}
.ws-dropdown .dd-item{padding:6px 8px;font-size:13px;color:var(--text-primary);cursor:pointer;border-radius:4px;display:flex;align-items:center;gap:8px;}
.ws-dropdown .dd-item:hover{background:var(--bg-hover);}
.ws-dropdown .dd-item.active{color:var(--accent);}
.ws-dropdown .dd-item .check{width:14px;flex-shrink:0;color:var(--accent);}
.lib-toolbar-wrap{position:relative;}
.ws-search-bar{margin-bottom:10px;}
.ws-search-bar input{width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);color:var(--text-primary);font-size:13px;outline:none;}
.ws-search-bar input:focus{border-color:var(--accent);}
.filter-row{display:flex;align-items:center;gap:4px;padding:2px 0;margin-bottom:4px;flex-wrap:wrap;}
.filter-chip{padding:4px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;font-size:12px;color:var(--text-secondary);cursor:pointer;}
.filter-chip:hover{background:var(--bg-hover);color:var(--text);}
.filter-chip.active{background:var(--accent);color:#fff;border-color:var(--accent);}
.filter-clear{font-size:11px;color:var(--text-tertiary);cursor:pointer;padding:2px 6px;border-radius:4px;}
.filter-clear:hover{color:var(--danger);background:var(--bg-hover);}
.ws-tree{list-style:none;margin:0;padding:0;}
.ws-tree-item{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:6px;cursor:pointer;transition:background 150ms;border:1px solid transparent;margin-bottom:2px;}
.ws-tree-item:hover{background:var(--bg-tertiary);border-color:var(--border);}
.ws-tree-item .name{flex:1;font-size:14px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.ws-tree-item .name.folder{font-weight:500;}
.tree-chevron{width:16px;height:16px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:8px;padding:0;border-radius:3px;}
.tree-chevron:hover{color:var(--text);background:var(--bg-hover);}
.tree-chevron.open{transform:rotate(90deg);}
.ws-table-compact,.ws-table-enhanced{width:100%;border-collapse:collapse;font-size:13px;}
.ws-table-compact th,.ws-table-enhanced th{text-align:left;padding:6px 10px;color:var(--text-tertiary);font-weight:500;border-bottom:2px solid var(--border);font-size:11px;text-transform:uppercase;}
.ws-table-compact td,.ws-table-enhanced td{padding:5px 10px;border-bottom:1px solid var(--border);}
.ws-table-compact tr:hover td,.ws-table-enhanced tr:hover td{background:var(--bg-hover);}
.title-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:8px;margin-top:8px;}
.title-card{display:flex;flex-direction:column;align-items:center;padding:16px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:10px;cursor:pointer;min-height:100px;justify-content:center;}
.title-card:hover{background:var(--bg-hover);border-color:var(--accent);}
.title-card-name{font-size:13px;font-weight:500;text-align:center;word-break:break-word;}
.title-card-meta{display:flex;gap:8px;margin-top:6px;font-size:11px;color:var(--text-tertiary);}
.content-list{display:flex;flex-direction:column;gap:4px;margin-top:8px;}
.content-item{padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;cursor:pointer;}
.content-item:hover{background:var(--bg-hover);border-color:var(--accent);}
.empty-state{text-align:center;padding:80px 20px;color:var(--text-dim);}
.empty-state h2{font-size:18px;margin-bottom:8px;color:var(--text);}
.gw-file-toolbar{display:flex;align-items:center;gap:8px;padding:8px 24px;background:var(--bg-secondary);border-bottom:1px solid var(--border);font-size:13px;color:var(--text-dim);}
.gw-file-toolbar strong{color:var(--text);margin-right:12px;}
.gw-content{padding:24px;overflow-y:auto;flex:1;}
.gw-content pre{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;padding:16px;overflow-x:auto;font-size:13px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:70vh;overflow-y:auto;}
.gw-content textarea{width:100%;min-height:50vh;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;padding:12px;font-family:monospace;resize:vertical;outline:none;}
.gw-commit-bar{display:flex;align-items:center;gap:8px;padding:12px 24px;background:var(--bg-secondary);border-top:1px solid var(--border);flex-wrap:wrap;}
.gw-commit-bar input{flex:1;min-width:180px;padding:8px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;outline:none;}
.gw-commit-bar input:focus{border-color:var(--accent);}
/* Prism theme tweaks for dark UI */
.gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;}
.ctx-item{display:flex;align-items:center;gap:8px;padding:7px 14px;font-size:13px;color:var(--text);cursor:pointer;}
.ctx-item:hover{background:var(--bg-hover);}
.ctx-item.ctx-danger{color:var(--danger);}
.btn{padding:10px 20px;border-radius:8px;font-size:13px;cursor:pointer;border:none;font-weight:500;}
.btn-primary{background:var(--accent);color:#fff;}
.btn-danger{background:rgba(255,80,80,.1);color:var(--danger);}
</style>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/gitea_workspace.js?v={{ asset_version }}"></script>
<div class="gw-main" x-data="giteaWorkspace()">
<div class="ws-split" x-data="giteaWorkspace()">
<div class="ws-main">
<!-- File view -->
<div x-show="showFile && !showEditor" x-transition>
<div class="gw-file-toolbar">
@@ -47,22 +104,10 @@
<button class="btn btn-danger" @click="deleteCurrentFile()">{{ fd_icon("trash",14) }} Delete</button>
</div>
<div class="gw-content">
<!-- Skeleton loading -->
<div x-show="fileLoading" class="skeleton-file">
<div class="skeleton" style="height:15px;width:60%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:80%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:40%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:75%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:55%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:90%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:35%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:65%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:45%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:70%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:50%;"></div>
<div class="gw-code-head" x-show="!fileLoading" style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<span class="gw-code-lang" x-text="fileLanguageLabel" style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-tertiary);"></span>
</div>
<!-- Syntax-highlighted code -->
<pre x-show="!fileLoading"><code :class="'language-'+fileLanguage" x-ref="codeBlock" x-text="fileContent"></code></pre>
<pre x-show="!fileLoading"><code class="hljs" :class="'language-'+fileLanguage" :data-hljs-lang="fileLanguage" x-ref="codeBlock" x-text="fileContent"></code></pre>
</div>
</div>
@@ -89,24 +134,89 @@
</div>
</template>
<!-- File tree browser (like local-workspace) -->
<!-- Browser (same chrome as /local-workspace) -->
<template x-if="!showFile && !showEditor && !showPrivate">
<div class="gw-content">
<!-- Breadcrumb -->
<div class="gw-breadcrumb" style="display:flex;align-items:center;gap:4px;margin-bottom:16px;font-size:14px;color:var(--text-secondary);">
<a href="#" @click.prevent="navigateToRoot()" style="color:var(--text-secondary);text-decoration:none;" x-text="owner + '/' + repo"></a>
<template x-for="(b, i) in folderStack" :key="i">
<span style="display:contents;">
<span style="color:var(--text-tertiary);">/</span>
<a href="#" @click.prevent="navigateToFolder(i)" style="color:var(--text-secondary);text-decoration:none;" x-text="b"></a>
</span>
</template>
<span x-show="treeLoading" style="color:var(--text-tertiary);">Loading...</span>
<div>
<div class="breadcrumb-row">
<a href="/workspaces" class="btn-icon nav-arrow" title="All workspaces">{{ fd_icon("home",14) }}</a>
<div class="unified-breadcrumb">
<a href="#" @click.prevent="navigateToRoot()" class="bc-root" style="cursor:pointer;" x-text="owner + '/' + repo"></a>
<template x-for="(b, i) in folderStack" :key="i">
<span style="display:contents;">
<span class="bc-sep">/</span>
<a href="#" @click.prevent="navigateToFolder(i)" x-text="b"></a>
</span>
</template>
<span x-show="treeLoading" style="color:var(--text-tertiary);">Loading...</span>
</div>
<div class="ws-toolbar">
<div class="lib-toolbar-wrap" style="position:relative;">
<button class="ws-icon-btn" :class="{active: viewMenuOpen}" title="View" @click.stop="toggleViewMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>
</button>
<div class="ws-dropdown" x-show="viewMenuOpen" @click.outside="viewMenuOpen=false" x-transition style="right:0;">
<div class="dd-label">View</div>
<div class="dd-item" :class="{active: viewMode==='tree'}" @click="viewMode='tree', viewMenuOpen=false"><span class="check" x-text="viewMode==='tree'?'✓':''"></span>{{ fd_icon("folder",14) }} Tree</div>
<div class="dd-item" :class="{active: viewMode==='list'}" @click="viewMode='list', viewMenuOpen=false"><span class="check" x-text="viewMode==='list'?'✓':''"></span>{{ fd_icon("list",14) }} List</div>
<div class="dd-item" :class="{active: viewMode==='details'}" @click="viewMode='details', viewMenuOpen=false"><span class="check" x-text="viewMode==='details'?'✓':''"></span>{{ fd_icon("bar-chart",14) }} Details</div>
<div class="dd-item" :class="{active: viewMode==='title'}" @click="viewMode='title', viewMenuOpen=false"><span class="check" x-text="viewMode==='title'?'✓':''"></span>{{ fd_icon("grid",14) }} Cards</div>
<div class="dd-item" :class="{active: viewMode==='content'}" @click="viewMode='content', viewMenuOpen=false"><span class="check" x-text="viewMode==='content'?'✓':''"></span>{{ fd_icon("align-left",14) }} Content</div>
</div>
</div>
<div class="lib-toolbar-wrap" style="position:relative;">
<button class="ws-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSortMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="14" y2="9"/><line x1="4" y1="15" x2="10" y2="15"/></svg>
</button>
<div class="ws-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition style="right:0;">
<div class="dd-label">Sort by</div>
<div class="dd-item" :class="{active: sortBy===''}" @click="sortBy='', doSort(), sortOpen=false"><span class="check" x-text="sortBy===''?'✓':''"></span> Name A→Z</div>
<div class="dd-item" :class="{active: sortBy==='name-desc'}" @click="sortBy='name-desc', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='name-desc'?'✓':''"></span> Name Z→A</div>
<div class="dd-item" :class="{active: sortBy==='type'}" @click="sortBy='type', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='type'?'✓':''"></span> Type</div>
</div>
</div>
<div class="lib-toolbar-wrap" style="position:relative;">
<button class="ws-icon-btn" :class="{active: filterOpen || filterType}" title="Filter" @click.stop="toggleFilterMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
</button>
<div class="ws-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition style="right:0;">
<div class="dd-label">File type</div>
<div class="dd-item" :class="{active: filterType==='folder'}" @click="filterByType('folder'), filterOpen=false"><span class="check" x-text="filterType==='folder'?'✓':''"></span>{{ fd_icon("folder",14) }} Folders</div>
<div class="dd-item" :class="{active: filterType==='page'}" @click="filterByType('page'), filterOpen=false"><span class="check" x-text="filterType==='page'?'✓':''"></span>{{ fd_icon("file",14) }} Files</div>
<div class="dd-item" :class="{active: filterType==='image'}" @click="filterByType('image'), filterOpen=false"><span class="check" x-text="filterType==='image'?'✓':''"></span>{{ fd_icon("image",14) }} Images</div>
<div class="dd-item" :class="{active: filterType==='code'}" @click="filterByType('code'), filterOpen=false"><span class="check" x-text="filterType==='code'?'✓':''"></span>{{ fd_icon("file",14) }} Code</div>
<div class="more-sep" x-show="filterType"></div>
<div class="dd-item" x-show="filterType" @click="filterType='', doFilter(), filterOpen=false" style="color:var(--text-dim);">{{ fd_icon("x",14) }} Clear filter</div>
</div>
</div>
<button class="ws-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="toggleSearch()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</button>
<button class="ws-icon-btn" @click="refreshTree()" title="Refresh">{{ fd_icon("refresh",16) }}</button>
<button class="ws-icon-btn" @click="showNewFile=!showNewFile" title="New file">{{ fd_icon("file",14) }}+</button>
<button class="ws-icon-btn" @click="createFolderAtRoot()" title="New folder">{{ fd_icon("folder",14) }}+</button>
<button class="ws-icon-btn" @click="triggerUpload()" title="Upload file">{{ fd_icon("upload",14) }}</button>
<input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple>
</div>
</div>
<div class="ws-search-bar" x-show="searchOpen" x-transition>
<input type="text" placeholder="Filter files and folders..." x-model="searchQuery" @input="doFilter()" autocomplete="off">
</div>
<div class="filter-row" x-show="filterType" style="padding:4px 0;margin-bottom:6px;">
<span class="filter-clear" @click="filterType='', doFilter()">clear filter ×</span>
</div>
<!-- Tree items -->
<div class="gw-content" style="padding:12px 4px 24px;">
<div x-show="!treeLoading">
<template x-if="treeItems.length === 0">
<template x-if="treeError">
<div class="empty-state">
<h2>{{ fd_icon("link",16) }} Repository unavailable</h2>
<p x-text="treeErrorText || 'Could not load the repository tree.'"></p>
<div class="empty-actions">
<button class="btn btn-primary" @click="refreshTree()">Retry</button>
</div>
</div>
</template>
<template x-if="!treeError && (filteredTreeItems || []).length === 0">
<div class="empty-state">
<h2>{{ fd_icon("folder",16) }} Empty</h2>
<p>No files in this folder</p>
@@ -114,12 +224,12 @@
</div>
</template>
<div class="ws-tree">
<template x-for="item in sortedTreeItems" :key="item.path">
<!-- Tree view -->
<div class="ws-tree" x-show="viewMode==='tree'">
<template x-for="item in (filteredTreeItems || [])" :key="item.path">
<div class="ws-tree-item"
@click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)"
@contextmenu.prevent="openGwContext($event, item)"
style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;">
@contextmenu.prevent="openGwContext($event, item)">
<span x-init="bindGwIcon($el, item)" style="font-size:16px;"></span>
<span x-text="item.name" style="flex:1;font-size:14px;" :style="{ fontWeight: item.type==='folder' ? '500' : '400' }"></span>
<span x-text="item.type==='folder' ? '' : formatSize(item.size)" style="font-size:12px;color:var(--text-tertiary);"></span>
@@ -127,6 +237,59 @@
</template>
</div>
<!-- List view -->
<table class="ws-table-compact" x-show="viewMode==='list' && (filteredTreeItems || []).length>0">
<thead><tr><th>Name</th><th style="width:80px">Type</th><th style="width:80px">Size</th></tr></thead>
<tbody>
<template x-for="item in (filteredTreeItems || [])" :key="'l'+item.path">
<tr @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)" style="cursor:pointer">
<td x-text="item.name"></td>
<td style="font-size:12px;color:var(--text-tertiary)" x-text="item.type"></td>
<td style="font-size:12px;color:var(--text-secondary)" x-text="formatSize(item.size)"></td>
</tr>
</template>
</tbody>
</table>
<!-- Details view -->
<table class="ws-table-enhanced" x-show="viewMode==='details' && (filteredTreeItems || []).length>0">
<thead><tr><th>Name</th><th>Path</th><th>Size</th><th>Type</th></tr></thead>
<tbody>
<template x-for="item in (filteredTreeItems || [])" :key="'d'+item.path">
<tr @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)" style="cursor:pointer">
<td x-text="item.name"></td>
<td style="font-size:12px;color:var(--text-tertiary);font-family:monospace;" x-text="item.path"></td>
<td style="font-size:12px;" x-text="formatSize(item.size)"></td>
<td style="font-size:12px;" x-text="item.type"></td>
</tr>
</template>
</tbody>
</table>
<!-- Cards view -->
<div class="title-grid" x-show="viewMode==='title' && (filteredTreeItems || []).length>0">
<template x-for="item in (filteredTreeItems || [])" :key="'g'+item.path">
<div class="title-card" @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)">
<div class="title-card-icon" x-init="bindGwIcon($el, item)"></div>
<div class="title-card-name" x-text="item.name"></div>
<div class="title-card-meta"><span x-text="formatSize(item.size)"></span></div>
</div>
</template>
</div>
<!-- Content view -->
<div class="content-list" x-show="viewMode==='content' && (filteredTreeItems || []).length>0">
<template x-for="item in (filteredTreeItems || [])" :key="'c'+item.path">
<div class="content-item" @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)">
<div class="content-item-header">
<span x-init="bindGwIcon($el, item)"></span>
<span x-text="item.name"></span>
<span style="font-size:11px;color:var(--text-tertiary);margin-left:auto" x-text="formatSize(item.size)"></span>
</div>
</div>
</template>
</div>
<!-- Context menu -->
<div class="gw-context-menu"
x-show="gwCtx.visible"
@@ -145,6 +308,7 @@
<input type="text" x-model="newFileMsg" placeholder="Commit message" class="settings-input" style="margin-bottom:8px;">
<button class="btn btn-primary" @click="createNewFile()">{{ fd_icon("save",14) }} Create file</button>
</div>
</div>
</div>
</template>
@@ -188,6 +352,7 @@
</div>
</div>
</template>
</div>
</div>
{% endblock %}
+358
View File
@@ -0,0 +1,358 @@
{% extends "base.html" %}
{% block page_title %}{{ workspace_name }}{% endblock %}
{% block title_prefix %}{{ workspace_name }}{% endblock %}
{% block page_icon %}{{ fd_icon("link",18) }}{% endblock %}
{% block topbar %}
{% set page_icon = "link" %}
{% set page_title = workspace_name %}
{% include '_header.html' %}
{% endblock %}
{% block content %}
<!-- Shared forge layout — identical structure to /local-workspace:
ws-split > ws-main, breadcrumb-row + Library-style toolbar
(View/Sort/Filter/Search, New file), search bar, filter chips,
then tree / list / details / cards / content views bound to the
forge tree (filteredTreeItems). File view + editor + commit bar
preserved below. -->
<link rel="stylesheet" href="/static/css/highlight.css?v={{ asset_version }}">
<script data-cfasync="false" src="/static/js/highlight.min.js?v={{ asset_version }}"></script>
<script data-cfasync="false" src="/static/js/highlight-extra.min.js?v={{ asset_version }}"></script>
<script data-cfasync="false" src="/static/js/code-highlight.js?v={{ asset_version }}"></script>
<style>
.ws-split{display:flex;max-width:100%;margin:0 auto;padding:0 16px 0;gap:0;min-height:calc(100vh - var(--topbar-height));}
.ws-main{flex:1;min-width:0;overflow:auto;}
.breadcrumb-row{display:flex;align-items:center;gap:4px;padding:2px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px 8px 0 0;margin-bottom:0;flex-wrap:wrap;}
.breadcrumb-row .nav-arrow{width:22px;height:22px;padding:0;font-size:13px;}
.toolbar-row{display:flex;align-items:center;gap:4px;padding:2px 8px;background:var(--bg-secondary);border:1px solid var(--border);border-top:none;border-radius:0 0 8px 8px;margin-bottom:2px;flex-wrap:wrap;}
.nav-arrow{width:28px;height:28px;font-size:13px;flex-shrink:0;}
.unified-breadcrumb{flex:1;display:flex;align-items:center;gap:2px;min-width:0;font-size:13px;overflow:hidden;}
.unified-breadcrumb .bc-root{color:var(--text-secondary);text-decoration:none;white-space:nowrap;font-weight:500;flex-shrink:0;}
.unified-breadcrumb .bc-root:hover{color:var(--accent);}
.unified-breadcrumb .bc-sep{color:var(--text-tertiary);margin:0 2px;flex-shrink:0;}
.unified-breadcrumb a{color:var(--text-secondary);text-decoration:none;white-space:nowrap;}
.unified-breadcrumb a:hover{color:var(--accent);}
.ws-toolbar{display:flex;align-items:center;gap:2px;flex-shrink:0;margin-left:auto;}
.ws-icon-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;background:transparent;border:none;color:var(--text-secondary);cursor:pointer;}
.ws-icon-btn:hover,.ws-icon-btn.active{background:var(--bg-hover);color:var(--text-primary);}
.ws-icon-btn svg{width:16px;height:16px;}
.ws-dropdown{position:absolute;top:calc(100% + 4px);right:0;min-width:210px;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:var(--shadow-popover);z-index:200;padding:6px;}
.ws-dropdown .dd-label{padding:6px 8px 2px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.3px;}
.ws-dropdown .dd-item{padding:6px 8px;font-size:13px;color:var(--text-primary);cursor:pointer;border-radius:4px;display:flex;align-items:center;gap:8px;}
.ws-dropdown .dd-item:hover{background:var(--bg-hover);}
.ws-dropdown .dd-item.active{color:var(--accent);}
.ws-dropdown .dd-item .check{width:14px;flex-shrink:0;color:var(--accent);}
.lib-toolbar-wrap{position:relative;}
.ws-search-bar{margin-bottom:10px;}
.ws-search-bar input{width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);color:var(--text-primary);font-size:13px;outline:none;}
.ws-search-bar input:focus{border-color:var(--accent);}
.filter-row{display:flex;align-items:center;gap:4px;padding:2px 0;margin-bottom:4px;flex-wrap:wrap;}
.filter-chip{padding:4px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;font-size:12px;color:var(--text-secondary);cursor:pointer;}
.filter-chip:hover{background:var(--bg-hover);color:var(--text);}
.filter-chip.active{background:var(--accent);color:#fff;border-color:var(--accent);}
.filter-clear{font-size:11px;color:var(--text-tertiary);cursor:pointer;padding:2px 6px;border-radius:4px;}
.filter-clear:hover{color:var(--danger);background:var(--bg-hover);}
.ws-tree{list-style:none;margin:0;padding:0;}
.ws-tree-item{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:6px;cursor:pointer;transition:background 150ms;border:1px solid transparent;margin-bottom:2px;}
.ws-tree-item:hover{background:var(--bg-tertiary);border-color:var(--border);}
.ws-tree-item .name{flex:1;font-size:14px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.ws-tree-item .name.folder{font-weight:500;}
.tree-chevron{width:16px;height:16px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:8px;padding:0;border-radius:3px;}
.tree-chevron:hover{color:var(--text);background:var(--bg-hover);}
.tree-chevron.open{transform:rotate(90deg);}
.ws-table-compact,.ws-table-enhanced{width:100%;border-collapse:collapse;font-size:13px;}
.ws-table-compact th,.ws-table-enhanced th{text-align:left;padding:6px 10px;color:var(--text-tertiary);font-weight:500;border-bottom:2px solid var(--border);font-size:11px;text-transform:uppercase;}
.ws-table-compact td,.ws-table-enhanced td{padding:5px 10px;border-bottom:1px solid var(--border);}
.ws-table-compact tr:hover td,.ws-table-enhanced tr:hover td{background:var(--bg-hover);}
.title-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:8px;margin-top:8px;}
.title-card{display:flex;flex-direction:column;align-items:center;padding:16px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:10px;cursor:pointer;min-height:100px;justify-content:center;}
.title-card:hover{background:var(--bg-hover);border-color:var(--accent);}
.title-card-name{font-size:13px;font-weight:500;text-align:center;word-break:break-word;}
.title-card-meta{display:flex;gap:8px;margin-top:6px;font-size:11px;color:var(--text-tertiary);}
.content-list{display:flex;flex-direction:column;gap:4px;margin-top:8px;}
.content-item{padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;cursor:pointer;}
.content-item:hover{background:var(--bg-hover);border-color:var(--accent);}
.empty-state{text-align:center;padding:80px 20px;color:var(--text-dim);}
.empty-state h2{font-size:18px;margin-bottom:8px;color:var(--text);}
.gw-file-toolbar{display:flex;align-items:center;gap:8px;padding:8px 24px;background:var(--bg-secondary);border-bottom:1px solid var(--border);font-size:13px;color:var(--text-dim);}
.gw-file-toolbar strong{color:var(--text);margin-right:12px;}
.gw-content{padding:24px;overflow-y:auto;flex:1;}
.gw-content pre{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;padding:16px;overflow-x:auto;font-size:13px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:70vh;overflow-y:auto;}
.gw-content textarea{width:100%;min-height:50vh;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;padding:12px;font-family:monospace;resize:vertical;outline:none;}
.gw-commit-bar{display:flex;align-items:center;gap:8px;padding:12px 24px;background:var(--bg-secondary);border-top:1px solid var(--border);flex-wrap:wrap;}
.gw-commit-bar input{flex:1;min-width:180px;padding:8px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;outline:none;}
.ctx-item{display:flex;align-items:center;gap:8px;padding:7px 14px;font-size:13px;color:var(--text);cursor:pointer;}
.ctx-item:hover{background:var(--bg-hover);}
.ctx-item.ctx-danger{color:var(--danger);}
.btn{padding:10px 20px;border-radius:8px;font-size:13px;cursor:pointer;border:none;font-weight:500;}
.btn-primary{background:var(--accent);color:#fff;}
.btn-danger{background:rgba(255,80,80,.1);color:var(--danger);}
</style>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/gitea_workspace.js?v={{ asset_version }}"></script>
<div class="ws-split" x-data="giteaWorkspace()">
<div class="ws-main">
<!-- File view -->
<div x-show="showFile && !showEditor" x-transition>
<div class="gw-file-toolbar">
<strong x-text="filePath || ''"></strong>
<span x-text="formatSize(fileSize)"></span>
<span style="flex:1;"></span>
<button class="btn" @click="openEditor()">{{ fd_icon("edit",14) }} Edit</button>
<button class="btn btn-danger" @click="deleteCurrentFile()">{{ fd_icon("trash",14) }} Delete</button>
</div>
<div class="gw-content">
<div class="gw-code-head" x-show="!fileLoading" style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<span class="gw-code-lang" x-text="fileLanguageLabel" style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-tertiary);"></span>
</div>
<pre x-show="!fileLoading"><code class="hljs" :class="'language-'+fileLanguage" :data-hljs-lang="fileLanguage" x-ref="codeBlock" x-text="fileContent"></code></pre>
</div>
</div>
<!-- Editor view -->
<template x-if="showEditor">
<div>
<div class="gw-file-toolbar">
<strong>Editing: <span x-text="filePath || 'new file'"></span></strong>
<span style="flex:1;"></span>
<button class="btn" @click="showEditor=false">Cancel</button>
</div>
<div class="gw-content">
<textarea x-model="editContent"></textarea>
</div>
<div class="gw-commit-bar">
<input type="text" x-model="commitMessage" placeholder="Commit message" list="commit-history">
<datalist id="commit-history">
<template x-for="msg in commitHistory" :key="msg">
<option :value="msg">
</template>
</datalist>
<button class="btn-primary" @click="saveFile()">{{ fd_icon("save",14) }} Commit</button>
</div>
</div>
</template>
<!-- Browser (same chrome as /local-workspace) -->
<template x-if="!showFile && !showEditor && !showPrivate">
<div>
<div class="breadcrumb-row">
<a href="/workspaces" class="btn-icon nav-arrow" title="All workspaces">{{ fd_icon("home",14) }}</a>
<div class="unified-breadcrumb">
<a href="#" @click.prevent="navigateToRoot()" class="bc-root" style="cursor:pointer;" x-text="owner + '/' + repo"></a>
<template x-for="(b, i) in folderStack" :key="i">
<span style="display:contents;">
<span class="bc-sep">/</span>
<a href="#" @click.prevent="navigateToFolder(i)" x-text="b"></a>
</span>
</template>
<span x-show="treeLoading" style="color:var(--text-tertiary);">Loading...</span>
</div>
<div class="ws-toolbar">
<div class="lib-toolbar-wrap" style="position:relative;">
<button class="ws-icon-btn" :class="{active: viewMenuOpen}" title="View" @click.stop="toggleViewMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>
</button>
<div class="ws-dropdown" x-show="viewMenuOpen" @click.outside="viewMenuOpen=false" x-transition style="right:0;">
<div class="dd-label">View</div>
<div class="dd-item" :class="{active: viewMode==='tree'}" @click="viewMode='tree', viewMenuOpen=false"><span class="check" x-text="viewMode==='tree'?'✓':''"></span>{{ fd_icon("folder",14) }} Tree</div>
<div class="dd-item" :class="{active: viewMode==='list'}" @click="viewMode='list', viewMenuOpen=false"><span class="check" x-text="viewMode==='list'?'✓':''"></span>{{ fd_icon("list",14) }} List</div>
<div class="dd-item" :class="{active: viewMode==='details'}" @click="viewMode='details', viewMenuOpen=false"><span class="check" x-text="viewMode==='details'?'✓':''"></span>{{ fd_icon("bar-chart",14) }} Details</div>
<div class="dd-item" :class="{active: viewMode==='title'}" @click="viewMode='title', viewMenuOpen=false"><span class="check" x-text="viewMode==='title'?'✓':''"></span>{{ fd_icon("grid",14) }} Cards</div>
<div class="dd-item" :class="{active: viewMode==='content'}" @click="viewMode='content', viewMenuOpen=false"><span class="check" x-text="viewMode==='content'?'✓':''"></span>{{ fd_icon("align-left",14) }} Content</div>
</div>
</div>
<div class="lib-toolbar-wrap" style="position:relative;">
<button class="ws-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSortMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="14" y2="9"/><line x1="4" y1="15" x2="10" y2="15"/></svg>
</button>
<div class="ws-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition style="right:0;">
<div class="dd-label">Sort by</div>
<div class="dd-item" :class="{active: sortBy===''}" @click="sortBy='', doSort(), sortOpen=false"><span class="check" x-text="sortBy===''?'✓':''"></span> Name A→Z</div>
<div class="dd-item" :class="{active: sortBy==='name-desc'}" @click="sortBy='name-desc', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='name-desc'?'✓':''"></span> Name Z→A</div>
<div class="dd-item" :class="{active: sortBy==='type'}" @click="sortBy='type', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='type'?'✓':''"></span> Type</div>
</div>
</div>
<div class="lib-toolbar-wrap" style="position:relative;">
<button class="ws-icon-btn" :class="{active: filterOpen || filterType}" title="Filter" @click.stop="toggleFilterMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
</button>
<div class="ws-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition style="right:0;">
<div class="dd-label">File type</div>
<div class="dd-item" :class="{active: filterType==='folder'}" @click="filterByType('folder'), filterOpen=false"><span class="check" x-text="filterType==='folder'?'✓':''"></span>{{ fd_icon("folder",14) }} Folders</div>
<div class="dd-item" :class="{active: filterType==='page'}" @click="filterByType('page'), filterOpen=false"><span class="check" x-text="filterType==='page'?'✓':''"></span>{{ fd_icon("file",14) }} Files</div>
<div class="dd-item" :class="{active: filterType==='image'}" @click="filterByType('image'), filterOpen=false"><span class="check" x-text="filterType==='image'?'✓':''"></span>{{ fd_icon("image",14) }} Images</div>
<div class="dd-item" :class="{active: filterType==='code'}" @click="filterByType('code'), filterOpen=false"><span class="check" x-text="filterType==='code'?'✓':''"></span>{{ fd_icon("file",14) }} Code</div>
<div class="more-sep" x-show="filterType"></div>
<div class="dd-item" x-show="filterType" @click="filterType='', doFilter(), filterOpen=false" style="color:var(--text-dim);">{{ fd_icon("x",14) }} Clear filter</div>
</div>
</div>
<button class="ws-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="toggleSearch()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</button>
<button class="ws-icon-btn" @click="refreshTree()" title="Refresh">{{ fd_icon("refresh",16) }}</button>
<button class="ws-icon-btn" @click="showNewFile=!showNewFile" title="New file">{{ fd_icon("file",14) }}+</button>
<button class="ws-icon-btn" @click="createFolderAtRoot()" title="New folder">{{ fd_icon("folder",14) }}+</button>
<button class="ws-icon-btn" @click="triggerUpload()" title="Upload file">{{ fd_icon("upload",14) }}</button>
<input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple>
</div>
</div>
<div class="ws-search-bar" x-show="searchOpen" x-transition>
<input type="text" placeholder="Filter files and folders..." x-model="searchQuery" @input="doFilter()" autocomplete="off">
</div>
<div class="filter-row" x-show="filterType" style="padding:4px 0;margin-bottom:6px;">
<span class="filter-clear" @click="filterType='', doFilter()">clear filter ×</span>
</div>
<div class="gw-content" style="padding:12px 4px 24px;">
<div x-show="!treeLoading">
<template x-if="treeError">
<div class="empty-state">
<h2>{{ fd_icon("link",16) }} Repository unavailable</h2>
<p x-text="treeErrorText || 'Could not load the repository tree.'"></p>
<div class="empty-actions">
<button class="btn btn-primary" @click="refreshTree()">Retry</button>
</div>
</div>
</template>
<template x-if="!treeError && (filteredTreeItems || []).length === 0">
<div class="empty-state">
<h2>{{ fd_icon("folder",16) }} Empty</h2>
<p>No files in this folder</p>
<button class="btn btn-primary" @click="showNewFile=!showNewFile">{{ fd_icon("file",14) }}+ New file</button>
</div>
</template>
<!-- Tree view -->
<div class="ws-tree" x-show="viewMode==='tree'">
<template x-for="item in (filteredTreeItems || [])" :key="item.path">
<div class="ws-tree-item"
@click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)"
@contextmenu.prevent="openGwContext($event, item)">
<span x-init="bindGwIcon($el, item)" style="font-size:16px;"></span>
<span x-text="item.name" style="flex:1;font-size:14px;" :style="{ fontWeight: item.type==='folder' ? '500' : '400' }"></span>
<span x-text="item.type==='folder' ? '' : formatSize(item.size)" style="font-size:12px;color:var(--text-tertiary);"></span>
</div>
</template>
</div>
<!-- List view -->
<table class="ws-table-compact" x-show="viewMode==='list' && (filteredTreeItems || []).length>0">
<thead><tr><th>Name</th><th style="width:80px">Type</th><th style="width:80px">Size</th></tr></thead>
<tbody>
<template x-for="item in (filteredTreeItems || [])" :key="'l'+item.path">
<tr @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)" style="cursor:pointer">
<td x-text="item.name"></td>
<td style="font-size:12px;color:var(--text-tertiary)" x-text="item.type"></td>
<td style="font-size:12px;color:var(--text-secondary)" x-text="formatSize(item.size)"></td>
</tr>
</template>
</tbody>
</table>
<!-- Details view -->
<table class="ws-table-enhanced" x-show="viewMode==='details' && (filteredTreeItems || []).length>0">
<thead><tr><th>Name</th><th>Path</th><th>Size</th><th>Type</th></tr></thead>
<tbody>
<template x-for="item in (filteredTreeItems || [])" :key="'d'+item.path">
<tr @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)" style="cursor:pointer">
<td x-text="item.name"></td>
<td style="font-size:12px;color:var(--text-tertiary);font-family:monospace;" x-text="item.path"></td>
<td style="font-size:12px;" x-text="formatSize(item.size)"></td>
<td style="font-size:12px;" x-text="item.type"></td>
</tr>
</template>
</tbody>
</table>
<!-- Cards view -->
<div class="title-grid" x-show="viewMode==='title' && (filteredTreeItems || []).length>0">
<template x-for="item in (filteredTreeItems || [])" :key="'g'+item.path">
<div class="title-card" @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)">
<div class="title-card-icon" x-init="bindGwIcon($el, item)"></div>
<div class="title-card-name" x-text="item.name"></div>
<div class="title-card-meta"><span x-text="formatSize(item.size)"></span></div>
</div>
</template>
</div>
<!-- Content view -->
<div class="content-list" x-show="viewMode==='content' && (filteredTreeItems || []).length>0">
<template x-for="item in (filteredTreeItems || [])" :key="'c'+item.path">
<div class="content-item" @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)">
<div class="content-item-header">
<span x-init="bindGwIcon($el, item)"></span>
<span x-text="item.name"></span>
<span style="font-size:11px;color:var(--text-tertiary);margin-left:auto" x-text="formatSize(item.size)"></span>
</div>
</div>
</template>
</div>
<!-- Context menu -->
<div class="gw-context-menu"
x-show="gwCtx.visible"
:style="'left:' + gwCtx.x + 'px; top:' + gwCtx.y + 'px'"
@click.outside="gwCtx.visible = false"
style="display:none;position:fixed;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-md);box-shadow:var(--shadow-popover);z-index:1100;min-width:160px;padding:4px;">
<div class="ctx-item" @click="gwRename()">{{ fd_icon("edit",14) }} Rename</div>
<div class="ctx-item ctx-danger" @click="gwDelete()">{{ fd_icon("trash",14) }} Delete</div>
</div>
</div>
<!-- New file form -->
<div x-show="showNewFile" style="margin-top:16px;max-width:400px;">
<input type="text" x-model="newFilePath" placeholder="path/to/file.md" class="settings-input" style="margin-bottom:8px;">
<textarea x-model="newFileContent" placeholder="File content..." class="settings-input" style="height:120px;margin-bottom:8px;"></textarea>
<input type="text" x-model="newFileMsg" placeholder="Commit message" class="settings-input" style="margin-bottom:8px;">
<button class="btn btn-primary" @click="createNewFile()">{{ fd_icon("save",14) }} Create file</button>
</div>
</div>
</div>
</template>
<!-- Private pages view -->
<template x-if="showPrivate && !editingPrivate">
<div class="gw-content">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<h3>{{ fd_icon("lock",14) }} Private Pages</h3>
<button class="btn btn-primary" onclick="window.FlowDeck.createPage()">+ New Page</button>
</div>
<template x-if="privatePages.length === 0">
<div class="empty-state" style="padding:40px 20px;">
<p>No private pages yet. Create one to store notes linked to this project.</p>
</div>
</template>
<template x-for="pp in privatePages" :key="pp.id">
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:8px;cursor:pointer;display:flex;align-items:center;justify-content:space-between;"
@click="openPrivate(pp.id)">
<div>
<div style="font-weight:500;" x-text="pp.title"></div>
<div style="font-size:12px;color:var(--text-dim);" x-text="fmtGwDate(pp)"></div>
</div>
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px;" @click.stop="deletePrivate(pp.id)">{{ fd_icon('trash',14) }}</button>
</div>
</template>
</div>
</template>
<!-- Private page editor -->
<template x-if="editingPrivate">
<div>
<div class="gw-file-toolbar">
<strong>Editing: <span x-text="editingPrivateTitle || 'Untitled'"></span></strong>
<span style="flex:1;"></span>
<button class="btn" @click="savePrivate()">{{ fd_icon("save",14) }} Save</button>
<button class="btn" @click="closePrivateEdit()">Cancel</button>
</div>
<div class="gw-content">
<input x-model="editingPrivateTitle" placeholder="Page title" style="width:100%;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:16px;margin-bottom:12px;outline:none;">
<textarea x-model="editingPrivateContent" placeholder="Start writing…" style="min-height:60vh;"></textarea>
</div>
</div>
</template>
</div>
</div>
{% endblock %}
+252
View File
@@ -0,0 +1,252 @@
{% extends "base.html" %}
{% block page_title %}Help{% endblock %}
{% block title_prefix %}Help{% endblock %}
{% block page_icon %}{{ fd_icon("help-circle",18) }}{% endblock %}
{% block topbar %}
{% set page_icon = "help-circle" %}
{% set page_title = "Help" %}
{% include '_header.html' %}
{% endblock %}
{% block content %}
{# Same panel skeleton as settings (shared /static/css/settings.css) #}
<style>
.help-body{margin-bottom:24px;max-width:760px;}
.help-body p{font-size:14px;color:var(--text-secondary);line-height:1.65;margin:0 0 10px;}
.help-body ul{margin:0 0 14px;padding-left:18px;}
.help-body li{font-size:14px;color:var(--text-secondary);line-height:1.6;margin-bottom:4px;}
.help-body code{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;padding:1px 6px;font-size:12.5px;}
.help-body h3{font-size:13px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim);margin:22px 0 8px;padding-bottom:4px;border-bottom:1px solid var(--border);}
.help-kbd{display:inline-block;padding:2px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:12px;color:var(--text);min-width:16px;text-align:center;}
.help-shortcut-row{display:flex;align-items:center;gap:12px;padding:8px 12px;border-radius:6px;margin-bottom:2px;}
.help-shortcut-row:hover{background:var(--bg-hover);}
.help-shortcut-row .keys{display:flex;gap:4px;min-width:140px;}
.help-shortcut-row .desc{font-size:13px;color:var(--text-dim);}
.help-badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:11px;font-weight:600;}
.help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;}
.help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;}
.help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;}
.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
</style>
<!-- v7.69.3 : positionnement IDENTIQUE à la section Settings — les surcharges
inline (position:static / background:none / panneau 1050px) faisaient
d'Aide un bloc posé dans la page au lieu de l'overlay centré de Settings. -->
<div class="settings-overlay" x-data="helpInit()">
<div class="settings-panel" @keydown.escape="navOpen = false">
<button class="settings-menu-btn" @click="navOpen = !navOpen" title="Sections" aria-label="Open sections menu">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<button class="settings-close" @click="close()" title="Close" aria-label="Close help">×</button>
<!-- Mobile drawer backdrop -->
<div class="settings-nav-backdrop" x-show="navOpen" x-cloak @click="navOpen = false"></div>
<!-- Left navigation -->
<div class="settings-nav" :class="{ 'nav-open': navOpen }" @click.capture="closeNavOnMobile()">
<div class="settings-nav-header">Basics</div>
<div class="settings-nav-item" :class="{ active: section==='start' }" @click="go('start')">🚀 Getting Started</div>
<div class="settings-nav-item" :class="{ active: section==='editor' }" @click="go('editor')">📝 Pages &amp; Editor</div>
<div class="settings-nav-item" :class="{ active: section==='databases' }" @click="go('databases')">🗄️ Databases &amp; Views</div>
<div class="settings-nav-item" :class="{ active: section==='tasks' }" @click="go('tasks')">✅ Tasks &amp; Dependencies</div>
<div class="settings-nav-header">Workspace</div>
<div class="settings-nav-item" :class="{ active: section==='workspaces' }" @click="go('workspaces')">📁 Workspaces &amp; Forges</div>
<div class="settings-nav-item" :class="{ active: section==='collab' }" @click="go('collab')">👥 Collaboration</div>
<div class="settings-nav-item" :class="{ active: section==='nav' }" @click="go('nav')">📚 Library, Trash &amp; Search</div>
<div class="settings-nav-header">Features</div>
<div class="settings-nav-item" :class="{ active: section==='agent' }" @click="go('agent')">🤖 AI Agent &amp; Automations</div>
<div class="settings-nav-item" :class="{ active: section==='integrations' }" @click="go('integrations')">🔌 Integrations &amp; API</div>
<div class="settings-nav-item" :class="{ active: section==='pwa' }" @click="go('pwa')">📶 Offline &amp; PWA</div>
<div class="settings-nav-item" :class="{ active: section==='auth' }" @click="go('auth')">🔐 Accounts &amp; SSO</div>
<div class="settings-nav-header">Reference</div>
<div class="settings-nav-item" :class="{ active: section==='shortcuts' }" @click="go('shortcuts')">⌨️ Keyboard Shortcuts</div>
<div class="settings-nav-item" :class="{ active: section==='tips' }" @click="go('tips')">💡 Tips</div>
</div>
<!-- Content -->
<div class="settings-content">
<div class="help-body" x-show="section==='start'">
<h2>Getting Started</h2>
<p>FlowDeck is your private, self-hosted Notion-style workspace — pages, databases, and Git-forge (Gitea / GitHub) integration in one app.</p>
<h3>First steps</h3>
<ul>
<li>Open <b>Workspaces</b> (<code>/workspaces</code>) to pick or create a workspace — local or connected to a forge.</li>
<li>Click <b>+ New page</b> in the sidebar to start writing, or press <span class="help-kbd">Ctrl</span>+<span class="help-kbd">N</span>.</li>
<li>Use the <b>Agent &amp; IA</b> section in Settings to plug an LLM provider (OpenAI, Ollama, DeepInfra…) if you want AI features.</li>
</ul>
<h3>The sidebar</h3>
<ul>
<li>The workspace header (top-left) switches workspaces and opens your account menu.</li>
<li>Tabs <b>Home / Chat / Meetings / Inbox</b> swap the sidebar content; pages are a drag-and-drop tree.</li>
<li>On mobile the sidebar is a drawer: open it with the hamburger button, close it by tapping outside or swiping left.</li>
</ul>
</div>
<div class="help-body" x-show="section==='editor'">
<h2>Pages &amp; Editor</h2>
<p>Notion-style block editor: every line is a block you can drag, convert, and nest.</p>
<h3>Blocks</h3>
<ul>
<li>Type <code>/</code> for the slash menu — headings, callouts, toggles, code, math (KaTeX), tables, images, embeds, ToC…</li>
<li>Markdown shortcuts as you type: <code># </code> heading, <code>- </code> bullet, <code>1. </code> numbered, <code>&gt; </code> quote, <code>[] </code> todo, <code>``` </code> code.</li>
<li>Drag the handle left of a block to move or nest it; <span class="help-kbd">Tab</span>/<span class="help-kbd">Shift+Tab</span> indent/outdent.</li>
<li>Inline formatting: bold, italic, code, links, colors via the selection toolbar.</li>
</ul>
<h3>Page features</h3>
<ul>
<li>Cover image and icon on every page (click the top of the page).</li>
<li>Inline databases: add a table/board/calendar collection directly in a page.</li>
<li>Comments (threaded), page history with snapshots, backlinks.</li>
<li>Right-click any sidebar item for context menu: duplicate, rename, move, delete.</li>
</ul>
</div>
<div class="help-body" x-show="section==='databases'">
<h2>Databases &amp; Views</h2>
<p>Collections are databases with a JSON schema, independent of Gitea. <b>21 property types</b>: title, text, number, select, multi-select, status, date, person, checkbox, url, email, phone, files, unique_id, relation, rollup, formula, and auto-properties.</p>
<h3>Views</h3>
<ul>
<li><b>Table</b> — sortable columns, inline editing.</li>
<li><b>Board (Kanban)</b> — group by select/status, drag &amp; drop between columns.</li>
<li><b>Calendar</b> — month grid by date property.</li>
<li><b>Gallery</b> — visual cards, configurable card size.</li>
<li><b>List</b> — compact rows with preview.</li>
<li><b>Timeline</b> — Gantt bars from date ranges.</li>
<li><b>Status Overview / Team Load</b> — donut + stacked-bar dashboards.</li>
</ul>
<h3>Relations, rollups, formulas</h3>
<ul>
<li>Relations are bidirectional: link rows across collections.</li>
<li>Rollups aggregate related rows (12 aggregations: sum, count, average, min, max…).</li>
<li>Formulas support 19 functions over row properties.</li>
<li>Filters, sorts and grouping apply per-view.</li>
</ul>
</div>
<div class="help-body" x-show="section==='tasks'">
<h2>Tasks &amp; Dependencies</h2>
<ul>
<li>Todo blocks anywhere in pages become checkboxes; the <b>My Tasks</b> page aggregates every task in every collection you can access.</li>
<li>My Tasks views: <b>All / Today / Overdue / Next 7 days</b>.</li>
<li><b>Sub-items</b>: unlimited hierarchy — a parent row is Done when all children are Done (status aggregate).</li>
<li><b>Dependencies</b>: Blocking / Blocked by between rows; transitioning a blocked task is refused until its blocker is done.</li>
</ul>
</div>
<div class="help-body" x-show="section==='workspaces'">
<h2>Workspaces &amp; Forges</h2>
<p>Organize work into separate workspaces, each with its own page tree, collections and members.</p>
<ul>
<li><span class="help-badge local">Local</span> Pages and files stored on your server (SQLite under <code>/data</code>).</li>
<li><span class="help-badge gitea">Gitea</span> Connect your Gitea account (Settings → Integrations): repos appear as workspaces, browse the file tree in the sidebar, create/edit files with commit messages, sync labels as tags.</li>
<li><span class="help-badge github">GitHub</span> Connect via Settings → Integrations for repo browsing.</li>
<li>Gitea legacy boards are auto-adapted into FlowDeck collections (GiteaBoardCompat).</li>
<li>The <b>Private</b> section appears when a remote workspace is active — those pages stay local.</li>
</ul>
</div>
<div class="help-body" x-show="section==='collab'">
<h2>Collaboration</h2>
<ul>
<li><b>Roles</b>: workspace members are admin / editor / commenter / viewer.</li>
<li><b>Sharing</b>: the Share button (top-right of a page) — share with specific users or publish a read-only public link (<code>/p/your-slug</code>).</li>
<li><b>Teamspaces</b> (wiki): shared knowledge spaces with their own pages.</li>
<li><b>Meetings</b>: meeting notes with an action-items panel.</li>
<li><b>Sprints</b>: time-box collections of tasks, with burndown-ready statuses.</li>
<li><b>Notifications</b>: bell in the topbar for mentions, assignments, comments.</li>
<li>Live editing: changes propagate to other open tabs (real-time sync).</li>
</ul>
</div>
<div class="help-body" x-show="section==='nav'">
<h2>Library, Trash &amp; Search</h2>
<ul>
<li><b>Library</b> (<code>/library</code>) — tabs for Recents, Favorites, Shared, Published, with filtering.</li>
<li><b>Trash</b> (<code>/trash</code>) — soft-deleted pages, 30-day retention, restore or purge.</li>
<li><b>Ctrl+K</b> — command palette: fuzzy search across pages, jump anywhere, start an AI answer.</li>
<li>Full-text search API: <code>/api/v2/search</code>.</li>
</ul>
</div>
<div class="help-body" x-show="section==='agent'">
<h2>AI Agent &amp; Automations</h2>
<ul>
<li><b>Chat sidebar</b> (Chat tab): conversations with your configured LLM; pages can be attached as context.</li>
<li><b>Agent panel</b> on pages: propose edits, generate content, answer questions about the workspace.</li>
<li><b>Skill marketplace</b>: export/import portable skills, install presets from the <code>/</code> gallery.</li>
<li><b>Automations</b> (Settings → Automations): visual pipeline of trigger → condition → delay → action (webhook, notification, page update…), with run history and legacy JSON conversion.</li>
<li>Configure providers in <b>Settings → Agent &amp; IA</b>: per-provider API keys, model lists, offline mode without any provider.</li>
</ul>
</div>
<div class="help-body" x-show="section==='integrations'">
<h2>Integrations &amp; API</h2>
<ul>
<li><b>Public API v2</b> — <code>/api/v2</code>: CRUD on collections, pages, properties, views, comments, notifications, favorites, tags, sharing, sprints, templates. Bearer tokens (Settings → API tokens) with scopes <code>read</code>/<code>write</code>/<code>admin</code>, pagination, filters, sorting, idempotency keys, RFC 7807 errors. Interactive docs at <a href="/docs" target="_blank" rel="noopener">/docs</a>.</li>
<li><b>Agent API</b> — <code>/api/v2/agents/*</code>: agents, conversations, synchronous JSON run, action journal with rollback, external trigger.</li>
<li><b>Webhooks</b>: outgoing events (create/update/delete) managed in Settings → Integrations; incoming Gitea webhooks keep boards in sync.</li>
<li><b>Web Clipper</b> — browser extension (Manifest V3): clip article / selection / bookmark / screenshot straight into FlowDeck.</li>
<li><b>Import / Export</b>: CSV import, JSON/CSV/Markdown export.</li>
</ul>
</div>
<div class="help-body" x-show="section==='pwa'">
<h2>Offline mode (PWA)</h2>
<p>FlowDeck is a Progressive Web App: pages you visited stay available offline and your edits are saved locally, then synchronised when the connection returns.</p>
<ul>
<li><b>Install:</b> browser menu → <i>Install app</i> (Chrome/Edge) or <i>Add to Home Screen</i> (Safari/iOS).</li>
<li><b>Offline editing:</b> while offline, the editor stores changes in the browser (IndexedDB) and shows an offline banner with the number of pending changes. A ⟳ icon marks pages with unsynced edits.</li>
<li><b>Reconnection:</b> the queue is replayed automatically (and via Background Sync); a spinner badge appears while syncing, then a confirmation toast.</li>
<li><b>Conflicts:</b> the latest edit wins with a notice; server-side-deleted pages are recreated as orphan pages; title collisions get an <i>“…(copie offline)”</i> suffix.</li>
</ul>
</div>
<div class="help-body" x-show="section==='auth'">
<h2>Accounts &amp; SSO</h2>
<p>
<span class="help-badge local">Local</span> Email + password — create an account on the login page.<br><br>
<span class="help-badge gitea">Gitea OAuth</span> / <span class="help-badge github">GitHub OAuth</span> — sign in with your forge; your repos become workspaces. You can link a forge to an existing local account — your identity stays local.<br><br>
<span class="help-badge sso">SSO</span> <b>Enterprise SSO</b> — in <b>Settings → Admin → SSO / Enterprise</b>:
</p>
<ul>
<li><b>SAML 2.0</b> — paste IdP Entity ID, SSO URL and signing certificate; give the IdP your <code>/auth/saml/metadata</code> link.</li>
<li><b>OpenID Connect</b> — Issuer URL, Client ID and Client Secret (PKCE, scopes <code>openid profile email</code>).</li>
<li>Just-in-time provisioning, IdP groups mapped to workspace roles, <i>SSO only</i> mode disables local login (admins keep their door). Every attempt is audited in the login history.</li>
</ul>
<p>Sessions and API tokens are managed in Settings → Sessions / API tokens.</p>
</div>
<div class="help-body" x-show="section==='shortcuts'">
<h2>Keyboard Shortcuts</h2>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">K</span></div><div class="desc">Command palette / quick find</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">N</span></div><div class="desc">Create new page</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">S</span></div><div class="desc">Save current page</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">O</span></div><div class="desc">New AI chat (footer)</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">/</span></div><div class="desc">Slash command menu (in editor)</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">F2</span></div><div class="desc">Rename selected item</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Delete</span></div><div class="desc">Move selected item to trash</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Enter</span></div><div class="desc">Open selected page</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Tab</span> / <span class="help-kbd">Shift</span>+<span class="help-kbd">Tab</span></div><div class="desc">Indent / outdent block</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Escape</span></div><div class="desc">Close modal / cancel editing</div></div>
</div>
<div class="help-body" x-show="section==='tips'">
<h2>Tips</h2>
<ul>
<li>Toggle the desktop sidebar with the « button; collapsed, hover the left edge to peek it.</li>
<li><b>Ctrl+Click</b> / <b>Shift+Click</b> multi-select in the sidebar tree.</li>
<li>Hover any sidebar item for quick actions (favorite, share, delete).</li>
<li>Drag pages into the tree to reorganize; drop onto a page to nest it.</li>
<li>The section <b>⋮</b> menus reorder or hide sidebar sections to taste.</li>
<li>Dark/light theme toggle is in the topbar and persists across reloads.</li>
</ul>
</div>
</div>
</div>
</div>
<script data-cfasync="false" src="/static/js/help.js?v={{ asset_version }}"></script>
{% endblock %}
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Importer — FlowDeck</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="icon" href="/static/favicon.ico" sizes="48x48">
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
<style>
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;--warn:#D9730D;}
+37 -2
View File
@@ -5,12 +5,23 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% from '_icons.html' import fd_icon %}
<title>FlowDeck — All-in-one workspace</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="icon" href="/static/favicon.ico" sizes="48x48">
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#191919">
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<!-- v7.69 : identité visuelle — la landing est la page la plus partagée -->
<meta name="description" content="FlowDeck — espace de travail style Notion auto-hébergé, intégré à Gitea/GitHub. Éditeur de blocs, Kanban, bases de données.">
<meta property="og:type" content="website">
<meta property="og:site_name" content="FlowDeck">
<meta property="og:title" content="FlowDeck — votre espace Notion auto-hébergé">
<meta property="og:description" content="Éditeur de blocs, Kanban, bases de données et intégration Gitea/GitHub, sur votre propre serveur.">
<meta property="og:image" content="{{ app_base_url() }}/static/img/logo-512.jpg">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="FlowDeck — votre espace Notion auto-hébergé">
<meta name="twitter:description" content="Éditeur de blocs, Kanban, bases de données et intégration Gitea/GitHub.">
<meta name="twitter:image" content="{{ app_base_url() }}/static/img/logo-512.jpg">
<style>
:root {
--bg: #191919;
@@ -112,6 +123,22 @@
padding:12px 28px;
}
/* v7.69 : identité visuelle */
.brand-mark{border-radius:7px;flex:none;}
.hero-visual{
max-width:1060px;
margin:0 auto 72px;
padding:0 32px;
}
.hero-visual img{
width:100%;
height:auto;
display:block;
border-radius:14px;
border:1px solid var(--border);
box-shadow:0 30px 70px -30px rgba(0,0,0,.5);
}
/* Features grid */
.features{
display:grid;
@@ -148,7 +175,7 @@
<nav class="landing-nav">
<a href="/" class="landing-logo">
<span>{{ fd_icon("book",20) }}</span> FlowDeck
<img class="brand-mark" src="/static/img/logo.webp" alt="" width="26" height="26"> FlowDeck
</a>
<div class="landing-nav-actions">
<a href="/auth/login?provider=local" class="btn btn-secondary">Log in</a>
@@ -169,6 +196,14 @@
</div>
</section>
<!-- v7.69 : bannière du projet (asset optimisé : WebP 22 KB, JPEG en secours) -->
<div class="hero-visual">
<picture>
<source srcset="/static/img/banner.webp" type="image/webp">
<img src="/static/img/banner.jpg" alt="FlowDeck — éditeur de blocs, Kanban et bases de données" width="2400" height="793" loading="lazy" decoding="async">
</picture>
</div>
<div class="features">
<div class="feature-card">
<div class="feature-icon">{{ fd_icon("edit",24) }}</div>
+37 -8
View File
@@ -60,20 +60,38 @@
/* ── Table (div-based for hover effects) ── */
.lib-table{width:100%;}
.lib-table-header{display:flex;align-items:center;border-bottom:1px solid var(--border);padding:0;}
.lib-table-header .th{display:flex;align-items:center;gap:5px;padding:6px 8px;font-size:12px;font-weight:500;color:var(--text-dim);user-select:none;white-space:nowrap;}
.lib-table-header .th{display:flex;align-items:center;gap:5px;padding:6px 8px;font-size:12px;font-weight:500;color:var(--text-dim);user-select:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.lib-table-header .th svg{width:13px;height:13px;flex-shrink:0;}
/* Column widths — Notion-style */
.lib-col-name{flex:1;min-width:0;}
.lib-col-author{width:150px;flex-shrink:0;display:none;}
.lib-col-source{width:120px;flex-shrink:0;display:none;}
.lib-col-author{width:150px;flex-shrink:0;}
.lib-col-editedby{width:150px;flex-shrink:0;}
.lib-col-source{width:120px;flex-shrink:0;}
.lib-col-edited{width:110px;flex-shrink:0;text-align:right;justify-content:flex-end;}
.lib-table.show-author .lib-col-author{display:flex;}
.lib-table.show-source .lib-col-source{display:flex;}
.lib-col-visited{width:130px;flex-shrink:0;text-align:right;justify-content:flex-end;}
/* Visibilité des colonnes pilotée par Show columns. Ces règles doivent
battre .lib-table-header .th et .lib-row .row-cell (spécificité 0,2,0) :
avant, seul :not(.show-editedtime) (0,3,0) gagnait, donc seule Last
edited time s'affichait/masquait — les autres options semblaient mortes. */
.lib-table div.lib-col-author,
.lib-table div.lib-col-editedby,
.lib-table div.lib-col-source,
.lib-table div.lib-col-visited{display:none;}
.lib-table.show-author div.lib-col-author,
.lib-table.show-editedby div.lib-col-editedby,
.lib-table.show-source div.lib-col-source,
.lib-table.show-visited div.lib-col-visited{display:flex;}
.lib-table:not(.show-editedtime) div.lib-col-edited{display:none;}
.row-source{display:inline-flex;align-items:center;gap:5px;}
.row-source svg{width:13px;height:13px;flex-shrink:0;color:var(--text-dim);}
/* ── Row ── */
.lib-row{display:flex;align-items:center;height:34px;transition:background 0.1s;position:relative;}
.lib-row:hover{background:var(--bg-hover);}
/* ── Date section headers (AI Meeting Notes tab) ── */
.lib-section-header{display:flex;align-items:center;font-size:12px;font-weight:600;color:var(--text-secondary);padding:10px 8px 4px;user-select:none;}
.lib-section-header:first-child{padding-top:2px;}
.lib-row.selected{background:rgba(35,131,226,0.08);}
.lib-row.drag-over{outline:2px solid var(--accent);outline-offset:-2px;background:rgba(35,131,226,0.12);}
.lib-row.dragging{opacity:0.4;}
@@ -189,7 +207,7 @@
/* ── Responsive ── */
@media(max-width:768px){
.lib-col-author,.lib-col-source{display:none!important;}
.lib-col-author,.lib-col-editedby,.lib-col-source,.lib-col-visited{display:none!important;}
.peek-overlay{width:100vw;max-width:100vw;}
}
</style>
@@ -212,6 +230,9 @@
<button class="lib-tab" :class="{ active: tab === 'recents' }" @click="switchTab('recents')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 16 14"/></svg> Recents
</button>
<button class="lib-tab" :class="{ active: tab === 'meetings' }" @click="switchTab('meetings')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> AI Meeting Notes
</button>
<button class="lib-tab" :class="{ active: tab === 'favorites' }" @click="switchTab('favorites')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg> Favorites
</button>
@@ -265,6 +286,9 @@
<div class="lib-dropdown" x-show="viewOpen" @click.outside="viewOpen=false" x-transition>
<div class="dd-label">Show columns</div>
<div class="dd-item" @click="toggleColumn('author')"><span class="check" x-text="showAuthor ? '✓' : ''"></span> Created by</div>
<div class="dd-item" @click="toggleColumn('editedby')"><span class="check" x-text="showEditedBy ? '✓' : ''"></span> Last edited by</div>
<div class="dd-item" @click="toggleColumn('editedtime')"><span class="check" x-text="showEditedTime ? '✓' : ''"></span> Last edited time</div>
<div class="dd-item" @click="toggleColumn('visited')"><span class="check" x-text="showVisited ? '✓' : ''"></span> Last visited time</div>
<div class="dd-item" @click="toggleColumn('source')"><span class="check" x-text="showSource ? '✓' : ''"></span> Source</div>
</div>
</div>
@@ -351,7 +375,7 @@
</div>
<!-- ── Table ── -->
<div class="lib-table" id="lib-table" :class="{'show-author': showAuthor, 'show-source': showSource}">
<div class="lib-table" id="lib-table" :class="{'show-author': showAuthor, 'show-editedby': showEditedBy, 'show-editedtime': showEditedTime, 'show-visited': showVisited, 'show-source': showSource}">
<div class="lib-table-header">
<div class="th lib-col-name">
<input type="checkbox" style="width:14px;height:14px;accent-color:var(--accent);cursor:pointer;margin-left:2px;margin-right:6px;" :checked="allSelected" @click="toggleSelectAll()" title="Select / unselect all">
@@ -359,11 +383,16 @@
Page name
</div>
<div class="th lib-col-author">Created by</div>
<div class="th lib-col-source">Source</div>
<div class="th lib-col-editedby">Last edited by</div>
<div class="th lib-col-edited">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 16 14"/></svg>
Last edited time
</div>
<div class="th lib-col-visited">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
Last visited time
</div>
<div class="th lib-col-source">Source</div>
</div>
<div class="lib-table-body" id="lib-table-body">
</div>
+21 -10
View File
@@ -166,9 +166,12 @@
.bc-edit-go:hover{opacity:.85;}
/* ── Preserved styles ── */
/* NOTE : scopées à .ws-tree (arbre principal) — la variante globale
écrasait aussi les items .ws-tree-item de la sidebar (macro partagée),
d'où un espacement différent sur /local-workspace. */
.ws-tree{list-style:none;margin:0;padding:0;}
.ws-tree-item{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:6px;cursor:pointer;transition:background 150ms;border:1px solid transparent;margin-bottom:2px;}
.ws-tree-item:hover{background:var(--bg-tertiary);border-color:var(--border);}
.ws-tree .ws-tree-item{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:6px;cursor:pointer;transition:background 150ms;border:1px solid transparent;margin-bottom:2px;}
.ws-tree .ws-tree-item:hover{background:var(--bg-tertiary);border-color:var(--border);}
.ws-tree-item .icon{font-size:16px;width:20px;text-align:center;flex-shrink:0;}
.ws-tree-item .name{flex:1;font-size:14px;color:var(--text);}
.ws-tree-item .name.folder{font-weight:500;cursor:pointer;}
@@ -179,6 +182,8 @@
.ws-tree-item .name.file .inline-rename-input{flex:0 1 auto;}
.ws-tree-item .name.file .btn-open{flex:0 0 auto;}
.ws-shared-badge{flex-shrink:0;margin-right:4px;font-size:13px;line-height:1;opacity:.9;filter:grayscale(1) brightness(1.6);}
.ws-fav-badge{flex-shrink:0;margin-right:4px;font-size:13px;line-height:1;color:#f5a623;}
.ws-pub-badge{flex-shrink:0;margin-right:4px;font-size:13px;line-height:1;}
.ws-tree-item .actions{display:none;gap:2px;}
.ws-tree-item:hover .actions{display:flex;}
.ws-chevron{width:16px;height:16px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:8px;padding:0;border-radius:3px;transition:transform 150ms,color 100ms;}
@@ -480,7 +485,11 @@
.preview-leave-end{transform:translateX(100%);opacity:0;}
</style>
<script type="application/json" id="lw-config" nonce="{{ csp_nonce() }}">{{ {"current_folder_id": current_folder_id, "workspace_id": workspace_id} | tojson }}</script>
<script type="application/json" id="lw-config" nonce="{{ csp_nonce() }}">{{ {"current_folder_id": current_folder_id, "workspace_id": workspace_id, "workspace_name": workspace_name} | tojson }}</script>
<link rel="stylesheet" href="/static/css/highlight.css?v={{ asset_version }}">
<script data-cfasync="false" src="/static/js/highlight.min.js?v={{ asset_version }}"></script>
<script data-cfasync="false" src="/static/js/highlight-extra.min.js?v={{ asset_version }}"></script>
<script data-cfasync="false" src="/static/js/code-highlight.js?v={{ asset_version }}"></script>
<script data-cfasync="false" src="/static/js/local_workspace.js?v={{ asset_version }}"></script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof _wsInitData !== 'undefined');</script>
@@ -528,7 +537,6 @@
<a href="/workspaces" class="btn-icon nav-arrow" title="All workspaces">{{ fd_icon("home",14) }}</a>
<button class="btn-icon nav-arrow" @click="goBack()" title="Back" :disabled="!canGoBack">{{ fd_icon("chevron-left",14) }}</button>
<button class="btn-icon nav-arrow" @click="goForward()" title="Forward" :disabled="!canGoForward">{{ fd_icon("chevron-right",14) }}</button>
{% if breadcrumbs %}
<div class="unified-breadcrumb">
<span class="bc-icon">{{ fd_icon("folder",13) }}</span>
<a href="/local-workspace" @click.prevent="navigateToRoot()" class="bc-root">{{ workspace_name }}</a>
@@ -549,7 +557,6 @@
</div>
<button class="bc-edit-trigger" x-show="!editingPath" @click="startPathEdit()" title="Edit path">{{ fd_icon("edit",11) }}</button>
</div>
{% endif %}
<div class="ws-toolbar" style="margin-left:auto;">
<!-- View dropdown -->
<div class="lib-toolbar-wrap" style="position:relative;">
@@ -729,6 +736,8 @@
<span class="name folder" @click.stop="navigateToFolder(node.id)"
@dblclick.prevent.stop="startInlineRename(node, $event)">
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span x-show="node.favorited" class="ws-fav-badge" title="Favorite">★</span>
<span x-show="node.published" class="ws-pub-badge" title="Published to web">🌐</span>
<span x-show="renamingId !== node.id" x-text="node.name"></span>
<span class="child-count" x-show="node.child_count > 0 && renamingId !== node.id"
x-text="'(' + node.child_count + ')'"></span>
@@ -743,6 +752,8 @@
@mouseleave="hidePreview()"
title="Click to rename, double-click to open">
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span x-show="node.favorited" class="ws-fav-badge" title="Favorite">★</span>
<span x-show="node.published" class="ws-pub-badge" title="Published to web">🌐</span>
<span x-show="renamingId !== node.id" x-text="node.name"></span>
<input x-show="renamingId === node.id" class="inline-rename-input" x-model="renamingName"
@keydown.enter="commitRename(node)" @keydown.escape="cancelRename()"
@@ -970,11 +981,11 @@
<template x-for="t in (node.tags||[])" :key="'ctag'+t.id">
<span class="tag-chip" :style="{background: t.color}"><span x-text="t.name"></span></span>
</template>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- Old table view kept for backward compat, hidden -->
<table class="ws-table" x-show="false" style="display:none">
<thead><tr><th>Name</th><th>Type</th><th>Size</th></tr></thead>
+10 -1
View File
@@ -5,7 +5,7 @@
{% from '_icons.html' import fd_icon %}
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }} — FlowDeck</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="icon" href="/static/favicon.ico" sizes="48x48">
<style>
:root {
--bg: #191919;
@@ -173,8 +173,17 @@
</footer>
<script src="/static/js/katex.min.js?v=0.16.11"></script>
<link rel="stylesheet" href="/static/css/highlight.css?v={{ asset_version }}">
<script src="/static/js/highlight.min.js?v={{ asset_version }}"></script>
<script src="/static/js/highlight-extra.min.js?v={{ asset_version }}"></script>
<script src="/static/js/code-highlight.js?v={{ asset_version }}"></script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('DOMContentLoaded', function () {
// hljs — syntax-highlight code blocks (~230 file types via FDHighlight).
try {
if (window.FDHighlight) FDHighlight.highlightAllIn(document.querySelector('.pub-content') || document);
else if (window.hljs) document.querySelectorAll('.pub-content pre code').forEach(function (el) { try { window.hljs.highlightElement(el); } catch (e) {} });
} catch (e) {}
if (window.katex) {
document.querySelectorAll('div[data-katex]').forEach(function (el) {
var tex = el.getAttribute('data-katex') || '';
+15 -21
View File
@@ -11,16 +11,6 @@
{% block content %}
<style>
.settings-overlay{position:fixed;inset:0;z-index:500;display:flex;background:rgba(0,0,0,.4);backdrop-filter:blur(2px);}
.settings-panel{display:flex;width:1050px;max-width:98vw;height:85vh;margin:auto;background:var(--bg-primary);border:1px solid var(--border-strong);border-radius:14px;box-shadow:var(--shadow-modal);overflow:hidden;}
.settings-nav{width:200px;min-width:200px;background:var(--bg-sidebar);border-right:1px solid var(--border);padding:8px 0;overflow-y:auto;}
.settings-nav-header{padding:12px 16px;font-size:13px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;}
.settings-nav-item{display:flex;align-items:center;gap:8px;padding:6px 16px;font-size:13px;color:var(--text-secondary);cursor:pointer;transition:background 100ms;border-radius:0;}
.settings-nav-item:hover{background:var(--bg-hover);}
.settings-nav-item.active{background:rgba(35,131,226,.12);color:var(--text);font-weight:500;}
.settings-content{flex:1;overflow-y:auto;padding:24px 32px;}
.settings-content h2{font-size:18px;font-weight:600;margin-bottom:4px;}
.settings-content .section-desc{font-size:12px;color:var(--text-dim);margin-bottom:24px;}
.setting-group{margin-bottom:28px;}
.setting-group h3{font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim);margin-bottom:8px;padding-bottom:4px;border-bottom:1px solid var(--border);}
.setting-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--border);}
@@ -69,9 +59,7 @@
.status-dot.active{background:var(--toast-success-bg);}
.status-dot.inactive{background:var(--danger);}
/* Close button */
.settings-close{position:absolute;top:12px;right:12px;width:32px;height:32px;background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;border-radius:6px;display:flex;align-items:center;justify-content:center;}
.settings-close:hover{background:var(--bg-hover);color:var(--text);}
/* Close button — shared styles in /static/css/settings.css */
/* Agent & IA — status summary + provider cards */
.llm-summary{display:flex;gap:14px;align-items:flex-start;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:12px;padding:14px 16px;margin-bottom:22px;flex-wrap:wrap;}
@@ -125,26 +113,32 @@
.llm-detail .prov-field-row{margin-top:12px;}
.llm-detail-empty{display:flex;align-items:center;justify-content:center;height:260px;font-size:13px;color:var(--text-dim);text-align:center;padding:0 20px;}
@media (max-width:760px){.llm-layout{grid-template-columns:1fr;}.llm-list{max-height:220px;}}
/* ── Mobile side-nav drawer: shared settings.css handles overlay/nav/panel ── */
</style>
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="historyBack()">
<div class="settings-panel" style="position:relative;">
<div class="settings-overlay" x-data="settingsInit()">
<div class="settings-panel" style="position:relative;" @keydown.escape="historyBack()">
<button class="settings-menu-btn" @click="navOpen = !navOpen" title="Sections" aria-label="Open sections menu">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<button class="settings-close" @click="closeSettings()" title="Close">×</button>
<!-- Left navigation -->
<div class="settings-nav">
<!-- Left navigation (mobile: side-navigation drawer via hamburger + backdrop) -->
<div class="settings-nav-backdrop" x-show="navOpen" x-cloak @click="navOpen = false"></div>
<div class="settings-nav" :class="{ 'nav-open': navOpen }" @click.capture="closeNavOnMobile()">
<div class="settings-nav-header">Account</div>
<div class="settings-nav-item" :class="{ active: activeSection==='account' }" @click="activeSection='account'"><span class="nav-icon-inline">{{ fd_icon("user",14) }}</span> My account</div>
<div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'"><span class="nav-icon-inline">{{ fd_icon("bell",14) }}</span> Notifications</div>
<div class="settings-nav-item" :class="{ active: activeSection==='api-tokens' }" @click="navTo('api-tokens')"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div>
<div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="navTo('sessions')"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="navTo('extensions')"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>
{% if plugin_enabled('web-clipper') %}<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="navTo('extensions')"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>{% endif %}
<div class="settings-nav-header">Workspace</div>
<div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">{{ fd_icon("file",14) }} General</div>
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
<div class="settings-nav-header">Features</div>
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">{{ fd_icon("link",14) }} Integrations</div>
<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="navTo('automations')">{{ fd_icon("zap",14) }} Automations</div>
{% if plugin_enabled('automations') %}<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="navTo('automations')">{{ fd_icon("zap",14) }} Automations</div>{% endif %}
<div class="settings-nav-item" :class="{ active: activeSection==='llm' }" @click="activeSection='llm'">{{ fd_icon("bot",14) }} Agent &amp; IA</div>
<!-- Admin nav: only visible to admins -->
<template x-if="userIsAdmin">
@@ -423,7 +417,7 @@
</div>
<!-- Extensions (Web Clipper) -->
<div x-show="activeSection==='extensions'">
<div x-show="{{ 'true' if plugin_enabled('web-clipper') else 'false' }} &amp;&amp; activeSection==='extensions'">
<h2>Extensions</h2>
<p class="section-desc">FlowDeck Web Clipper — capture web content directly into FlowDeck. Manage connected browsers.</p>
<div class="setting-group">
@@ -584,7 +578,7 @@
</div>
<!-- Automations -->
<div x-show="activeSection==='automations'">
<div x-show="{{ 'true' if plugin_enabled('automations') else 'false' }} &amp;&amp; activeSection==='automations'">
<h2>Automations</h2>
<p class="section-desc">Règles if-this-then-that : déclencheur + condition + action. Utilisables aussi via le bloc bouton dans l'éditeur de page.</p>
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bienvenue sur FlowDeck</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="icon" href="/static/favicon.ico" sizes="48x48">
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
<style>
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;}
+20
View File
@@ -77,3 +77,23 @@ except OSError: # pragma: no cover
ENV.globals["asset_version"] = ASSET_VERSION
ENV.globals["csp_nonce"] = lambda: CSP_NONCE.get()
ENV.globals["csrf_token"] = lambda: CSRF_TOKEN.get()
# Plugins activés : conditionne les blocs de UI rendus côté serveur.
ENV.globals["plugin_enabled"] = lambda slug: _plugin_enabled(slug)
# v7.69 : URL de base absolue pour og:image / twitter:image — les crawlers
# ignorent les URL relatives. Lecture paresseuse comme _plugin_enabled
# (évite l'import circulaire au chargement du module). Même source que les
# liens de partage de pages (collaboration.py) : si elle est mal renseignée,
# ces liens le sont déjà.
ENV.globals["app_base_url"] = lambda: _app_base_url()
def _app_base_url() -> str:
from app.config import settings
return (settings.app_base_url or "").rstrip("/")
def _plugin_enabled(slug: str) -> bool:
from app.services.plugins import is_enabled
return is_enabled(slug)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+333
View File
@@ -0,0 +1,333 @@
# FlowDeck — Modèle de données (v7.69.8)
Document de référence du schéma de persistance, généré à partir du code réel :
- `app/db.py` — schéma de base (baseline, « version 1 »), créé de façon idempotente par `init_db()` (lignes 14–839) et connexion SQLite via `get_conn()` (lignes 843–858) ;
- `app/migrations.py` — migrations versionnées (1–38, table `schema_version`) ;
- `app/services/webhook_outbound.py` — table `webhook_subscriptions` (créée au boot, l. 272–286) ;
- `app/models/requests.py` / `app/models/responses.py` — couche Pydantic de l'API.
Total : **105 tables relationnelles + 1 table virtuelle FTS5** (`pages_fts`), réparties entre le baseline (`app/db.py`) et 33 migrations effectuant des créations de tables (`app/migrations.py`).
---
## 1. Moteur de base de données
### 1.1 SQLite uniquement (pas de dual-engine)
**Correction vis-à-vis de certaines notes d'architecture : FlowDeck ne supporte PAS PostgreSQL.** Le code est exclusivement SQLite :
- `app/db.py` l.4 : `import sqlite3` (stdlib) — aucune dépendance `psycopg`/`pg8000`/SQLAlchemy nulle part (`requirements.txt`, `pyproject.toml` vérifiés) ;
- le module entier est typé `sqlite3.Connection` (y compris `app/migrations.py` l.16) ;
- fonctionnalités SQLite spécifiques utilisées : `PRAGMA journal_mode=WAL`, `PRAGMA foreign_keys=ON`, `PRAGMA busy_timeout=5000` (`get_conn`, l.846–850), `INTEGER PRIMARY KEY AUTOINCREMENT`, `ALTER TABLE … ADD COLUMN` (sans `IF NOT EXISTS`, d'où le pattern `try/except sqlite3.OperationalError`), déclencheurs (`TRIGGER`), extension **FTS5**, `DROP TABLE`/`RENAME` pour les reconstructions (`comments`, `tags`) ;
- `app/config.py` l.58 : `database_url: str = "sqlite:////data/flowdeck.db"` — seul le préfixe `sqlite:///` est interprété par la propriété `db_path` (l.151–163, avec gestion `:memory:` et des chemins Windows type `C:\…`) ; toute autre valeur retombe sur `/data/flowdeck.db`.
La « compatibilité » mentionnée dans certains documents est donc en réalité : une **base SQLite unique par instance**, en mode WAL (un writer, multi-readers), avec `PRAGMA busy_timeout=5000` pour supporter la concurrence (tests xdist, scheduler d'automations). Les backups (v5.2.0, `backup_*` dans `app/config.py`) sont des copies du fichier SQLite.
### 1.2 Stratégie de migrations
Décrite dans l'en-tête de `app/migrations.py` (l.1–13) :
| Composant | Rôle |
|---|---|
| `app/db.py::init_db()` | Schéma **baseline** (« version 1 ») : `CREATE TABLE IF NOT EXISTS` + ALTER garde-fous (`try/except OperationalError`). Réexécuté à chaque boot, idempotent. |
| `schema_version` (`version INTEGER PK, name TEXT, applied_at`) | Registre des migrations appliquées ; version maximale lue par `current_version()` (l.66–71). Scellée à `BASELINE_VERSION = 1` au premier démarrage (l.86–101). |
| `@register(version, name)` (l.28–38) | Décorateur d'enregistrement dans `MIGRATIONS`, trié par version, doublons refusés. |
| `apply_migrations(conn)` (l.86–111) | Appelée en fin de `init_db()` (`app/db.py` l.829–831) ; applique exactement une fois chaque migration `> version courante`. |
| `_apply_one()` (l.113–130) | **Une migration = une transaction** (`BEGIN` explicite, `rollback` complet à l'échec, puis `INSERT INTO schema_version`) — DDL tout-ou-rien (audit A31). |
| `columns(conn, table)` (l.53–64) | Helper unique `PRAGMA table_info` pour tester l'existence de colonnes avant un ALTER. |
| `fts5_available()` (l.74–83) | Détection de FTS5 ; sinon repli sur recherche `LIKE`. |
Version de schéma actuelle : **38** (`v7.1.1 meeting block`, dernier `@register`). Les migrations 4 et 17 et 23–38 ont été enregistrées hors ordre chronologique de version (versions 4, 17, 23 insérées après 21) — le tri à l'enregistrement garantit l'ordre d'application.
---
## 2. Tables par domaine
Légende : **PK** = clé primaire, **FK** = clé étrangère (avec cascade quand indiquée). Sauf mention, PK = `id INTEGER PK AUTOINCREMENT`.
### 2.1 Kanban & intégration Gitea (héritage v0.x–v1.x)
Créées dans `app/db.py` (l.22–140, 430–478).
| Table | But | Clés / colonnes clés |
|---|---|---|
| `boards` | Board Kanban par dépôt Gitea | `UNIQUE(project_owner, project_name)` ; `columns_json` (liste des colonnes), `wip_limits_json` |
| `cards` | Position d'une issue Gitea dans une colonne | FK `board_id → boards` ; `gitea_issue_id`, `column_name`, `position`, `priority`, `due_date` |
| `col_mapping` | Mapping colonne ↔ label Gitea | FK `board_id → boards` ; `UNIQUE(board_id, column_name)` ; `close_issue` |
| `notes` | Notes par projet | `UNIQUE(project_owner, project_name, title)` ; `content` |
| `checklists` | Checklists par issue | `board_id`, `gitea_issue_id` (pas de FK déclarée), `title`, `position`, `UNIQUE(board_id, gitea_issue_id, title)` |
| `checklist_items` | Lignes de checklist | FK `checklist_id → checklists ON DELETE CASCADE` ; `content`, `checked`, `position` |
| `project_properties` | Propriétés par projet (ancêtre de `collection_properties`, conservé) | `UNIQUE(project_owner, project_name, name)` ; `prop_type`, `options_json` |
| `property_values` | Valeurs de ces propriétés par issue | FK `property_id → project_properties ON DELETE CASCADE` ; `UNIQUE(property_id, gitea_issue_id)` |
| `ai_keywords` | Fréquentité des mots-clés détectés par l'IA | `UNIQUE(project_owner, project_name, keyword)` ; `color`, `usage_count` |
| `gitea_private_pages` | Pages privées rattachées à un dépôt Gitea (v2.7) | FK `user_id → users` ; `gitea_owner`, `gitea_repo`, `content` |
| `user_tokens` | Token Gitea par utilisateur | `gitea_user_id INTEGER UNIQUE`, `gitea_token` |
| `projects` | Registre forge-agnostique des projets (gitea/github/builtin), synchronisé par cron (mig. 6, `migrations.py` l.260–331) | `UNIQUE(proj_type, owner, name)` ; `clone_url`, `default_branch`, `last_synced_at` |
### 2.2 Utilisateurs & authentification
| Table | But | Clés / colonnes clés | Emplacement |
|---|---|---|---|
| `users` | Compte local ou fédéré | `login UNIQUE`, `email`, `password_hash`, `is_admin`, `is_active`, `login_attempts`, `locked_until`, `auth_method` (local\|gitea), `avatar_url/color`, `sidebar_config` (JSON), `notification_prefs` (JSON), `timezone`, `totp_secret_enc`, `totp_backup_hashes` (JSON) | db.py l.22 ; ALTER db.py l.328–470, mig. 10/11/28 |
| `login_history` | Journal des connexions | FK `user_id → users` ; `ip_address`, `user_agent` | db.py l.40 |
| `user_oauth_tokens` | Tokens OAuth par provider (Gitea, Google, M365…) | FK `user_id → users` ; `UNIQUE(user_id, provider)` ; `access_token`, `refresh_token`, `expires_at` | db.py l.55 |
| `user_sessions` | Sessions cookie révocables (une ligne = un cookie signé) | **PK `id TEXT`** (payload du cookie) ; FK `user_id → users ON DELETE CASCADE` ; `revoked`, `last_seen_at` | mig. 6 (l.294–316) |
| `api_tokens` | Tokens Bearer publics (sha256 stocké) | FK `user_id → users ON DELETE CASCADE` ; `token_hash UNIQUE`, `token_prefix`, `scopes`, `expires_at`, `revoked` | mig. 6 + mig. 20 (l.884–900) |
| `webauthn_credentials` | Passkeys / 2FA WebAuthn | FK `user_id → users ON DELETE CASCADE` ; `credential_id UNIQUE`, `public_key`, `sign_count` | mig. 28 (l.1311–1324) |
| `sso_config` | Config SSO entreprise (SAML 2.0 ou OIDC), la table prime sur `.env` dès qu'un admin sauvegarde | FK `workspace_id → workspaces` ; secrets chiffrés au repos (`client_secret`, `sp_private_key` — `app/services/sso_provisioning.py`) ; `attribute_mapping`/`groups_mapping` (JSON), `active` | mig. 23 (l.1495–1523) |
| `sso_login_history` | Audit de chaque tentative SSO (succès + rejets) | FK `user_id → users` ; `success`, `error_message`, `sso_identifier` | mig. 23 (l.1525–1545) |
| `sso_requests` | Store mono-usage anti-replay (AuthnRequest ID, état OIDC, PKCE, relay state) | **PK `id TEXT`** ; `kind`, `code_verifier`, `next_path`, `used` | mig. 23 (l.1547–1555) |
| `scim_tokens` | Tokens Bearer de provisioning SCIM (hash unique) | `token_hash UNIQUE`, `revoked`, FK `created_by → users` | mig. 28 (l.1285–1294) |
| `domain_claims` | Domaines d'entreprise vérifiés (DNS/txt), auto-jonction + `enforce_sso` | `domain UNIQUE`, `txt_token`, FK `workspace_id → workspaces` | mig. 28 (l.1296–1307) |
| `llm_config` | Config LLM globale de l'instance — **ligne unique** `CHECK (id = 1)` | `provider`, `model`, `api_key`, `api_base`, `verified`, `verified_model`, `last_error` ; précédence : ligne DB > `settings.llm_*` | db.py l.769–784 |
| `user_llm_keys` | Clés API LLM par utilisateur et provider | FK `user_id → users ON DELETE CASCADE` ; `UNIQUE(user_id, provider)` ; `models_json` (cache des modèles), `default_model`, `verified*` | db.py l.788–812 |
### 2.3 Workspaces, collaboration & permissions
| Table | But | Clés / colonnes clés | Emplacement |
|---|---|---|---|
| `workspaces` | Espace de travail multi-utilisateur | FK `owner_id → users` ; `settings_json` | db.py l.222 |
| `workspace_members` | Adhésion + rôle (`owner/admin/editor/viewer/commenter`) | FK `workspace_id → workspaces CASCADE`, `user_id → users` ; `UNIQUE(workspace_id, user_id)` | db.py l.230 |
| `teamspaces` | Namespace de pages/bases dans un workspace (v7.3.0) ; `private=1` = invisibilité 404 | FK `workspace_id → workspaces CASCADE` ; `UNIQUE(workspace_id, name)` | mig. 29 (l.1380–1391) |
| `teamspace_members` | Membres + rôle d'un teamspace | FK `teamspace_id → teamspaces CASCADE`, `user_id → users` ; `UNIQUE(teamspace_id, user_id)` | mig. 29 (l.1393–1403) |
| `user_groups` | Groupes réutilisables par workspace (ACL) | FK `workspace_id → workspaces CASCADE` ; `UNIQUE(workspace_id, name)` | mig. 18 (l.721–730) |
| `group_members` | Adhésion à un groupe | FK `group_id → user_groups CASCADE`, `user_id → users CASCADE` ; `UNIQUE(group_id, user_id)` | mig. 18 (l.732–740) |
| `page_permissions` | Grant explicite sur une page de l'éditeur | FK `page_id → pages CASCADE` ; `user_id XOR group_id` (`CHECK`) ; `role` (viewer/commenter/editor/owner), `grant_type`, `granted_by` | mig. 18 (l.745–760) |
| `collection_permissions` | Idem pour les bases (collections) | FK `collection_id → collections CASCADE` ; mêmes colonnes | mig. 18 (l.766–780) |
| `property_permissions` | Grant par propriété de collection (view/edit) | FK `collection_id`, `property_id → collection_properties CASCADE` | mig. 18 (l.782–797) |
| `permission_audit_log` | Traînée immuable grant/revoke | `resource_type`, `resource_id`, `action`, `old_role`, `new_role`, `performed_by → users`, `ip_address` | mig. 18 (l.799–815) |
| `page_shares` | Partage de page (user, groupe ou email) | FK `page_id → pages CASCADE`, `shared_with_user_id → users`, `shared_with_group_id → user_groups` ; `permission` (view/edit/comment) | db.py l.451–478 |
| `guest_shares` | Accès sans compte via `/g/<token>` (v7.3.0) | FK `page_id → pages CASCADE` ; `token UNIQUE`, `role`, `expires_at`, `revoked` | mig. 29 (l.1440–1452) |
| `favorites` | Favoris d'un utilisateur (reconstruite en v2.2 pour pointer `pages`) | FK `user_id → users`, `page_id → pages` ; `UNIQUE(user_id, page_id)` | db.py l.260–270 |
| `recents` | Pages récemment accédées (vue « Recents ») | FK `user_id → users`, `page_id → pages` ; `source_type` (local/gitea…), `UNIQUE(user_id, page_id)` | db.py l.481–493 |
| `notifications` | Notifications in-app (mention/commentaire/page) | FK `user_id → users CASCADE`, `actor_id → users` ; `ntype`, `resource_type/resource_id`, `is_read` ; index `(user_id, is_read)` | db.py l.592–613 |
| `comments` | Commentaires (fil, résolus) ancrés sur pages ou lignes de base — **reconstruite** v4.9 : cible polymorphe | FK `user_id → users` ; `target_type` (`page` \| `collection_page`) + `target_id`, `parent_id` (réponses), `anchor_block_id/anchor_start/anchor_end` (ancre inline) | db.py l.241 (v2.0) puis l.620–649 (v4.9) |
| `comment_reactions` | Réactions emoji d'un commentaire | FK `comment_id → comments CASCADE`, `user_id → users CASCADE` ; `UNIQUE(comment_id, user_id, emoji)` | mig. 29 (l.1418–1428) |
| `text_reactions` | Réactions ancrées sur une plage de texte d'un bloc (façon Notion, v7.64) | FK `page_id → pages CASCADE`, `user_id → users CASCADE` ; `block_id TEXT`, offsets `anchor_start/anchor_end`, `UNIQUE(page_id, block_id, start, end, emoji, user_id)` | mig. 37 (l.1683–1707) |
| `page_follows` | Abonnement aux changements d'une page | PK composite `(page_id → pages CASCADE, user_id → users CASCADE)` | mig. 29 (l.1430–1437) |
| `page_verifications` | Badge « page vérifiée ✅ » avec expiration | **PK `page_id → pages CASCADE`** (1 par page) ; `verified_by → users`, `expires_at` | mig. 29 (l.1407–1416) |
| `tags` | Étiquettes par utilisateur (`user_id=0` = globales) | FK `user_id → users` ; `UNIQUE(name, user_id)` ; `color` | db.py l.282–290 |
| `page_tags` | Association page ↔ tag | PK composite `(page_id → pages CASCADE, tag_id → tags CASCADE)` | db.py l.292–299 |
| `custom_emojis` | Emojis personnalisés uploadés par workspace | FK `workspace_id` (défaut 1, **non déclaré**) ; `name`, `url` | mig. 8 (l.371–388) |
### 2.4 Pages & éditeur de blocs
| Table | But | Clés / colonnes clés | Emplacement |
|---|---|---|---|
| `pages` | Page de l'éditeur (wiki) — table pivot du produit | FK `workspace_id → workspaces`, `parent_id → pages` (hiérarchie), `collection_id → collections` (base plein écran, v4.6), `collection_row_id → collection_pages CASCADE` (page-ombre de contenu d'une ligne de base, v6.5, mig. 22 l.997–1020), `locked_by → users` ; colonnes notables : `content` (markdown legacy **ou JSON de blocs** selon `content_format`), `content_format`, `parent_section`, `share_mode`, `published`/`is_published`/`publish_slug`/`is_shared`, `deleted_at` (corbeille), `sort_order`, `cover_url`, `page_icon`, `is_locked`, `full_width`, `font_small`, `search_excluded`, `permission_type` (inherit/restricted/private), `teamspace_id` (ALTER, **sans FK déclarée**), `sync_version` (trigger de concurrence optimiste, mig. 17) | db.py l.154–166 + ALTER l.328–470 ; mig. 7/13/18/22/24/25/29 |
| `page_versions` | Snapshots annulables de l'éditeur de blocs (undo/restore, une ligne par save) | FK `page_id → pages CASCADE` ; `title`, `blocks_json` (liste complète des blocs), `note`, `user_id → users` | mig. 7 (l.336–365) |
| `page_history` | Historique legacy des **lignes de base** (`collection_pages`) | FK `page_id → collection_pages CASCADE` ; `change_type`, `snapshot_json` | db.py l.249 |
| `page_global_templates` | Modèles de page globaux créés par les utilisateurs | `blocks_json` (format éditeur), FK `created_by → users SET NULL` | mig. 13 (l.582–597) |
| `synced_blocks` | Bloc synchronisé : source de vérité du contenu, réutilisé sur plusieurs pages | `content` (JSON blocs), `created_by → users` (sans FK), `workspace` (TEXT), `synced` via `page_synced_blocks` | mig. 15 (l.626–662) |
| `page_synced_blocks` | Référence page ↔ bloc synchronisé (désynchronisable par page) | FK `page_id → pages CASCADE`, `synced_block_id → synced_blocks CASCADE` ; `block_index`, `UNIQUE(page_id, synced_block_id)` | mig. 15 (l.646–662) |
| `page_views` | Compteur de vues quotidien par page (même pattern que `site_views`) | FK `page_id → pages CASCADE` ; PK composite `(page_id, day)`, `views` | mig. 29 (l.1454–1461) |
| `pages_fts` | **Table virtuelle FTS5** (`title`, `body`) pour la recherche plein texte de la palette de commandes ; synchronisée par 3 triggers `AFTER INSERT/UPDATE/DELETE` sur `pages` ; backfill au boot ; repli `LIKE` si FTS5 absent | rowid = `pages.id` | mig. 3 (l.156–205) |
### 2.5 Bases de données (collections, style Notion)
| Table | But | Clés / colonnes clés | Emplacement |
|---|---|---|---|
| `collections` | Base de données (collection) | `schema_json` (schéma des propriétés), FK `gitea_owner/gitea_repo` (texte, verrouillage sync), `workspace_id → workspaces`, `created_by → users`, `is_locked`, `is_inline` + `parent_page_id` (base inline), `is_task` + `task_assignee_prop/task_status_prop/task_due_prop → collection_properties SET NULL` (mapping My Tasks, mig. 30 l.1558–1585), `permission_type`, `teamspace_id` (sans FK), `form_config_json`, `sync_version` | db.py l.172 + ALTER db.py l.495–520, mig. 18/24/29/30 |
| `collection_pages` | **Ligne** d'une base (row) | FK `collection_id → collections CASCADE`, `parent_id → collection_pages` (hiérarchie interne), `property_values_json` (valeurs par propriété), `gitea_issue_id/gitea_issue_number` (sync Kanban), `cover_url`, `external_event_id` (calendrier, mig. 27), `permission_type`, `sync_version` | db.py l.187 ; mig. 10/18/27 |
| `collection_views` | Vues sauvegardées par base (table/board/calendar/gallery/list…) | FK `collection_id → collections CASCADE` ; `view_type`, **`config_json`** (filtres/tri/groupement/columns visibles), `position`, `created_by` (NULL = vue partagée, sinon personnelle — mig. 10) | db.py l.210 ; mig. 10 (l.472–478) |
| `collection_properties` | Schéma des propriétés d'une base (remplace `project_properties`) | FK `collection_id → collections CASCADE` ; `prop_type`, `options_json` (select/multi-select), `number_format`, relationnels : `related_collection_id → collections SET NULL`, `relation_property_id/target_property_id → collection_properties SET NULL`, `rollup_function`, `formula_expression`, `validation_json` (mig. 4), `group_name` (mig. 10), `button_automation_id → automations SET NULL` (bouton natif, mig. 26) ; `UNIQUE(collection_id, name)` | db.py l.222–241 ; mig. 4/10/26 |
| `collection_data_sources` | Sources de données liées (base liée type Notion « Linked database ») | FK `collection_id → collections CASCADE`, `source_collection_id → collections` ; `is_linked`, `UNIQUE(collection_id, source_collection_id)` | db.py l.480–493 |
| `collection_dashboards` | Dashboard = combinaison de vues/widgets sur une page | FK `collection_id → collections CASCADE` ; **`layout_json`** (`{"columns":1,"widgets":[]}`) | db.py l.531–542 |
| `database_templates` | Modèles de bases prêts à l'emploi (seed idempotent, v5.3.0 : Meeting notes, etc.) | `name`, `icon`, `description`, `schema_json` | db.py l.272 + mig. 4 (l.390–411) ; seed via `app/services/db_templates.py::SEED_TEMPLATES` |
| `page_templates` | Modèles de **ligne** par base, éventuellement récurrents | FK `collection_id → collections CASCADE` ; `property_values_json`, `content_json` (blocs), `is_recurring` + `recurrence_rule` | db.py l.294–303 + ALTER l.512–522 |
| `page_dependencies` | Dépendances entre lignes (bloque / bloqué par) | FK `page_id`, `dependency_id → collection_pages CASCADE` ; `dependency_type`, `auto_shift` (overlap/…) ; `UNIQUE(page_id, dependency_id, dependency_type)` | db.py l.551–563 |
| `sprints` | Sprints Scrum par base de tâches | FK `collection_id → collections CASCADE` ; `start_date/end_date` (TEXT), `status` (planning/active/done), `goal`, `auto_complete` | db.py l.570–582 |
| `sprint_pages` | Lignes affectées à un sprint + vélocité | FK `sprint_id → sprints CASCADE`, `page_id → collection_pages CASCADE` ; `velocity_points`, `status_at_start`, `UNIQUE(sprint_id, page_id)` | db.py l.584–594 |
| `reminder_log` | Journal anti-doublon des rappels : 1 ligne par (ligne de base, date d'occurrence) — le rappel ne fire qu'une fois même après restart | FK `page_id → collection_pages CASCADE` ; `UNIQUE(page_id, occurrence_date)` ; alimenté par le scan `reminders_enabled` (`app/config.py` l.75–77) — **il n'existe pas de table `reminders`** ; les dates de rappel proviennent des propriétés `due` des lignes | mig. 11 (l.485–509) |
### 2.6 Automatisation, workers & webhooks
| Table | But | Clés / colonnes clés | Emplacement |
|---|---|---|---|
| `automations` | Règles if-this-then-that (moteur v5.1.0) : `trigger_type` event/cron/button, `event`, `cron_expression`, `collection_id` (TEXT sans FK, scope optionnel), **`condition_json`** (clauses), **`actions_json`** (descripteurs), `enabled`, `run_count`, `last_run_at`, `trigger_mode` (`any`\|`all`, fenêtre de 5 min, mig. 26) | | mig. 5 (l.208–257) + mig. 26 (l.1196–1201) |
| `automation_steps` | Étapes ordonnées d'une automatisation v2 (trigger/condition/delay/action, v7.0.0) ; l'engine retombe sur `condition_json/actions_json` si aucune step | FK `automation_id → automations CASCADE` ; `kind`, `position`, `config_json` | mig. 26 (l.1156–1172) |
| `automation_runs` | Historique d'exécution (audit + UI Settings) | FK `automation_id → automations CASCADE` ; `status` (fired/skipped/error), `trigger_source`, `collection_id`/`page_id` (TEXT/INT sans FK déclarée), `detail` | mig. 5 (l.242–257) |
| `workers` | Snippets Python sandboxés (cron ou manuel), partageables (v7.0.0) | `slug UNIQUE`, FK `workspace_id → workspaces CASCADE`, `code_py`, `schedule_cron`, `shared`, `daily_budget_s` (budget CPU quotidien), `created_by → users` (sans FK déclarée) | mig. 26 (l.1174–1192) |
| `worker_runs` | Logs d'exécution des workers | FK `worker_id → workers CASCADE` ; `status`, `logs`, `duration_ms` | mig. 26 (l.1194–1210) |
| `webhook_subscriptions` | Abonnement sortant URL + event + secret (créée au boot hors migrations, appelée par `init_db` l.834–837) | `url`, `event`, `secret`, `active` | `app/services/webhook_outbound.py` l.272–286 |
| `webhook_deliveries` | Journal de livraison (retry ledger) | FK `webhook_id → webhook_subscriptions CASCADE` ; `status` (pending/retrying/success/error/superseded), `http_code`, `attempt`, `event`, `next_retry_at` (epoch, scheduler v6.4), `payload` (JSON), `duration_ms` | mig. 20 (l.913–928) + mig. 21 (l.940–957) |
| `idempotency_keys` | Support d'`Idempotency-Key` sur les POST de l'API v2 (réponse rejouée) | **PK `key TEXT`** ; FK `user_id → users CASCADE` ; `response_json`, `status_code` | mig. 20 (l.930–938) |
### 2.7 IA / Agent FlowDeck
| Table | But | Clés / colonnes clés | Emplacement |
|---|---|---|---|
| `agents` | Définition d'agent (personnel ou workspace) | FK `workspace_id → workspaces`, `created_by → users` ; `agent_type`, `system_instructions`, `model`, `scope_json`, `trigger_json`, `approval_mode` (auto/gated), `is_active`, `UNIQUE(workspace_id, name)` | db.py l.660–680 |
| `agent_conversations` | Conversation d'un utilisateur avec un agent | FK `agent_id → agents CASCADE`, `user_id → users` ; `status`, `context_json`, override `provider`/`model` (ALTER db.py l.820–827), `memory_enabled` (mig. 31) | db.py l.681–691 |
| `agent_messages` | Messages (rôles user/assistant/tool), coût tokens | FK `conversation_id → agent_conversations CASCADE` ; `tool_calls_json`, `model`, `tokens_used` | db.py l.692–702 |
| `agent_actions` | Journal des tool calls exécutés (audit + undo) | FK `conversation_id → … CASCADE` ; `tool_name`, `target_type/target_id`, `payload_json`, `result_json`, `status`, `undo_snapshot_json`, `executed_by → users` | db.py l.703–716 |
| `agent_skills` | Skills réutilisables (prompt + outils autorisés) | FK `workspace_id → workspaces`, `created_by → users` ; `prompt_template`, `allowed_tools_json`, `UNIQUE(workspace_id, name)` | db.py l.717–729 |
| `agent_triggers` | Déclencheurs d'agent (manual/cron/événement) | FK `agent_id → agents CASCADE` ; `trigger_type`, `config_json`, `is_active`, `last_fired_at` | db.py l.730–740 |
| `agent_feedback` | 👍/👎 sur les réponses (rating CHECK up/down) | FK `conversation_id`/`message_id`/`user_id` en `SET NULL` ; `snippet`, `comment` | db.py l.742–756 |
| `agent_policies` | Gouvernance par workspace (1 ligne/workspace) | FK `workspace_id → workspaces CASCADE`, `UNIQUE(workspace_id)` ; `allowed_tools_json`, `max_steps`, `require_approval` | mig. 28 (l.1326–1338) |
| `agent_approvals` | File d'approbation des actions d'écriture gated | `conversation_id` (INT **sans FK déclarée**), `tool`, `args_json`, `status` (pending/…), `requester_id`/`approver_id → users` | mig. 28 (l.1340–1354) |
| `agent_memory` | Résumé mémorisé par conversation (upsert 1:1, v7.54.0) | **PK `conversation_id → agent_conversations CASCADE`** ; `kind` (summary), `content` | mig. 31 (l.1661–1681) |
| `agent_connectors` | Catalogue de connecteurs d'agent (custom/Discord/Telegram/MCP, v7.55–7.57) | `name`, `url`, `secret_encrypted` (Fernet), `kind`, `auth` (bearer/…), `tools_json` (cache d'outils MCP), `enabled`, `status`, `created_by` (sans FK) | mig. 32 (l.1640–1659) + mig. 34 (l.1613–1624) |
| `connector_tokens` | Tokens OAuth par (kind, user) — Google / M365, chiffrés Fernet | **PK composite `(kind, user_id)`** ; `tokens_enc` | mig. 33 (l.1626–1638) |
| `plugins` | Registre des modules activables/désactivables de l'instance (web-tools, web-clipper, automations) — seed idempotent | **PK `slug TEXT`** ; `enabled` | mig. 35 (l.1589–1611) |
| `semantic_embeddings` | Recherche sémantique hybride + Ask AI (v6.9) : vecteurs « hashed-TF » 256 dims **sans dépendance externe** ; chunks par ressource polymorphe | **PK composite `(resource_type, resource_id, chunk_id)`** (page \| collection…) ; `chunk_text`, `embedding BLOB`, `model` | mig. 25 (l.1096–1133) |
| `semantic_index_state` | État d'indexation incrémentale (dernier index par ressource) | **PK composite `(resource_type, resource_id)`** ; `indexed_at` | mig. 25 (l.1122–1133) |
### 2.8 Sites publics & formulaires
(mig. 24, `migrations.py` l.1021–1093)
| Table | But | Clés / colonnes clés |
|---|---|---|
| `sites` | Mini-site public multi-pages (façon Notion Sites) | `slug UNIQUE`, FK `root_page_id → pages CASCADE`, `custom_domain UNIQUE`, `theme`, `password_hash`, `expires_at`, `noindex`, `analytics_id`, `created_by → users` |
| `site_pages` | Arbre public ordonné des pages du site | FK `site_id → sites CASCADE`, `page_id → pages CASCADE` ; `position`, `UNIQUE(site_id, page_id)` |
| `site_views` | Compteur de vues jour/site (upsert, **pas d'IP brute** — RGPD) | FK `site_id → sites CASCADE` ; PK composite `(site_id, day)`, `views` |
| `form_responses` | Journal anonymisé des soumissions de formulaire public | FK `collection_id → collections CASCADE`, `row_id → collection_pages SET NULL` (ligne créée) ; `ip_hash` (hash tournant quotidien, pas d'IP) ; config du form dans `collections.form_config_json` |
### 2.9 Calendrier & réunions
| Table | But | Clés / colonnes clés | Emplacement |
|---|---|---|---|
| `calendar_links` | Lien par utilisateur entre une collection et un calendrier externe (Google REST ou CalDAV générique) | FK `user_id → users CASCADE`, `collection_id → collections CASCADE` ; `provider`, `tokens_enc` (Fernet), `calendar_id`, `date_property`, `sync_token`, `UNIQUE(user_id, provider, calendar_id)` | mig. 27 (l.1231–1245) |
| `meeting_transcripts` | Note de réunion (audio + transcription + résumé IA), machine à états du bloc Notion-like (idle/recording/paused/processing/done/failed) | FK `page_id → pages CASCADE` ; `audio_path`, `transcript`, `summary`, `language` ; v7.1.1 : `status`, `mode`, `channels_json`, `instruction_snapshot`, `consent_json`, `segments_json`, `processing_steps_json`, `summary_json`, `generated_title`, `completeness_class`, `quality_score`, `duration_ms/paused_ms`, **`event_occurrence_id`** (index unique partiel : une occurrence calendrier = au plus une note) | mig. 27 (l.1247–1271) + mig. 38 (l.1730–1800) |
| `meeting_consent_log` | Journal append-only du consentement d'enregistrement | FK `transcript_id → meeting_transcripts CASCADE` ; `method` (start_attestation/…), `attested_by → users`, `participants_json` | mig. 38 (l.1767–1777) |
| `meeting_distributions` | Journal append-only des diffusions de compte-rendu (copy_link/…) | FK `transcript_id → meeting_transcripts CASCADE` ; `channel`, `actor_id → users`, `recipients_json`, `status` | mig. 38 (l.1779–1788) |
### 2.10 Import/export & offline (PWA, clipper)
| Table | But | Clés / colonnes clés | Emplacement |
|---|---|---|---|
| `import_jobs` | Job d'import asynchrone (polling UI) — Notion/CSV/markdown | **PK `id TEXT`** (uuid) ; `source`, `filename`, `status` (queued/running/done/error), `error`, `report_json` | mig. 9 (l.434–451) |
| `import_items` | Dédup : 1 ligne par page importée, clé (workspace, source, external_id) → re-import idempotent | FK `workspace_id`/`page_id` **sans REFERENCES déclaré** ; `UNIQUE(workspace_id, source, external_id)` | mig. 9 (l.418–432) |
| `offline_sync_queue` | File serveur des mutations reçues des clients hors-ligne (`/api/v2/sync/batch`), rejouables/auditées par device | FK `user_id → users CASCADE` ; `device_id`, `type` (page_create/update/delete/move, collection_*), `payload` (JSON), `client_timestamp`, `server_version`, `status` (pending/syncing/synced/failed), `retries`, `error` ; détection de conflit édit/édit via `sync_version` (mig. 17, triggers sur `pages`, `collection_pages`, `collections`) | mig. 16 (l.668–700) |
| `extension_devices` | Devices de l'extension Web Clipper (token hashé, scopes) | FK `user_id → users CASCADE` ; `device_id`, `token_hash`, `scopes`, `revoked`, `UNIQUE(user_id, extension_name, device_id)` | mig. 19 (l.838–857) |
| `extension_clips` | Clippings capturés (page cible + workspace cible) | FK `user_id → users CASCADE`, `target_page_id → pages SET NULL`, `target_workspace_id → workspaces SET NULL` ; `clip_type`, `source_url` | mig. 19 (l.861–877) |
### 2.11 API & audit
| Table | But | Clés / colonnes clés | Emplacement |
|---|---|---|---|
| `api_audit_log` | Traînée immuable des mutations de l'API publique v2 | FK `user_id → users SET NULL`, `token_id → api_tokens SET NULL` ; `action`, `resource_type`, `resource_id`, `ip_address`, `detail` | mig. 20 (l.895–911) |
| `schema_version` | Registre des migrations appliquées (meta) | **PK `version INTEGER`** ; `name`, `applied_at` | `app/migrations.py` l.41–51 |
### 2.12 Divers
`favorites`, `recents`, `notes`, `ai_keywords`, `login_history` déjà couverts ci-dessus. À noter également : les tables temporaires de reconstruction vues dans le code (`comments_new`, `tags_new`) **ne sont pas** des tables permanentes (pattern recreate → copy → drop → rename).
---
## 3. Relations clés (graphe FK simplifié)
```mermaid
graph TD
USERS[users]
WS[workspaces]
PAGES[pages]
COLS[collections]
CP[collection_pages]
CPV[collection_properties]
CV[collection_views]
WS -->|owner_id| USERS
WS -->|workspace_members| USERS
WS -->|workspace_id| PAGES
WS -->|workspace_id| COLS
PAGES -->|parent_id self| PAGES
PAGES -->|collection_id| COLS
PAGES -->|collection_row_id CASCADE| CP
CP -->|collection_id CASCADE| COLS
CP -->|parent_id self| CP
CPV -->|collection_id CASCADE| COLS
CPV -->|related_collection_id| COLS
CPV -->|relation/target_property_id self| CPV
CPV -->|button_automation_id| AUTO[automations]
CV -->|collection_id CASCADE| COLS
AUTO -->|collection_id texte sans FK| COLS
AGENTS[agents] -->|workspace_id| WS
CONV[agent_conversations] -->|agent_id CASCADE| AGENTS
CONV -->|user_id| USERS
MSG[agent_messages + actions] -->|conversation_id CASCADE| CONV
GRP[user_groups] -->|workspace_id CASCADE| WS
PPERM[page/collection/property_permissions] -->|user_id XOR group_id| USERS
PPERM -->|group_id| GRP
SITES[sites/site_pages] -->|root/page CASCADE| PAGES
TRANS[meeting_transcripts] -->|page_id CASCADE| PAGES
CAL[calendar_links] -->|user_id| USERS
CAL -->|collection_id CASCADE| COLS
CLIPS[extension_clips] -->|target_page_id| PAGES
```
Points de relation à retenir :
1. **`users` est le hub d'identité** : plus de 30 FK entrantes (sessions, tokens, permissions, audit, contenu). `workspaces` est le hub d'isolation multi-tenant (`owner_id`, `workspace_members` définit le rôle).
2. **Hiérarchie `pages ↔ collection_pages`** : deux univers de « pages ». `pages` = éditeur de blocs wiki (FK self `parent_id`, soft-delete `deleted_at`, corbeille) ; `collection_pages` = lignes de base. Le pont v6.5 : chaque ligne peut avoir une page-ombre `pages.collection_row_id` (CASCADE) qui porte son contenu blocs — versions, synced blocks et temps réel opèrent sur elle.
3. **Chaîne de cascades principales** : `collections → collection_pages/collection_views/collection_properties → …` et `pages → page_versions/page_shares/page_synced_blocks/page_permissions/guest_shares/text_reactions`. Les suppressions de ressource nettoyent l'éditorial ; les grants d'audit (`*_audit_log`, `api_audit_log`, `permission_audit_log`) privilégient `SET NULL` sur l'acteur pour survivre à la suppression du compte.
4. **Permissions en 3 niveaux** (mig. 18) : `pages/collections/collection_pages.permission_type` (`inherit`|`restricted`|`private`) pilote si les tables `*_permissions` (user **XOR** group via `CHECK`) font foi ; les groupes (`user_groups`/`group_members`) sont workspace-scopés ; `page_shares` reste le chemin de partage simple, `guest_shares` le chemin sans compte.
5. **Sync sémantique Gitea** : `boards/cards/col_mapping/checklists/property_values` et `collection_pages.gitea_issue_id` + `collections.gitea_owner/repo` relient les entités FlowDeck aux issues Gitea (jointures par ID numérique, **pas de FK** — système externe).
6. **Polymorphisme non-FK** : `notifications.resource_type/resource_id`, `semantic_embeddings.resource_type/resource_id`, `permission_audit_log.resource_type/resource_id`, `import_items.page_id`, `agent_approvals.conversation_id`, `automations.collection_id`, `custom_emojis.workspace_id`, `teamspaces`-id sur `pages`/`collections` (ALTER sans FK) — cohérence maintenue applicativement, pas par le moteur.
---
## 4. Colonnes JSON structurantes
Le modèle est fortement « document-oriented » : le JSON TEXT est le format de stockage des structures riches (choix assumé du SQLite sans JSON natif, requêtage via `json_extract` côté applicatif).
| Domaine | Table.colonne | Contenu |
|---|---|---|
| Kanban | `boards.columns_json` / `wip_limits_json` | liste ordonnée des colonnes ; limites WIP par colonne |
| Kanban/legacy | `project_properties.options_json` | options de select |
| Collections | `collections.schema_json` | schéma complet des propriétés d'une base (déclaratif, dupliqué avec `collection_properties`) |
| Collections | `collections.form_config_json` | formulaire public (champs, validations) |
| Lignes | `collection_pages.property_values_json` | map `{property_name → valeur}` de la ligne |
| Vues | `collection_views.config_json` | **config de vue** : filtres, sorts, groupements, colonnes visibles/cache, sizing — cœur du système de vues multiples |
| Propriétés | `collection_properties.options_json` / `validation_json` | options select, règles de validation |
| Dashboard | `collection_dashboards.layout_json` | colonnes + widgets |
| Modèles | `database_templates.schema_json`, `page_templates.property_values_json` / `content_json` | blueprint de base / ligne |
| Éditeur | `pages.content` (avec `content_format='blocks'`) | **tableau JSON des blocs** (heading, todo, callout, synced_block, meeting, etc.) — l'unité de contenu fondamentale |
| Historique | `page_versions.blocks_json`, `page_history.snapshot_json`, `page_global_templates.blocks_json`, `synced_blocks.content` | snapshots de liste de blocs |
| Workspace | `workspaces.settings_json`, `users.sidebar_config`, `users.notification_prefs`, `users.totp_backup_hashes` | préférences |
| Agent | `agents.scope_json`/`trigger_json`, `agent_conversations.context_json`, `agent_messages.tool_calls_json`, `agent_actions.payload_json`/`result_json`/`undo_snapshot_json`, `agent_skills.allowed_tools_json`, `agent_triggers.config_json`, `agent_policies.allowed_tools_json`, `agent_approvals.args_json` | orchestration, audit et undo |
| LLM | `user_llm_keys.models_json` | cache des modèles distants |
| Automatisation | `automations.condition_json`/`actions_json`, `automation_steps.config_json` | DSL des règles (fallback steps ↔ legacy colonnes) |
| Sync offline | `offline_sync_queue.payload`, `idempotency_keys.response_json` | mutation client + réponse rejouée |
| Webhooks | `webhook_deliveries.payload` | corps livré |
| Réunions | `meeting_transcripts.channels_json`/`consent_json`/`segments_json`/`processing_steps_json`/`summary_json`, `meeting_consent_log.participants_json`, `meeting_distributions.recipients_json` | pipeline transcription |
| Imports | `import_jobs.report_json` | rapport par fichier |
| SSO | `sso_config.attribute_mapping`, `groups_mapping`, `sso_login_history` (mapping) | attributs d'assertion |
---
## 5. Couche Pydantic (`app/models/`)
Les modèles Pydantic ne couvrent **qu'une petite partie** du contrat API — la majorité des routes FastAPI valide en dur dans les handlers :
- `app/models/requests.py` (75 l.) :
- `FileSaveRequest` — sauvegarde de fichier via Gitea (`path`, `content`, `message`, `sha` optionnel pour update) ; `@model_validator(mode="after")` restreint l'extension via `ALLOWED_EXTENSIONS`/`_ext` (`app/middleware/security`) ;
- `IssueCreateRequest` / `IssueUpdateRequest` — création/mutation d'issues Gitea (title ≤ 500, `state` pattern `^(open|closed)$`, labels/milestone/assignee en ids virgule) ;
- `CardMoveRequest` — déplacement de carte Kanban (owner/repo/issue_id/column) ;
- `ColMappingRequest` — mapping colonne ↔ label ;
- `UploadValidationResult` — DTO de validation d'upload (filename/size/extension/valid/error).
- `app/models/responses.py` (38 l.) : deux enveloppes standardisées, utilisées en `response_model` OpenAPI des routes publiques :
- `ErrorResponse {error, detail?}` ;
- `SuccessResponse {status="ok", data?: dict}`.
Aucun modèle ORM (pas de SQLAlchemy) : l'accès aux données est du **SQL brut `sqlite3`** via le context-manager `get_conn()` (`app/db.py` l.843–858, `row_factory = sqlite3.Row`), et les conversions dict↔JSON des colonnes `*_json` sont faites manuellement dans les services.
---
## 6. Fiches de synthèse technique
| Aspect | Valeur réelle (vérifiée dans le code) |
|---|---|
| Moteur | SQLite unique, fichier `settings.db_path` (`database_url` = `sqlite:///…`), WAL + `busy_timeout=5000`, `foreign_keys=ON` |
| ORM | aucun — SQL brut |
| Pilote | `sqlite3` stdlib |
| Migrations | maison, versionnées : baseline v1 (`init_db` idempotent) + registry `@register` (`app/migrations.py`), table `schema_version`, transaction par migration |
| Version de schéma | 38 |
| Triggers | FTS5 (`pages_fts_ai/ad/au`), bumps `sync_version` (`*_sync_version_bu`) sur `pages`/`collection_pages`/`collections` |
| Full-text | FTS5 optionnel, repli `LIKE` |
| Recherche vectorielle | `semantic_embeddings` (hash TF 256-dim en BLOB, PK composite polymorphe, pas d'extension vec0) |
| Chiffrement au repos | Fernet sur `calendar_links.tokens_enc`, `connector_tokens.tokens_enc`, secrets SSO ; hash sha256 pour `api_tokens.token_hash`, `scim_tokens.token_hash`, `extension_devices.token_hash` ; `password_hash` (users) |
| Sauvegardes | copie planifiée du fichier SQLite (`backup_*`, `app/config.py` l.66–70) |
| Concurrence | verrou applicatif d'écriture indirect via WAL + busy_timeout ; migration async du 510 call sites synchrones encore en cours (note A21, `app/db.py` l.848–852) |
+12 -3
View File
@@ -1,8 +1,8 @@
# V7.1.0 — Calendar sync + Meeting Notes
# V7.1.0 — Calendar sync + Meeting Notes · V7.1.1 — Bloc façon Notion
> **Statut** : ✅ Livré en v7.1.0 (2026-09-28) — `services/calendar_sync.py`, `services/meetings.py`, `routers/meetings.py`, migration 27, 15 tests verts · **Roadmap** : `ROADMAP.md § v7.1.0`
> **Statut** : ✅ Livré en v7.1.0 (2026-09-28) — `services/calendar_sync.py`, `services/meetings.py`, `routers/meetings.py`, migration 27, 15 tests verts · **Refonte Notion livrée en v7.1.1** (migration 38, `static/js/meeting_block.js`, `static/css/meeting.css`, 5 tests verts `tests/test_v711_meeting_notion.py`) · **Roadmap** : `ROADMAP.md § v7.1.0`
> Notes d'implémentation : pas de lib CalDAV/Google (REST + XML bruts) ; refresh OAuth hors scope (relink explicite sur 401) ; conflit = LWW auto + notif (pas de picker « garder ma version ») ; free/busy au jour (pas de créneaux horaires).
> **Référence Notion** : Notion Calendar (Google sync) + AI Meeting Notes → trigger Custom Agents (07/2026).
> **Référence Notion** : Notion Calendar (Google sync) + AI Meeting Notes → trigger Custom Agents (07/2026). Spec détaillée : `docs/architecture-meeting-notion-flowdeck.md` (§3A parcours visuel des 6 états, §3B maquettes ASCII, §6.5 machine à états, §12.7 étapes Thinking, §12.8 complétude + titre).
> **Existant** : vues jour/semaine/mois, moteur `recurrence.py`, `reminders.py` (scan 60s, dédup), timezones, `notify_assignment()`, template Meeting Notes.
---
@@ -41,3 +41,12 @@ Upload `.mp3/.wav/.m4a` (100 MB) sur page Meeting → `meeting_transcripts` →
## 3. Tests (`tests/test_v71_calendar_meetings.py`, ~15)
OAuth mock (tokens chiffrés roundtrip), pull/push, conflit LWW + notif, freebusy, transcription mock → résumé blocs, trigger émis, quota 100 MB refusé.
## 4. V7.1.1 — Refonte du bloc façon Notion (§3A/3B de `architecture-meeting-notion-flowdeck.md`)
- **Bloc à états** (`static/js/meeting_block.js` surcharge `window.FDMeeting`, styles `static/css/meeting.css`) : `idle → recording → paused → processing → done/failed`, révélation progressive `Notes → Notes+Transcript → Summary+Notes+Transcript` (Summary en tête, actif par défaut après Stop), bouton Start à menu (source/mode, langue), onde en direct + chrono, pastille Thinking, barre `Share this summary` (Copy link / Email / Slack tracés), pied instructions + attestation de consentement puis bandeau. Le titre généré par l'IA ne touche jamais le titre de la page hôte. Anciens blocs migrés à la volée.
- **Capture Web 2 modes** : A micro (`getUserMedia`), B onglet + micro (`getDisplayMedia` + micro, canaux séparés, alerte « je n'entends que vous » après 20 s de canal distant muet) ; STT navigateur quand dispo, transcript manuel + import audio en secours, tampon IndexedDB avec reprise après rechargement (recording → paused, processing → suivi repris).
- **Backend** (migration 38) : colonnes état/mode/instructions photo/consent/segments/steps/résumé structuré/titre/complétude + tables append-only `meeting_consent_log`, `meeting_distributions` + unicité une occurrence = une note. Endpoints : `POST /transcripts` (création idempotente), `/consents`, `/sessions`, `/pause`, `/resume`, `/stop` (analyse inline), `/segments`, `/transcript` (groupé par source), `/processing-steps`, `/processing:retry`, `/generated-title` (PATCH), `/distributions`, `/notes` (PATCH), `GET /meetings/history` + `/meetings/instructions`.
- **Pipeline IA** (`services/meetings.py`) : étapes Thinking persistées (lecture → analyse → résolution des références → compréhension → complétude → résumé → titre → citations), résumé contraint Overview/Décisions/Actions/Questions avec citations `¹ [seg N]` obligatoires et états vides explicites, classification `complete/brief/incomplete/fragmentary` (jamais de refus : résumé dégradé + titre-diagnostic `Brief or Incomplete Meeting Recording`), audit via `meeting.summarized` inchangé.
- **Hub** (`base.html`) : onglets Upcoming / Today / Past, recherche plein texte, CTA contextuels (Join & transcribe / Prepare / View summary).
- **Tests** : `tests/test_v711_meeting_notion.py` (5) — porte de consentement, machine à états complète avec résumé dégradé + titre-diagnostic, résumé structuré complet, unicité par occurrence, instructions + historique.
+22
View File
@@ -223,6 +223,28 @@ Chaque phase se clôt par : `ruff` + suite verte, bump de version, CHANGELOG, RO
`initialize` / `tools/list` / `tools/call` des serveurs streamables courants.
Upgrade : client SSE persistant si un serveur ne répond qu'en flux.
## 9ter. Avenant phase 8 (v7.58.0) — décisions livrées
- **Garde de route = dépendance posée à l'`include_router`** (`main.py`), pas
de décorateur par route ni de middleware : 3 lignes, aucun router modifié.
⚠️ l'annotation `request: Request` doit être importée **au module** : avec
`from __future__ import annotations` les annotations sont des chaînes et
FastAPI les résout dans les globales du module — sinon il lit un query param
→ 422 (piège réel, corrigé en cours de phase).
- **Effet réel aux 4 niveaux** : routes (dépendance), arrière-plan (garde de
tick du scheduler), outils (`ToolRegistry._all()`), UI (rendu serveur).
- **UI : `{% if %}` pour la nav, `x-show` pour les sections** — la nav est
**absente du DOM** (test « UI absente » au sens propre) ; les grosses
sections ne sont pas restructurées, leur booléen est rendu au serveur.
- **Handler unifié des 404** (comportement historique conservé) : hors `/api`
une route refusée **redirige 302 → /workspaces**, `/api*` répond 404 JSON —
les tests assertent les deux formes au lieu de réécrire le handler.
- **SQLite** : `INSERT … ON CONFLICT` évalue NOT NULL **avant** l'upsert →
fournir toutes les colonnes NOT NULL, même pour un simple changement d'état.
- `ponytail:` plafond assumé — pas de classe-adapter par plugin ni de
« marketplace » : ajouter un plugin = 1 tuple dans `CATALOG` (+ 1 garde si
son effet n'est pas déjà couvert).
## 10. Hors périmètre
- Écriture dans les sources distantes (Google Docs, Slack…) — lecture seule au départ.
+366
View File
@@ -0,0 +1,366 @@
# Agents & Skills pour Flowdeck — Phase 1 : Le Skill devient une page
| Champ | Valeur |
|---|---|
| Phase | **1 sur 5** — fondation de toute la solution |
| Document parent | `architecture-agents-skills-notion-flowdeck.md` v1.1 — en particulier §3.4 (les Skills dans Notion), §4.2 (écarts E1, E3, E4), §10.3–10.4, §13.1, §13.4–13.6 |
| Version | 1.0 — 9 octobre 2026 |
| Auteur | Spark, pour Bruno |
| Statut | Prêt à implémenter |
| Prérequis | Flowdeck v7.69.8 ; migration courante = 38 |
| Migration créée | **39** |
| Dépendances | Aucune (première phase) |
| Débloque | Phases 2 (routeur de skills), 3 (skills des Custom Agents), 5 (Skills API) |
> **Résultat visible à la fin de cette phase.** N'importe quelle page Flowdeck peut devenir un skill ; une collection peut devenir une base de skills avec `Description` / `Files` / `Tags` ; la Library a un onglet *Skills* et un onglet *Discover* avec `Enable for me` ; les skills s'exécutent depuis le chat (`/`), le menu de sélection, le menu slash et le menu de bloc via un moteur unique (le Skill Runner) ; les skills intégrés d'AI Writing sont exposés comme skills ; chaque skill s'exporte en `SKILL.md` téléchargeable pour les agents locaux. Aucun skill existant n'est perdu : les enregistrements actuels et les 17 presets deviennent des pages.
---
## Table des matières
1. [Objectif et principe directeur](#1-objectif-et-principe-directeur)
2. [Périmètre](#2-périmètre)
3. [État d'entrée — ce qui existe en v7.69.8](#3-état-dentrée--ce-qui-existe-en-v7698)
4. [Modèle de données — migration 39](#4-modèle-de-données--migration-39)
5. [Tickets de travail](#5-tickets-de-travail)
6. [API livrée par la phase](#6-api-livrée-par-la-phase)
7. [Comportements détaillés](#7-comportements-détaillés)
8. [Plan de migration des données existantes](#8-plan-de-migration-des-données-existantes)
9. [Tests](#9-tests)
10. [Critères d'acceptation](#10-critères-dacceptation)
11. [Risques spécifiques et vigilance](#11-risques-spécifiques-et-vigilance)
12. [Ordre d'exécution](#12-ordre-dexécution)
13. [Définition de « terminé »](#13-définition-de--terminé-)
---
## 1. Objectif et principe directeur
Le principe (ADR-01 du document parent) : **la page est la source de vérité du skill ; la table `agent_skills` devient son index d'exécution.** Toute l'économie de la phase vient de ce que Flowdeck n'a pas à construire pour un skill ce qu'il a déjà pour une page : éditeur de blocs, ACL (partage, groupes, héritage), historique `page_versions`, corbeille, recherche FTS5 et sémantique, commentaires, Library.
Corollaires qui guident chaque ticket :
- **Marquer une page ne la déplace pas et ne la copie pas.** C'est l'insertion d'une ligne d'index. Démarquer supprime la ligne, la page ne bouge pas.
- **Un skill de base est une ligne de collection comme les autres** : son contenu est la page-ombre (`pages.collection_row_id`), ses métadonnées (`Description`, `Files`) sont des propriétés de la ligne.
- **Aucun nouvel éditeur, aucun nouveau modèle de permissions.** Si un comportement de skill ne peut pas s'exprimer avec les mécanismes de page/collection existants, c'est un signal d'arrêt et de redesign, pas une raison d'ajouter un sous-système.
## 2. Périmètre
### 2.1 Inclus
- Migration 39 (refonte `agent_skills`, `collections.is_skills_db`, `user_skill_enablements`, `skill_runs`, `skill_local_downloads`).
- Marquer/démarquer une page comme skill ; déplacer une page dans une base de skills.
- Créer une base de skills (gabarit) ; convertir une collection existante en base de skills (correspondance des propriétés).
- Bannière de skill sur la page (états, interrupteurs, téléchargement, badge de copie périmée).
- Library : onglet *Skills* et sous-vue *Discover*, `Enable for me`, désactivation pour soi.
- Skill Runner : chargement d'un skill (page + fichiers), construction de la consigne, exécution manuelle depuis 4 surfaces (chat `/`, sélection, slash, bloc), journalisation dans `skill_runs`.
- Skills intégrés : améliorer l'écriture, corriger, expliquer, reformater, traduire, résumer.
- Création assistée d'un skill par l'Agent (dialogue → création de la page).
- Export `SKILL.md` (+ fichiers partageables) et import `SKILL.md` ; format `flowdeck-skill` v1 conservé.
- Migration des skills existants et des 17 presets en pages.
### 2.2 Exclu (phases ultérieures)
- L'**usage automatique** des skills par l'Agent (routage sur la `Description`) → Phase 2. La `Description` est collectée et indexée dès cette phase, mais rien ne l'exploite encore automatiquement.
- L'usage des skills par les **Custom Agents** (accès accordés, runs autonomes) → Phase 3.
- La **comptabilité** d'usage détaillée par run (`agent_runs`, crédits) → Phases 2 et 4. Ici, `skill_runs` journalise avec les références disponibles (conversation, surface, utilisateur) ; la colonne `run_id` définitive arrive en Phase 2 — voir §4.3 pour la parade.
- L'écriture automatique dans les répertoires des agents locaux (utilitaire compagnon) → question ouverte Q4 du document parent ; cette phase livre le **téléchargement** du bundle et le suivi d'empreinte.
## 3. État d'entrée — ce qui existe en v7.69.8
| Élément | État | Fichier / mécanisme (architecture v7.69.8, §16.3) |
|---|---|---|
| Skills actuels | Prompts paramétrés + outils autorisés, table `agent_skills` | `app/services/skill_gallery.py` |
| Galerie | 17 presets installables | `skill_gallery.py`, routes `/api/agent/skills` |
| Format portable | `flowdeck-skill` v1, export/import JSON | idem + `/api/v2/skills/*` |
| Points d'entrée éditeur | Menu contextuel de bloc → *Skills ›* ; toolbar de sélection → *Skills* (§13.3) ; section Compétences du Menu + (gérer/parcourir) | `page_editor_scripts.js`, `agent_panel_*.js` |
| AI Writing | 6 actions headless (écrire, résumer, traduire, continuer, autocomplétion, propriétés) | `app/services/ai_writing.py` |
| Page-ombre de ligne | Chaque ligne de collection peut porter une page de contenu | `pages.collection_row_id` (v6.5) |
| Indexation sémantique | Embeddings maison, index incrémental, `resource_type` libre | `app/services/semantic_search.py` |
## 4. Modèle de données — migration 39
### 4.1 Schéma (repris du document parent §10.3–10.4, reséquencé)
```sql
-- Migration 39 — Phase 1
ALTER TABLE collections ADD COLUMN is_skills_db INTEGER NOT NULL DEFAULT 0;
-- Correspondance des propriétés dans collections.schema_json :
-- {"skill_properties": {"description": "<nom prop>", "files": "<nom prop>", "tags": "<nom prop>"}}
-- agent_skills est RECRÉÉE (patron : nouvelle table, copie, bascule, §8)
CREATE TABLE agent_skills_new (
id INTEGER PRIMARY KEY,
page_id INTEGER NOT NULL UNIQUE REFERENCES pages(id) ON DELETE CASCADE,
collection_id INTEGER REFERENCES collections(id),
name_cached TEXT NOT NULL,
description TEXT DEFAULT '',
files_json TEXT DEFAULT '[]',
tools_json TEXT DEFAULT '[]',
is_builtin INTEGER NOT NULL DEFAULT 0,
auto_use_default INTEGER NOT NULL DEFAULT 1,
editor_menu_default INTEGER NOT NULL DEFAULT 0,
owner_id INTEGER REFERENCES users(id),
status TEXT DEFAULT 'ready',
source TEXT DEFAULT 'page', -- 'page' | 'legacy' | 'import' | 'builtin'
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE user_skill_enablements (
user_id INTEGER NOT NULL REFERENCES users(id),
skill_id INTEGER NOT NULL REFERENCES agent_skills_new(id) ON DELETE CASCADE,
enabled INTEGER NOT NULL DEFAULT 1,
auto_use INTEGER, editor_menu INTEGER,
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, skill_id)
);
CREATE TABLE skill_runs (
id INTEGER PRIMARY KEY,
skill_id INTEGER NOT NULL REFERENCES agent_skills_new(id),
run_id INTEGER, -- nullable en Phase 1, rattaché en Phase 2 (§4.3)
conversation_id INTEGER REFERENCES agent_conversations(id),
user_id INTEGER REFERENCES users(id),
surface TEXT NOT NULL, -- 'chat' | 'editor' | 'agent' | 'api'
invocation TEXT NOT NULL, -- 'manual' en Phase 1 ('auto' dès la Phase 2)
created_at TEXT NOT NULL
);
CREATE TABLE skill_local_downloads (
user_id INTEGER NOT NULL REFERENCES users(id),
skill_id INTEGER NOT NULL REFERENCES agent_skills_new(id) ON DELETE CASCADE,
target TEXT NOT NULL, -- 'claude-code'|'codex'|'cursor'|'gemini'|'grok'|'file'
page_version TEXT NOT NULL, -- empreinte au téléchargement
downloaded_at TEXT NOT NULL,
PRIMARY KEY (user_id, skill_id, target)
);
```
### 4.2 Règles du schéma
1. `page_id UNIQUE` : un skill = une page, sans exception. Le marquage est idempotent (un second marquage renvoie le skill existant).
2. `ON DELETE CASCADE` depuis `pages` : mettre la page à la corbeille **purge dure** ou la suppression effective retire le skill partout (menus, Library) — comportement attendu, testé (T-19).
3. Pour un skill de base, `description` et `files_json` sont des **caches relus** depuis les propriétés de la ligne à chaque sauvegarde de la ligne ou de sa page-ombre ; la ligne de collection fait foi.
4. Pour un skill autonome, `description`/`files_json` sont édités depuis la bannière et stockés ici.
5. `tools_json` hérite du modèle actuel : un skill peut **restreindre** les outils d'un run, jamais les élargir (intersection appliquée par le moteur, §7.4).
### 4.3 La parade `run_id`
`agent_runs` n'existe qu'en Phase 2. Pour ne pas bloquer la Phase 1 : `skill_runs.run_id` est nullable, `conversation_id` + horodatage portent la traçabilité immédiate ; la Phase 2 ajoute la contrainte de rattachement par une migration de données (et non de schéma). Ne pas créer de table `runs` temporaire.
### 4.4 Indexation
À la création/modification d'un skill : écrire ou mettre à jour les lignes `semantic_embeddings` avec `resource_type='skill'`, contenu = `name + description` (le titre seul ne suffit pas à l'appariement futur). Le scheduler d'indexation existant (300 s) est le filet ; l'écriture directe à la sauvegarde donne la fraîcheur nécessaire aux menus.
## 5. Tickets de travail
*Convention : chaque ticket est indépendamment testable ; « Fichiers » donne les points d'ancrage d'après l'architecture v7.69.8 — à confirmer contre le code réel au démarrage du ticket.*
### Socle données
**P1-T01 — Migration 39 et recréation d'`agent_skills`.**
Fichiers : `app/migrations.py` (`@register(39, ...)`), `app/db.py` si la baseline doit refléter le nouvel état pour les installations neuves.
Travail : créer le schéma du §4 ; la copie des données existantes est dans le même ticket que le script de migration de contenu (P1-T02), exécuté comme étape de la migration (données) — la migration reste une transaction unique.
Acceptation : sur une base de test v7.69.8 complète, `apply_migrations()` passe à la version 39 sans perte (comptages avant/après identiques pour les skills convertis, §8).
**P1-T02 — Migration de contenu : skills existants → pages.**
Travail : pour chaque ancien skill sans page : créer une page (workspace de l'utilisateur, privée par défaut, dans une base de skills privée créée à la demande — voir P1-T04), y écrire le prompt d'origine **à l'identique** comme corps de page, créer la ligne `agent_skills` avec `source='legacy'`, conserver `tools_json`. Les presets de la galerie ne sont **pas** matérialisés pour tous : ils le sont à l'installation (P1-T13).
Acceptation : chaque ancien skill reste invocable après migration (test de non-régression sur le jeu de skills de la base de test) ; aucun prompt modifié (comparaison de hash avant/après).
**P1-T03 — Service `skills_pages.py` : le cycle de vie page ↔ skill.**
Fichiers : nouveau `app/services/skills_pages.py` ; évolution de `skill_gallery.py` (qui devient un consommateur du service, pas le propriétaire du modèle).
Travail : `mark_as_skill(page_id, user)`, `unmark(page_id)`, `get_skill_by_page(page_id)`, `sync_skill_from_row(collection_page)` (recopie Description/Files depuis les propriétés), `sync_skill_from_page(page)` (titre, cache), suppression en cascade déjà portée par le schéma. Tous les appels vérifient l'ACL de la page via `PermissionManager` (marquer exige l'édition de la page).
Acceptation : les cinq opérations sont couvertes en tests unitaires, y compris les refus d'ACL.
### Bases de skills
**P1-T04 — Base de skills privée par défaut.**
Travail : à la première création d'un skill par un utilisateur sans base, créer sa collection `is_skills_db=1` privée (« Mes skills »), avec les propriétés `Description` (text), `Files` (files), `Tags` (multi_select) créées dans le schéma de la collection. Idempotent par utilisateur.
Acceptation : deux créations successives de skills par le même utilisateur n'ont créé qu'une base.
**P1-T05 — Gabarit « Skills » et conversion d'une collection.**
Fichiers : gabarits de bases (`db_templates`/seed existant), route `PUT /db/{id}/skills-db` (§6).
Travail : création directe d'une base de type Skills (mêmes propriétés qu'en T04 + vue par défaut triée par nom) ; dialogue de conversion : mapper des propriétés existantes vers `Description`/`Files`/`Tags` ou les créer ; option « activer les pages existantes comme skills pour moi » ; la conversion pose `is_skills_db=1` et crée les lignes `agent_skills` des lignes de la base (page-ombre requise : la créer si absente).
Acceptation : convertir la base de test « Playbooks » transforme chaque ligne en skill visible dans la Library ; la déconversion (`is_skills_db=0`) retire les lignes d'index sans toucher aux pages (règle du §18 du document parent).
**P1-T06 — Marquage depuis une page ordinaire.**
Fichiers : menu `•••` de page (gabarits de l'éditeur), route `POST /api/agent/pages/{id}/skill`.
Travail : entrée *Use as a skill* (case à cocher, état relu) ; si la page n'est dans aucune base de skills, le skill est autonome (description éditée via la bannière, T07) ; proposer en un clic « déplacer dans une base de skills » (réutilise le *Move to* existant + création de la ligne dans la base cible).
Acceptation : marquer, démarquer, re-marquer la même page donne un seul skill à chaque état stable.
### Expérience du skill
**P1-T07 — Bannière de skill.**
Fichiers : fragment de l'éditeur de page (au-dessus du corps, comme les bannières existantes), JS de l'éditeur.
Contenu : badge « Skill », base d'appartenance (lien), état d'activation pour l'utilisateur, interrupteurs *Use automatically* (défaut du skill) et *Add to text editor menu*, champ `Description` (skill autonome) ou lien vers la propriété (skill de base), bouton *Download for local agents*, badge orange « copie locale périmée » si une ligne `skill_local_downloads` de l'utilisateur est en retard d'empreinte.
Acceptation : la bannière n'apparaît que sur les pages-skills ; ses interrupteurs écrivent les bonnes colonnes (défaut du skill vs choix de l'utilisateur — ne pas confondre, §7.2).
**P1-T08 — Library : onglets Skills et Discover.**
Fichiers : `app/routers/library.py`, `app/templates/library.html`, `static/js/library.js`.
Travail : onglet *Skills* (skills créés + activés ; colonnes du §7.5 du parent : Nom, Base, Description, Use automatically, Menu éditeur, Propriétaire, Dernière utilisation depuis `skill_runs`) ; sous-vue *Discover* (skills lisibles non activés) avec bouton `Enable for me` par ligne ; recherche par nom/description ; les interrupteurs de l'onglet écrivent `user_skill_enablements`.
Acceptation : un skill partagé avec moi apparaît dans Discover, pas dans Skills ; après `Enable for me`, il bascule ; le désactiver le retire de mes menus sans affecter les autres utilisateurs (test multi-utilisateurs).
**P1-T09 — Le Skill Runner.**
Fichiers : nouveau `app/services/skill_runner.py`.
Travail : `load_skill(skill_id, user)` → {corps Markdown de la page (blocs éligibles sérialisés comme le fait le Context Builder), fichiers extraits (texte des fichiers `shareable`, plafonnés par budget), tools_json} ; `run(skill_id, input, surface, user, conversation_id)` → construit la consigne (page du skill en couche « consigne de tâche »), appelle le moteur existant en run contraint (outils = intersection), écrit `skill_runs`, renvoie la sortie structurée {texte, blocs, skill_name}. Mode LLM `offline` : sortie déterministe de test.
Acceptation : un run de skill produit une ligne `skill_runs` complète ; le nom du skill est présent dans la réponse rendue (préparation de l'invariant I5).
**P1-T10 — Invocation dans le chat : menu `/`.**
Fichiers : `agent_panel_*.js`, route `GET /api/agent/skills/menu`, compositeur du panneau.
Travail : taper `/` ouvre la liste (skills activés de l'utilisateur, recherche par nom) ; `/nom` filtre directement ; `+` lance la création assistée (T14) ; sélectionner un skill l'attache au prochain message (pastille retirable) et le run passe par le Runner (T09).
Acceptation : `/` sans skill activé affiche les intégrés + l'entrée de création ; un skill forcé par `/` n'est jamais remplacé par un autre (le routeur automatique n'existe pas encore, mais le contrat est posé).
**P1-T11 — Invocation dans l'éditeur : sélection, slash, bloc.**
Fichiers : `page_editor_scripts.js` (les trois points d'entrée existent — les rebrancher sur le Runner), registre des types de blocs.
Travail : déclarer `skill_eligible` par type de bloc (liste du document parent §13.4) ; menu de sélection : skills avec `editor_menu` effectif d'abord, intégrés ensuite, entrée *Manage Skills* ; menu de bloc → *Skills* (bloc + contenu imbriqué) ; slash `/skill` uniquement sur ligne non vide ; résultat en **aperçu diff** pour les transformations (accepter/refuser/réessayer), insertion sous le bloc pour les générations.
Acceptation : les trois gestes produisent le même résultat à entrée égale ; un bloc non éligible ou vide n'offre pas de skills.
**P1-T12 — Skills intégrés sur AI Writing.**
Fichiers : `app/services/ai_writing.py`, seed des skills intégrés.
Travail : créer les 6 pages-skills intégrées (`is_builtin=1`, `source='builtin'`, workspace système ou seed par utilisateur — décision : seed **par workspace**, page système non éditable, désactivable par utilisateur via les enablements) : Améliorer l'écriture, Corriger, Expliquer, Reformater, Traduire, Résumer — chacune appelle le service AI Writing existant via le Runner plutôt que de dupliquer les prompts ; l'autocomplétion inline reste sur le service direct (latence).
Acceptation : le menu de sélection d'un nouvel utilisateur affiche exactement les 6 intégrés, dans l'ordre ; désactiver « Corriger » le retire pour cet utilisateur seul.
### Portabilité
**P1-T13 — Galerie rebranchée sur les pages.**
Travail : installer un preset = créer la page-skill correspondante dans la base privée de l'utilisateur (corps = le contenu du preset, `Description` rédigée pour un futur appariement — relire les 17 descriptions pendant ce ticket) + enablement activé. La galerie existante devient une vue de presets-pages ; l'ancien format d'installation est abandonné après migration (T02).
Acceptation : réinstaller les 17 presets sur un compte de test donne 17 pages éditables ; modifier la page modifie le comportement du skill au run suivant.
**P1-T14 — Création assistée par l'Agent.**
Travail : depuis le chat (`/` → `+` ou « crée un skill qui… »), un run guidé de l'Agent personnel pose les questions minimales (travail, entrées, règles, exemples, sortie) puis appelle un outil interne de création de page-skill dans la base privée ; l'utilisateur est amené sur la page créée pour relire.
Acceptation : le parcours complet produit un skill invocable sans quitter le chat autrement que pour la relecture.
**P1-T15 — Export `SKILL.md` et suivi de copie locale.**
Fichiers : `app/services/skill_export.py` (nouveau), route `GET /api/agent/skills/{id}/download`.
Travail : sérialisation Markdown (frontmatter `name`/`description` + corps de page) ; bundle ZIP = `SKILL.md` + fichiers `shareable=1` ; écriture d'une ligne `skill_local_downloads` (target `file` par défaut, cible nommée si choisie) avec l'empreinte = hash du contenu sérialisé + `updated_at` de la page ; l'empreinte courante est recalculée à la lecture pour le badge (T07).
Acceptation : le `SKILL.md` exporté est relu par le test d'import (T16) sans perte de nom/description/corps.
**P1-T16 — Import `SKILL.md` et compatibilité v1.**
Travail : `POST /api/agent/skills/import` accepte un `SKILL.md` (frontmatter analysé) ou un bundle, crée une page-skill (`source='import'`) dans la base privée, range les fichiers joints en propriété `Files` ; le format `flowdeck-skill` v1 reste accepté et produit le même résultat (page + ligne d'index, `tools_json` conservé).
Acceptation : les trois sources (page existante, `SKILL.md` externe, JSON v1) convergent vers le même objet skill.
### Finitions
**P1-T17 — API v2 des skills alignée.**
Travail : `GET /api/v2/skills` et `/{id}` exposent `page_id`, `description`, `collection_id` ; négociation de contenu : `Accept: text/markdown` renvoie la sérialisation `SKILL.md` ; le JSON v1 reste le défaut (compatibilité des clients existants).
Acceptation : les tests API v2 actuels passent inchangés ; un test nouveau couvre la variante Markdown.
**P1-T18 — Documentation in-app et aide.**
Travail : entrée d'aide du produit (section Skills : page, base, Library, Discover, agents locaux), textes de la bannière et des dialogues en français, noms d'objets en anglais conformément au document parent.
Acceptation : revue des libellés (pas de « compétence » imposé dans l'UI là où le produit dit *Skill*).
## 6. API livrée par la phase
| Route | Ticket |
|---|---|
| `POST /api/agent/pages/{id}/skill` · `DELETE …` | T06 |
| `PUT /db/{id}/skills-db` (conversion) | T05 |
| `GET /api/agent/skills/menu` | T10 |
| `PUT /api/agent/skills/{id}/enablement` | T08 |
| `POST /api/agent/skills/{id}/run-chat` · `POST …/run-editor` | T09–T11 |
| `GET /api/agent/skills/{id}/download` | T15 |
| `POST /api/agent/skills/import` | T16 |
| `GET /api/v2/skills` · `GET /api/v2/skills/{id}` (étendus) | T17 |
Toutes les routes internes suivent les conventions existantes : cookie-auth, CSRF complet, contrôle d'ACL dans le routeur, erreurs au format du produit.
## 7. Comportements détaillés
### 7.1 Qui peut faire quoi
| Action | Droit requis |
|---|---|
| Marquer une page | Éditer la page |
| Éditer un skill | Éditer sa page (rien de plus, rien de moins) |
| Activer un skill pour soi | Lire sa page |
| Exécuter un skill | Lire sa page + être activé (ou être son créateur) |
| Convertir une base | Admin/owner de la collection |
| Désactiver le skill d'un autre | Impossible : la désactivation est toujours « pour soi » ; retirer pour tous = supprimer la page ou la démarquer par un éditeur |
### 7.2 Défaut du skill vs choix de l'utilisateur
Deux niveaux, à ne jamais écraser l'un par l'autre : les colonnes `*_default` d'`agent_skills` (posées par le propriétaire) et les surcharges nullable de `user_skill_enablements` (posées par chaque utilisateur). Résolution : surcharge utilisateur si non NULL, sinon défaut du skill. La bannière édite le **défaut** (si l'utilisateur peut éditer la page) ; l'onglet Library édite la **surcharge**.
### 7.3 Contenu éligible d'un bloc de skill
Le corps du skill est la page entière sérialisée, mais l'**entrée** d'un run éditeur respecte l'éligibilité (§13.4 du parent) : types listés seulement, contenu imbriqué inclus, blocs vides ignorés. Le Runner reçoit l'entrée déjà filtrée — il ne connaît pas l'éditeur.
### 7.4 Contrainte d'outils
`outils_du_run = outils_autorisés_par_la_politique ∩ (outils_du_skill si tools_json non vide, sinon tous)`. Un skill ne peut ni ajouter un outil MCP, ni lever une approbation. Test dédié obligatoire (T-19).
### 7.5 Le skill supprimé en cours de route
Si la page part à la corbeille entre l'affichage du menu et le run : le Runner répond par une erreur propre « skill indisponible », le menu se rafraîchit, aucun run fantôme dans `skill_runs`.
## 8. Plan de migration des données existantes
1. **Sauvegarde** : snapshot SQLite par le service de backup existant avant le déploiement (procédure habituelle des migrations risquées).
2. **Inventaire** : compter les skills existants par utilisateur et par type ; photographier les hashes des prompts.
3. **Migration 39** : schéma + T02 en une transaction ; en cas d'échec, rien n'est appliqué (patron des migrations Flowdeck).
4. **Vérification post-migration** (script jetable, pas de code livré) : comptages avant/après, hashes des corps de pages générées = hashes des prompts d'origine, échantillon de 5 skills invoqués en mode offline.
5. **Bascule d'UI** : les anciens écrans de gestion des skills (galerie enregistrements) sont retirés dans le même déploiement ; pas de période de double modèle.
## 9. Tests
*Pyramide existante : pytest (TestClient, base temporaire), Playwright pour les gestes réels. Tous les tests de skill doivent passer en mode LLM `offline`.*
| # | Niveau | Cas |
|---|---|---|
| T-01 | Unitaire | Marquer/démarquer : idempotence, unicité `page_id`, refus sans droit d'édition |
| T-02 | Intégration | Migration 39 sur fixture v7.69.8 : comptages, hashes, skills legacy invocables |
| T-03 | Intégration | Conversion d'une collection : propriétés mappées ou créées, lignes → skills, déconversion propre |
| T-04 | Intégration | Skill de base : modifier la propriété `Description` de la ligne met à jour le cache du skill |
| T-05 | Intégration | Discover/Enable : visibilité par ACL, activation personnelle, désactivation sans effet sur autrui |
| T-06 | Intégration | Runner : ligne `skill_runs` écrite ; nom du skill dans la sortie ; intersection d'outils respectée |
| T-07 | Intégration | Un skill dont `tools_json` tente d'ajouter un outil hors politique : l'outil n'est pas disponible au run |
| T-08 | Intégration | Export `SKILL.md` → import : nom, description, corps et fichiers conservés |
| T-09 | Intégration | Badge de copie périmée : téléchargement, puis édition de la page → badge présent ; re-téléchargement → badge absent |
| T-10 | E2E | Sélectionner un texte → menu Skills → Corriger → diff → accepter : le texte est remplacé, l'historique de page contient la version |
| T-11 | E2E | Chat : `/` → choisir un skill → pastille visible → réponse produite |
| T-12 | E2E | Supprimer la page d'un skill affiché dans un menu ouvert : erreur propre au run, pas d'écran cassé |
| T-13 | Non-régression | Les tests existants des routes skills (cookie et v2) passent ; le format v1 s'importe toujours |
## 10. Critères d'acceptation
- [ ] Sur une installation migrée, l'inventaire des skills avant/après est identique (noms, prompts) et chaque skill est devenu une page éditable.
- [ ] Un playbook existant (page ordinaire) devient un skill invocable en moins de cinq gestes, sans copier-coller.
- [ ] Une équipe partage une base de skills ; un membre la découvre dans *Discover*, l'active pour lui, et l'invoque depuis l'éditeur.
- [ ] Les quatre surfaces d'invocation (chat `/`, sélection, slash, bloc) passent par le même Runner (vérifiable : elles écrivent le même format de `skill_runs`).
- [ ] Un skill exporté en `SKILL.md` se réimporte sans perte ; le badge signale une copie locale périmée après édition de la page.
- [ ] Aucun run de skill ne peut utiliser un outil que la politique ou le skill ne lui donne pas.
- [ ] Le mode LLM `offline` exécute tous les parcours de test de la phase.
## 11. Risques spécifiques et vigilance
| Risque | Vigilance |
|---|---|
| La recréation d'`agent_skills` casse les clés étrangères existantes (références depuis d'autres tables) | Inventaire des FK entrantes **avant** T01 ; la recréation suit le patron SQLite (désactiver les FK pendant la copie, `foreign_key_check` après) |
| Les pages générées par la migration polluent les *Recents* et la recherche des utilisateurs | Marquer les pages migrées avec leur date d'origine ; ne pas toucher `recents` ; la recherche les indexe comme des pages normales (c'est voulu), mais le titre est préfixé du nom du skill d'origine, sans décor |
| Deux bases privées créées en concurrence (double premier skill) | Contrainte d'unicité applicative + création dans la même transaction que le premier skill (T04) |
| Les descriptions des 17 presets, écrites pour des humains, sont trop vagues pour le futur routeur | T13 impose leur relecture dès cette phase (le routeur de la Phase 2 en dépend) |
| Confondre défaut du skill et choix utilisateur dans l'UI | §7.2 + libellés distincts dans la bannière (« pour tous ») et la Library (« pour moi ») |
## 12. Ordre d'exécution
```text
T01 ── T02 ── T03 ──┬── T04 ── T05 ── T06 ── T07
├── T09 ──┬── T10
│ ├── T11
│ └── T12
├── T08
├── T13 ── T14
└── T15 ── T16 ── T17
T18 en fil continu ; tests au fur et à mesure, jamais en bloc final.
```
Le chemin critique est T01 → T03 → T09 : tant que le Runner n'existe pas, aucune surface ne peut être rebranchée.
## 13. Définition de « terminé »
La phase est terminée quand : les critères du §10 passent sur une base migrée depuis la v7.69.8 **et** sur une installation neuve ; la suite de tests existante est verte ; la documentation in-app (T18) est livrée ; le document parent est annoté (version 1.2 : « Phase 1 livrée », avec les écarts éventuels entre plan et réalisation consignés plutôt que tus).
---
*Document de phase 1/5 — voir le document parent pour l'analyse fonctionnelle Notion et l'architecture d'ensemble.*
@@ -0,0 +1,305 @@
# Agents & Skills pour Flowdeck — Phase 2 : L'Agent choisit, l'Agent personnel se personnalise
| Champ | Valeur |
|---|---|
| Phase | **2 sur 5** — intelligence d'usage de l'Agent personnel |
| Document parent | `architecture-agents-skills-notion-flowdeck.md` v1.1 — en particulier §3.2 (l'Agent personnel), §10.2 et §10.5, §12.1–12.4, §13.2–13.3 |
| Version | 1.0 — 9 octobre 2026 |
| Auteur | Spark, pour Bruno |
| Statut | Prêt à implémenter **après la Phase 1** |
| Prérequis | Phase 1 livrée (skills = pages, descriptions indexées, Skill Runner, enablements) |
| Migration créée | **40** |
| Débloque | Phase 3 (les Custom Agents héritent du contexte de run, du routeur et du journal de run), Phase 4 (le journal d'usage s'appuie sur `agent_runs`) |
> **Résultat visible à la fin de cette phase.** L'Agent personnel a une identité et une **page d'instructions** personnelle (« Mon Flowdeck AI »), commutable ; dans le chat, le sélecteur **All sources** choisit ce que l'Agent peut consulter, le sélecteur de modèle propose **Auto** ; l'Agent **applique tout seul un skill pertinent** quand la demande correspond à sa description, le nomme dans sa réponse et le liste dans le panneau des sources ; les conversations s'épinglent et se titrent automatiquement ; et **chaque exécution devient un objet `agent_runs` relisible** — modèle réellement utilisé, sources lues, skills appliqués, étapes, coût en tokens.
---
## Table des matières
1. [Objectif](#1-objectif)
2. [Périmètre](#2-périmètre)
3. [État d'entrée](#3-état-dentrée)
4. [Modèle de données — migration 40](#4-modèle-de-données--migration-40)
5. [Tickets de travail](#5-tickets-de-travail)
6. [API livrée par la phase](#6-api-livrée-par-la-phase)
7. [Comportements détaillés](#7-comportements-détaillés)
8. [Tests](#8-tests)
9. [Critères d'acceptation](#9-critères-dacceptation)
10. [Risques spécifiques et vigilance](#10-risques-spécifiques-et-vigilance)
11. [Ordre d'exécution](#11-ordre-dexécution)
12. [Définition de « terminé »](#12-définition-de--terminé-)
---
## 1. Objectif
Deux manques séparent l'Agent personnel actuel de son modèle Notion, et cette phase les comble :
1. **Il ne choisit rien.** Ni le skill pertinent (l'utilisateur doit le nommer), ni le modèle adapté (le modèle est celui de la configuration), ni ses sources au-delà des mentions. La phase livre le **Skill Router** (déterministe d'abord, arbitrage LLM en zone grise), le **routage de modèle `Auto`** et le sélecteur **All sources** persisté par conversation.
2. **Il n'a pas de mémoire de comportement durable et éditable.** Les instructions vivent dans des champs, pas dans une page que l'utilisateur peut écrire, versionner et commuter comme ses autres documents. La phase livre la **page d'instructions personnelle** et son assemblage dans le prompt du run.
Le troisième livrable est invisible mais structurel : **`agent_runs`**. Sans un objet « run » explicite, ni l'Activity des Custom Agents (Phase 3), ni la comptabilité (Phase 4), ni le débogage des appariements de skills ne sont possibles.
## 2. Périmètre
### 2.1 Inclus
- Migration 40 : `agent_personal_settings`, `agents.instruction_page_id`, `agent_runs`, `agent_actions.run_id`, colonnes de conversation (`pinned`, `title_auto`, `sources_json`).
- Personnalisation de l'Agent personnel : nom, avatar/accessoires, mode d'affichage du chat (barre latérale / flottant).
- Page(s) d'instructions personnelle(s) : création (« Mon Flowdeck AI »), choix d'une page existante, modèles de départ, commutation de la page active ; résolution des instructions dans le prompt du run (ordre du document parent §12.3).
- Modification des instructions « par l'Agent » sur demande de l'utilisateur — en proposition diff, jamais en écriture silencieuse.
- Contexte de run typé (`RunContext`, §12.1 du parent) refactoré dans le moteur : surface, acteur, régime (`user` en cette phase), instructions, sources, modèle demandé, budget.
- Sélecteur **All sources** dans le compositeur : workspace, connecteurs actifs, serveurs MCP ; état persisté par conversation ; bouton `@` existant conservé.
- Sélecteur de modèle dans le panneau : `Auto` + modèles configurés ; affichage du modèle **réellement utilisé** dans la réponse ; signalement des modèles « web seulement » (sources workspace décochées pour le run).
- **Skill Router** : appariement description ↔ demande (recherche hybride existante, seuils, arbitrage LLM optionnel), au plus 2 skills automatiques, jamais d'écartement d'un skill forcé, journal de décision.
- Nomination du skill utilisé dans la réponse et **panneau des sources** du run (pages, connecteurs, MCP, skills).
- Conversations : épinglage, titrage automatique (premier échange), historique cherchable dans l'onglet *Chat*.
- Étapes nommées du run dans le flux SSE (`step`, `skill_used` — extension du protocole existant).
- Actions suggérées contextuelles au-dessus du compositeur (amorces de prompt selon la page courante et les blocs sélectionnés).
### 2.2 Exclu
- Les **crédits** et limites tarifaires (Phase 4) : en cette phase, `agent_runs` comptabilise les **tokens** ; le champ `credits` existe mais reste à 0 et n'est jamais affiché comme un coût.
- Les modèles **autorisés par surface** et l'activation admin des premium (Phase 4) : le sélecteur montre les modèles de la configuration LLM existante.
- Les runs **autonomes** (déclencheurs, régime `allowlist`) → Phase 3. `agent_runs.surface` accepte dès maintenant les valeurs futures, sans les produire.
- Les fichiers de conversation (dépôt, production) → Phase 4. `sources_json` ne contient pas encore d'entrées fichiers.
## 3. État d'entrée
| Élément (v7.69.8 + Phase 1) | Usage dans cette phase |
|---|---|
| Moteur ReAct, flux SSE (`reasoning`, `action`, `notice`, `final`, `error`) | Reçoit le `RunContext` ; émet `step` et `skill_used` en plus |
| Context Builder (snapshot Markdown filtré ACL, mentions résolues) | Devient « v2 » : ajoute page/blocs courants, page d'instructions, sources choisies, skills résolus |
| Mémoire par conversation (`agent_memory`) | Couche 3 de l'assemblage d'instructions (§7.1), inchangée |
| Client LLM 23 fournisseurs + précédence de config en 4 niveaux | Le routeur `Auto` choisit **parmi** les modèles de cette configuration |
| Recherche hybride (FTS5 + cosinus, RRF k=60) | Le Skill Router la réutilise avec `resource_type='skill'` |
| Skills = pages, descriptions indexées, enablements | Entrées du routeur |
| Skill Runner (Phase 1) | Exécute les skills forcés **et** résolus par le routeur (même chemin d'exécution) |
## 4. Modèle de données — migration 40
```sql
-- Migration 40 — Phase 2
CREATE TABLE agent_personal_settings (
user_id INTEGER PRIMARY KEY REFERENCES users(id),
display_name TEXT,
avatar_json TEXT DEFAULT '{}',
instruction_page_ids TEXT DEFAULT '[]',
active_instruction_page_id INTEGER REFERENCES pages(id),
chat_mode TEXT DEFAULT 'sidebar', -- 'sidebar' | 'floating'
default_model TEXT -- NULL = Auto
);
ALTER TABLE agents ADD COLUMN instruction_page_id INTEGER REFERENCES pages(id);
-- (les autres colonnes d'agents — kind, web_access, délégation — arrivent en Phase 3)
CREATE TABLE agent_runs (
id INTEGER PRIMARY KEY,
agent_id INTEGER REFERENCES agents(id), -- NULL en cette phase (Agent personnel)
user_id INTEGER REFERENCES users(id),
conversation_id INTEGER REFERENCES agent_conversations(id),
parent_run_id INTEGER REFERENCES agent_runs(id), -- posé dès maintenant, utilisé en Phase 3
surface TEXT NOT NULL, -- 'chat' | 'editor' (+ valeurs futures admises)
trigger_id INTEGER, -- NULL en cette phase (Phase 3 rattachera agent_triggers)
trigger_payload_json TEXT DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'running',
-- 'running'|'waiting_approval'|'done'|'failed'|'cancelled'|'budget_exceeded'
provider TEXT, model TEXT, -- modèle RÉELLEMENT utilisé
tokens_in INTEGER DEFAULT 0, tokens_out INTEGER DEFAULT 0,
credits REAL DEFAULT 0, -- toujours 0 en cette phase (Phase 4)
skills_json TEXT DEFAULT '[]', -- [{skill_id, name, invocation}]
sources_json TEXT DEFAULT '[]', -- sources réellement lues (panneau des sources)
router_decision_json TEXT DEFAULT '{}', -- candidats, scores, seuils, arbitre (§7.3)
error TEXT,
started_at TEXT NOT NULL, finished_at TEXT
);
ALTER TABLE agent_actions ADD COLUMN run_id INTEGER REFERENCES agent_runs(id);
ALTER TABLE agent_conversations ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0;
ALTER TABLE agent_conversations ADD COLUMN title_auto INTEGER NOT NULL DEFAULT 0;
ALTER TABLE agent_conversations ADD COLUMN sources_json TEXT DEFAULT '{}';
-- Rattachement de l'existant : créer rétroactivement un agent_runs 'done' par
-- conversation ayant des agent_actions ? NON — décision : les runs commencent
-- à la livraison de la phase ; l'historique ancien reste lisible comme avant.
```
Règles :
1. **Un run par exécution du moteur**, créé avant le premier appel LLM, terminé dans tous les cas (succès, erreur, annulation, budget) — un run qui reste `running` après un redémarrage du processus est soldé en `failed` (« interrompu ») au démarrage par le lifespan.
2. `skill_runs.run_id` (Phase 1) devient renseigné systématiquement ; les lignes de Phase 1 sans run restent valides.
3. `sources_json` du **run** (ce qui a été lu) est distinct de `sources_json` de la **conversation** (ce qui est autorisé à être lu) — la collision de nom est assumée et documentée partout où les deux apparaissent.
## 5. Tickets de travail
### Le run comme objet
**P2-T01 — Migration 40 et modèle de run.**
Fichiers : `app/migrations.py`, nouveau `app/services/agent_runs.py` (création, transitions d'état, solde au démarrage).
Acceptation : un run de chat crée exactement une ligne ; un arrêt du processus en plein run laisse une ligne soldée `failed/interrompu` après redémarrage, jamais un zombie `running`.
**P2-T02 — `RunContext` dans le moteur.**
Fichiers : `app/services/agent_engine.py`, `app/services/context_builder.py` (v2).
Travail : introduire le dataclass du document parent §12.1 ; tous les points d'entrée existants (panneau, éditeur via le Runner, API v2 synchrone) construisent un `RunContext` ; le régime est `user` pour tous en cette phase ; les écritures de journal (actions) portent `run_id`.
Acceptation : les tests existants du moteur passent sans modification de leurs attentes fonctionnelles ; chaque `agent_actions` nouveau a un `run_id` non nul.
**P2-T03 — Étapes nommées en SSE.**
Travail : événements `step` {key, label, status} émis aux frontières réelles (contexte, routage, chaque famille d'outils, génération) et `skill_used` {skill_id, name, invocation} ; le panneau les rend comme une liste cochée ; repli : un client qui ignore ces événements affiche le flux actuel inchangé.
Acceptation : pendant un run long, l'utilisateur voit progresser les étapes ; aucune étape « décorative » (une étape affichée = un travail réel du moteur).
### Personnalisation et instructions
**P2-T04 — Réglages personnels et identité de l'Agent.**
Fichiers : routes `GET/PUT /api/agent/personal-settings`, panneau (en-tête, menu de personnalisation).
Travail : nom d'affichage, avatar (accessoires en `avatar_json`), mode d'affichage persisté, modèle par défaut personnel ; valeurs par défaut = comportement actuel pour les utilisateurs sans ligne de réglages.
Acceptation : la personnalisation d'un utilisateur ne fuit jamais chez un autre (test multi-utilisateurs sur la même instance).
**P2-T05 — Page d'instructions personnelle.**
Fichiers : route `POST /api/agent/personal-settings/instructions-page`, sélecteur de page existant.
Travail : à la première demande, créer la page privée « Mon Flowdeck AI » (gabarit : ton souhaité, ce que l'Agent doit retenir — rôle, habitudes —, pages/canaux à consulter d'abord) ; permettre de choisir une page existante ou un modèle, d'en maintenir plusieurs (`instruction_page_ids`) et de commuter la page active ; rappel dans l'interface : quiconque édite cette page modifie le comportement de l'Agent — pour partager un exemple, dupliquer la page.
Acceptation : modifier la page active change la réponse au run suivant (test en mode offline avec consigne détectable) ; une page active devenue illisible (ACL retirée) est ignorée avec un `notice`, pas un échec.
**P2-T06 — Assemblage des instructions du run.**
Fichiers : `context_builder.py` v2.
Travail : ordre des couches du document parent §12.3 (système produit → instructions de la page active → mémoire → skills résolus → contexte de travail) ; chaque couche journalisée (présente/absente, taille) dans le run ; la mémoire reste désactivable par conversation (comportement actuel conservé).
Acceptation : un test d'assemblage vérifie l'ordre et l'absence de doublon quand la page d'instructions est aussi mentionnée par `@` dans la demande.
**P2-T07 — L'Agent propose de mettre à jour ses instructions.**
Travail : quand l'utilisateur dit « retiens que… », l'Agent propose une modification de la page active rendue en **diff** dans le panneau ; l'application passe par le chemin d'édition ordinaire des pages (donc versionnée) ; refus = rien n'est écrit.
Acceptation : aucune écriture d'instructions sans validation explicite (test négatif : une instruction « retiens » glissée dans un contenu lu par l'Agent ne produit aucune proposition).
### Contexte et modèle
**P2-T08 — Sélecteur All sources.**
Fichiers : compositeur du panneau, `PUT /api/agent/conversations/{id}/sources`.
Travail : popover à cases — Workspace (toujours présent), chaque connecteur actif de l'utilisateur, chaque serveur MCP connecté (rubrique dédiée) ; l'état est stocké dans `agent_conversations.sources_json` et **appliqué** par le Context Builder (une source décochée n'est ni cherchée ni lue) ; les mentions `@` explicites restent possibles mais limitées aux sources cochées (le préciser dans l'interface au moment du choix).
Acceptation : décocher Gmail rend ses résultats absents d'un run qui les aurait eus cochés (test avec connecteur de test).
**P2-T09 — Routage de modèle `Auto`.**
Fichiers : `llm_config.py` (extension), moteur, sélecteur du panneau.
Travail : valeur `auto` acceptée partout où un modèle se choisit ; routeur léger : classification de la demande (longueur, multi-étapes détectées, recherche web requise, présence de fichiers — heuristiques déterministes documentées dans le code) → choix parmi les modèles configurés selon une table de profils (rapide / équilibré / fort) déclarée dans la configuration du workspace ; en mode `offline`, le routeur choisit toujours le profil déterministe de test ; le modèle choisi est écrit dans le run et affiché sous la réponse (« Modèle : X ») ; choix manuel = jamais de routage.
Acceptation : deux demandes types (courte factuelle / longue multi-étapes) choisissent deux profils différents sur la configuration de test ; le modèle affiché est celui du run, pas celui demandé.
**P2-T10 — Modèles « web seulement ».**
Travail : un modèle peut être marqué dans la configuration comme ne recevant pas le contexte workspace ; le sélectionner décoche visuellement les sources workspace/connecteurs pour le run et l'annonce avant l'envoi (pas de surprise après coup).
Acceptation : impossible d'envoyer un run « web seulement » avec une mention `@page` active sans avertissement explicite.
### Le Skill Router
**P2-T11 — Service `skill_router.py`.**
Fichiers : nouveau `app/services/skill_router.py`.
Travail : entrée (texte de la demande + contexte minimal) → candidats par recherche hybride sur `resource_type='skill'` (top 8) restreints aux skills activés de l'utilisateur (enablements de Phase 1) avec `auto_use` effectif vrai et `description` non vide → scores RRF → décision du document parent §13.2 (seuil haut : appliquer ; zone grise : arbitrage ; seuil bas : rien) ; seuils en réglages de workspace avec des défauts prudents ; sortie = liste ordonnée (0 à 2 skills) + décision complète sérialisée.
Acceptation : sur le jeu d'essai du §8 (30 demandes étiquetées), précision et rappel mesurés et consignés ; les seuils par défaut sont ceux qui passent le jeu d'essai, pas des valeurs devinées.
**P2-T12 — Arbitrage LLM en zone grise.**
Travail : quand les scores tombent en zone grise, un appel court au modèle du run (prompt fermé : descriptions des candidats, choix ou « aucun ») tranche ; l'arbitrage est **interdit en mode offline** (la branche déterministe décide seule, par le seuil) ; le résultat et sa justification courte rejoignent `router_decision_json`.
Acceptation : désactiver l'arbitrage (réglage) ramène le routeur à un comportement 100 % déterministe et testable.
**P2-T13 — Intégration du routeur au run et au Runner.**
Travail : le moteur appelle le routeur après l'assemblage du contexte (couche 4) ; les skills retenus sont exécutés par le Skill Runner de la Phase 1 (même chemin que les skills forcés, `invocation='auto'`) ; un skill **forcé** (`/nom`, menu) court-circuite le routeur ; les intégrés ne sont jamais choisis automatiquement (sauf Traduire/Résumer si leur description correspond — décision explicite : non en cette phase, ils restent manuels) ; la réponse nomme le skill utilisé, le panneau des sources le liste (T14).
Acceptation : UC-03 du document parent passe de bout en bout ; un mauvais appariement est explicable depuis `router_decision_json` seul.
### Conversations et panneau
**P2-T14 — Panneau des sources du run.**
Travail : panneau latéral du chat listant, pour le dernier run ou un run sélectionné : pages lues, lignes de bases, connecteurs interrogés, serveurs MCP appelés, skills appliqués (nom + lien vers la page du skill) ; données lues depuis `agent_runs.sources_json` + `skills_json` alimentés par le Context Builder et le registre d'outils (chaque lecture d'outil s'y déclare).
Acceptation : chaque source affichée a réellement été lue pendant le run (test : une source simplement disponible mais non lue n'apparaît pas).
**P2-T15 — Épinglage, titrage, historique.**
Travail : `PATCH /api/agent/conversations/{id}` (épingler, renommer) ; titre automatique après le premier échange complet (génération par le service de titrage léger — même patron que le titre des notes de réunion : un appel borné ou, en offline, une troncature propre de la demande) ; liste des conversations dans l'onglet *Chat* : épinglées en tête, recherche par titre ; un titre renommé à la main n'est plus jamais écrasé (`title_auto` repéré).
Acceptation : renommer puis continuer la conversation conserve le titre manuel.
**P2-T16 — Actions suggérées contextuelles.**
Travail : rangée au-dessus du compositeur, calculée depuis la page courante (type de page, blocs sélectionnés) : 2 à 4 amorces (extraire les actions, raccourcir, résumer la sélection…) ; cliquer pré-remplit le compositeur — ne lance **pas** le run.
Acceptation : aucune suggestion ne déclenche d'appel LLM à l'affichage.
## 6. API livrée par la phase
| Route | Ticket |
|---|---|
| `GET/PUT /api/agent/personal-settings` | T04 |
| `POST /api/agent/personal-settings/instructions-page` | T05 |
| `PATCH /api/agent/conversations/{id}` | T15 |
| `PUT /api/agent/conversations/{id}/sources` | T08 |
| `GET /api/agent/conversations/{id}/runs` · `GET /api/agent/runs/{id}` | T01/T14 |
| (SSE) événements `step`, `skill_used` | T03 |
## 7. Comportements détaillés
### 7.1 L'ordre des couches d'instructions (rappel normatif)
Système produit → page d'instructions active → mémoire de conversation → skills du run → contexte de travail. En cas de contradiction entre la page d'instructions et une consigne du skill : le **skill gagne pour sa tâche** (il est plus spécifique et plus récent dans le prompt) ; en cas de contradiction avec le système produit : le système gagne toujours. Ces deux règles sont écrites dans les tests d'assemblage (T06).
### 7.2 Ce que « Auto » ne doit jamais faire
Ne jamais choisir un modèle non configuré ; ne jamais changer de modèle **en cours de run** ; ne jamais choisir un modèle premium payant à l'insu de l'utilisateur (les premium n'existent pas encore comme catégorie — Phase 4 — mais dès cette phase, si la configuration distingue des modèles « coûteux », `Auto` les évite sauf tâche classée longue/forte, et l'affiche).
### 7.3 Le journal de décision du routeur
`router_decision_json` contient : les candidats (skill, score), les seuils en vigueur, la branche prise (`forced` / `high` / `grey-llm` / `grey-deterministic` / `none`), l'arbitre éventuel et sa réponse brute tronquée. C'est un outil de débogage produit, pas une télémétrie : il est visible depuis le panneau des sources (section repliable « Pourquoi ce skill ? »).
### 7.4 Un skill automatique peut être refusé après coup
Sous chaque réponse ayant utilisé un skill automatique : action « Ce skill n'était pas pertinent » → enregistre un contre-exemple (demande + skill écarté) dans le journal, et propose de désactiver `Use automatically` pour ce skill. Les contre-exemples alimentent le jeu d'essai (§8) — c'est la matière de l'optimisation de la Phase 5.
## 8. Tests
| # | Niveau | Cas |
|---|---|---|
| T-01 | Intégration | Run complet → une ligne `agent_runs` correcte (statut, modèle réel, tokens > 0 en mode test, skills) |
| T-02 | Intégration | Processus tué en plein run (simulation) → run soldé au redémarrage |
| T-03 | Unitaire | Assemblage d'instructions : ordre des couches, déduplication, page illisible ignorée avec notice |
| T-04 | Intégration | Page d'instructions modifiée → comportement du run suivant modifié (mode offline, consigne détectable) |
| T-05 | Intégration | Proposition de mise à jour des instructions : refus = aucune écriture ; acceptation = nouvelle version de page |
| T-06 | Intégration | All sources : source décochée absente des lectures du run |
| T-07 | Unitaire | Routeur `Auto` : profils choisis sur demandes types ; jamais de changement en cours de run |
| T-08 | Unitaire | Skill Router sur jeu d'essai de 30 demandes (10 doivent matcher un skill précis, 10 aucun, 10 zone grise) : seuils validés, décision journalisée complète |
| T-09 | Intégration | Skill forcé `/nom` : le routeur ne s'exécute pas, `invocation='manual'` dans `skill_runs` et `skills_json` |
| T-10 | Intégration | Skill automatique : réponse nomme le skill ; panneau des sources le liste ; `skill_runs.invocation='auto'` |
| T-11 | Intégration | Contre-exemple « pas pertinent » enregistré et retrouvable |
| T-12 | E2E | Épingler une conversation, la renommer, la retrouver par la recherche de l'onglet Chat |
| T-13 | E2E | Changer la page d'instructions active depuis la personnalisation ; le changement est visible au run suivant |
| T-14 | Non-régression | Tous les tests du moteur, du panneau et de l'API v2 agent de la v7.69.8 + Phase 1 passent |
**Jeu d'essai du routeur (à constituer dans T11, livré comme fixture)** : 30 demandes réelles rédigées à partir des descriptions des 17 presets migrés et des 6 intégrés, étiquetées par un humain. C'est un actif permanent du projet, pas un artefact de phase.
## 9. Critères d'acceptation
- [ ] UC-01 et UC-03 du document parent passent de bout en bout (tâche multi-sources ; skill automatique nommé et sourcé).
- [ ] Un utilisateur personnalise son Agent (nom, page d'instructions) et constate le changement de comportement au run suivant.
- [ ] Chaque run de la phase est relisible : modèle réel, sources lues, skills et décision du routeur sont dans `agent_runs` et affichables.
- [ ] Le routeur est entièrement déterministe en mode offline et passe le jeu d'essai aux seuils livrés.
- [ ] Aucun skill automatique ne peut être un skill non activé pour l'utilisateur, sans description, ou un intégré (en cette phase).
- [ ] Le panneau des sources ne montre que des sources réellement lues.
## 10. Risques spécifiques et vigilance
| Risque | Vigilance |
|---|---|
| Le routeur applique un mauvais skill en silence et dégrade la confiance | Skill toujours nommé, bouton « pas pertinent », seuils prudents, arbitrage journalisé ; communiquer le comportement dans l'aide in-app |
| La page d'instructions devient un fourre-tout qui contredit les skills | Règle de précédence du §7.1 ; gabarit de page qui sépare comportement général et notes ; l'assemblage tronque la page à un budget explicite et le signale |
| `Auto` choisit un modèle lent/cher pour des demandes triviales | Profils testés (T-07) ; le modèle réel est affiché, donc le comportement est observable et corrigeable |
| `agent_runs` grossit vite (un run par message) | Index sur `conversation_id` et `started_at` ; la rétention et l'archivage sont traités en Phase 5 — mais dès cette phase, ne stocker aucun contenu volumineux dans le run (les sources sont des références, pas des copies) |
| Collision de noms `sources_json` (conversation vs run) | §4 règle 3 ; nommer les champs différemment dans les API publiques (`allowed_sources` pour la conversation, `sources` pour le run) |
## 11. Ordre d'exécution
```text
T01 ── T02 ──┬── T03
├── T06 (après T05)
├── T08
└── T09 ── T10
T04 ── T05 ── T07
T11 ── T12 ── T13 ── T14
T15, T16 en parallèle, après T02.
```
Chemin critique : T01 → T02 → T13 (le routeur intégré au moteur) — T11 peut démarrer dès la Phase 1 livrée, en service isolé.
## 12. Définition de « terminé »
Critères du §9 verts ; jeu d'essai du routeur versionné dans les tests ; aide in-app mise à jour (personnalisation, All sources, Auto, skills automatiques) ; document parent annoté en 1.2 (« Phase 2 livrée ») avec les seuils du routeur réellement retenus.
---
*Document de phase 2/5 — dépend de la Phase 1 ; prépare les Phases 3 et 4.*
@@ -0,0 +1,314 @@
# Agents & Skills pour Flowdeck — Phase 3 : Les Custom Agents deviennent autonomes
| Champ | Valeur |
|---|---|
| Phase | **3 sur 5** — le passage de l'assistant à l'équipe d'agents |
| Document parent | `architecture-agents-skills-notion-flowdeck.md` v1.1 — en particulier §3.3 (les Custom Agents), §10.2 et §10.5, §12.1, §12.6–12.8, §16.1–16.2 |
| Version | 1.0 — 9 octobre 2026 |
| Auteur | Spark, pour Bruno |
| Statut | Prêt à implémenter **après les Phases 1 et 2** |
| Prérequis | Phase 1 (skills = pages), Phase 2 (`agent_runs`, `RunContext`, routeur) |
| Migration créée | **41** |
| Débloque | Phase 4 (les déclencheurs de messagerie et les écritures externes s'appuient sur ce dispatcher et ce régime de permissions) |
> **Résultat visible à la fin de cette phase.** Chaque Custom Agent a sa **page en trois onglets — Chat, Activity, Settings** ; il se déclenche sur **horaire, événements du workspace** (ligne ajoutée/modifiée/retirée, commentaire, note de réunion terminée) **et mentions** `[[fdagent:…]]`, avec filtres ; il n'accède qu'aux **ressources explicitement accordées** (régime `allowlist`) ; il se **partage en trois niveaux**, se **duplique** selon des règles explicites, son historique de configuration se **restaure** ; il peut **déléguer** à d'autres agents ; il se crée aussi en **décrivant le travail** à l'IA, qui propose un brouillon à relire.
---
## Table des matières
1. [Objectif](#1-objectif)
2. [Périmètre](#2-périmètre)
3. [État d'entrée](#3-état-dentrée)
4. [Modèle de données — migration 41](#4-modèle-de-données--migration-41)
5. [Tickets de travail](#5-tickets-de-travail)
6. [API livrée par la phase](#6-api-livrée-par-la-phase)
7. [Comportements détaillés](#7-comportements-détaillés)
8. [Tests](#8-tests)
9. [Critères d'acceptation](#9-critères-dacceptation)
10. [Risques spécifiques et vigilance](#10-risques-spécifiques-et-vigilance)
11. [Ordre d'exécution](#11-ordre-dexécution)
12. [Définition de « terminé »](#12-définition-de--terminé-)
---
## 1. Objectif
Flowdeck a déjà des « agents personnalisés » (instructions, outils autorisés, déclencheurs planifiés, trigger API). Ce qui leur manque pour être des Custom Agents au sens de Notion tient en quatre blocs, qui forment cette phase :
1. **Un cycle de vie complet** : page dédiée en 3 onglets, partage gradué, versions de configuration, duplication, intégration dans les pages, modèles de galerie, création assistée.
2. **Des déclencheurs événementiels** branchés sur le bus `fire_event()` des automatisations — l'infrastructure existe, le câblage est le travail.
3. **Le régime de permissions `allowlist`** : en autonome, l'agent ne voit que ses accès accordés, jusqu'aux résultats de recherche. C'est le ticket de sécurité central de toute la solution.
4. **La délégation** entre agents, comme outil borné et comptabilisé.
## 2. Périmètre
### 2.1 Inclus
- Migration 41 : `agents.kind/web_access/credit_limit_monthly/delegation_json`, `agent_permissions`, `agent_versions`, extension événementielle d'`agent_triggers`.
- Page agent SSR `/agents/{id}` : onglets Chat / Activity / Settings (formulaire complet : Instructions par page liée, Triggers, Tools and access, Modèle, Avancé).
- Régime `allowlist` appliqué dans le Context Builder et le Tool Registry pour les runs autonomes (filtre des ressources, y compris résultats de recherche et d'index sémantique).
- Dispatcher de déclencheurs : abonnement unique à `fire_event()`, correspondance type/ressource/filtres, déduplication, file bornée, plafonds, marque d'acteur anti-boucle.
- Déclencheurs livrés : horaire (existant, migré), `collection.row_added`, `collection.property_updated`, `collection.row_removed`, `page.comment_added`, `meeting.summarized`, mention d'agent. Filtres : propriété (opérateurs des automatisations), vue de base, mots-clés.
- Mentions d'agents `[[fdagent:ID]]` dans les pages, propriétés de base et commentaires ; sélecteur de mention étendu.
- Partage à 3 niveaux (`full` / `edit` / `interact`) pour personnes et groupes ; section *Agents* de la sidebar alimentée par les partages ; recherche incluant les agents accessibles.
- Historique de configuration (`agent_versions`) : snapshot à chaque sauvegarde des Settings, restauration.
- Duplication avec les règles de reprise du document parent §3.3.
- Bloc `agent_embed` : intégrer le chat d'un agent dans une page, sans transfert d'accès ni lecture implicite de la page hôte.
- Délégation : outil `delegate_to_agent`, runs enfants (`parent_run_id`), plafonds de profondeur et de budget (en tokens à ce stade).
- Création assistée : description → brouillon {instructions, déclencheurs, accès proposés} → relecture → enregistrement inactif par défaut.
- Galerie de modèles de Custom Agents (les 6 modèles de l'Annexe C du parent).
- Onglet Activity complet : filtres, détail d'un run (étapes, outils, sources, skills, erreurs, coût en tokens), *Re-run* avec la même entrée.
- Onglet Insights **minimal** : compteurs de runs (statut, période), modèles utilisés, tokens — l'export CSV et les vues avancées sont en Phase 5.
### 2.2 Exclu
- Déclencheurs et actions de **messagerie externe** (message/réaction/mention dans Slack, Discord, Telegram, Teams), courriel et calendrier → Phase 4. Le dispatcher accepte dès maintenant tout `event_name` : la Phase 4 n'ajoutera que des émetteurs et des types de ressources.
- Écritures externes et leurs approbations fines → Phase 4 (le régime d'approbation existant couvre les écritures internes dès cette phase).
- Budgets en **crédits** → Phase 4 ; `credit_limit_monthly` est créé et affiché, son application tarifaire attend le journal d'usage (le plafond **tokens** du moteur, existant, borne déjà les runs).
- Restriction « qui peut créer des agents » au niveau workspace (`who_can_create_agents`) → créée en données en Phase 4 avec les réglages IA du workspace ; en cette phase, la création suit les droits actuels des agents.
## 3. État d'entrée
| Élément | État |
|---|---|
| `agents` (instructions, `scope_json`, `approval_mode`, modèle), conversations, triggers horaires + scheduler 60 s | Existants (v7.69.8) |
| Bus d'événements des automatisations : `fire_event()`, événements `page.*`, `collection.*`, `form.submitted`, `meeting.summarized` ; action `agent_trigger` | Existant (§19.1 de l'architecture) — le dispatcher s'y abonne, il ne le remplace pas |
| `agent_policies` / `agent_approvals`, gates d'outils, HTTP 428 sur le destructif | Existants — étendus au régime `allowlist` |
| `agent_runs`, `RunContext`, Skill Router, panneau des sources | Livrés en Phase 2 |
| Skills = pages + accès par ACL de page | Livrés en Phase 1 — un skill « accordé » à un agent = sa page dans ses accès |
| Jetons wiki `[[fdpage:ID]]`, sélecteur de mentions | Existants — étendus à `[[fdagent:ID]]` |
## 4. Modèle de données — migration 41
```sql
-- Migration 41 — Phase 3
ALTER TABLE agents ADD COLUMN kind TEXT NOT NULL DEFAULT 'custom';
-- toutes les lignes existantes sont des Custom Agents ; l'Agent personnel
-- n'est pas une ligne d'agents (Phase 2).
ALTER TABLE agents ADD COLUMN web_access INTEGER NOT NULL DEFAULT 0;
ALTER TABLE agents ADD COLUMN credit_limit_monthly INTEGER; -- appliqué en Phase 4
ALTER TABLE agents ADD COLUMN delegation_json TEXT DEFAULT '[]'; -- agent_ids délégables
ALTER TABLE agents ADD COLUMN status TEXT NOT NULL DEFAULT 'active';
-- 'active' | 'suspended' (suspension = aucun nouveau run autonome)
CREATE TABLE agent_permissions (
id INTEGER PRIMARY KEY,
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id),
group_id INTEGER REFERENCES user_groups(id),
level TEXT NOT NULL CHECK (level IN ('full','edit','interact')),
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL,
CHECK ((user_id IS NULL) <> (group_id IS NULL))
);
CREATE TABLE agent_versions (
id INTEGER PRIMARY KEY,
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
snapshot_json TEXT NOT NULL,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL,
note TEXT
);
ALTER TABLE agent_triggers ADD COLUMN trigger_kind TEXT NOT NULL DEFAULT 'schedule';
ALTER TABLE agent_triggers ADD COLUMN event_name TEXT;
ALTER TABLE agent_triggers ADD COLUMN resource_type TEXT;
ALTER TABLE agent_triggers ADD COLUMN resource_id TEXT;
ALTER TABLE agent_triggers ADD COLUMN filter_json TEXT DEFAULT '{}';
ALTER TABLE agent_triggers ADD COLUMN show_typing INTEGER DEFAULT 1; -- utilisé en Phase 4
ALTER TABLE agent_runs ADD COLUMN trigger_id INTEGER REFERENCES agent_triggers(id);
-- Les triggers horaires existants migrent tels quels (trigger_kind='schedule').
-- agent_runs.agent_id (Phase 2, nullable) reçoit désormais les Custom Agents.
```
Le snapshot d'`agent_versions` contient : instructions (texte **et** `instruction_page_id`), modèle et politique d'approbation, `scope_json`, accès (liste des ressources accordées, §7.2), déclencheurs (copie complète), `web_access`, `delegation_json`. Les accès accordés vivent dans une table de liaison simple créée ici aussi :
```sql
CREATE TABLE agent_access (
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
resource_type TEXT NOT NULL, -- 'page' | 'collection' | 'skill' | 'agent' | 'workspace_shared'
resource_id TEXT NOT NULL, -- id, ou '*' pour workspace_shared
access TEXT NOT NULL DEFAULT 'read', -- 'read' | 'edit'
PRIMARY KEY (agent_id, resource_type, resource_id)
);
```
## 5. Tickets de travail
### Régime de permissions (à faire en premier — tout le reste en dépend)
**P3-T01 — Migration 41 et modèle d'accès.**
Fichiers : `app/migrations.py`, nouveau `app/services/agent_access.py`.
Travail : schéma du §4 ; service de résolution : `can_access(agent, resource, mode)` (lecture/écriture) combinant `agent_access`, l'ACL du propriétaire (`PermissionManager`) et les politiques du workspace — l'ordre d'évaluation est celui du document parent §16.1, et le service rend aussi le **motif** d'un refus (journalisable).
Acceptation : la matrice du §7.1 est couverte par des tests unitaires exhaustifs (chaque cellule).
**P3-T02 — Le filtre `allowlist` dans le contexte et les outils.**
Fichiers : `context_builder.py`, `tool_registry.py`, `semantic_search.py` (point de filtrage).
Travail : quand `RunContext.permission_regime == 'allowlist'` : le Context Builder ne charge que les ressources accordées ; chaque outil de lecture vérifie la ressource cible ; la recherche (FTS et sémantique) reçoit un filtre de ressources **avant** le classement des résultats — pas un masquage après coup (un titre hors accès ne doit pas transiter par le prompt ni par les journaux visibles) ; les outils d'écriture exigent `access='edit'` sur la cible en plus des gates existants.
Acceptation : test de fuite dédié — un agent sans accès à une page secrète ne peut ni la lire, ni la trouver par recherche, ni apprendre son titre par un résultat, ni l'atteindre par une relation depuis une page accordée (les relations sortantes vers du non-accordé sont tronquées et signalées dans le run, pas suivies).
### Page agent et cycle de vie
**P3-T03 — Page agent et onglet Settings.**
Fichiers : nouveau routeur SSR `/agents/{id}`, gabarits, `PUT /api/agent/agents/{id}/settings`.
Travail : les trois onglets ; Settings en sections (Instructions — sélecteur de page liée + aperçu du cache texte ; Triggers — cartes par déclencheur avec filtres et, pour les horaires, l'aperçu du prochain passage ; Tools and access — sélecteurs de pages/collections avec niveau read/edit, skills accordés, agents délégables, interrupteur web ; Modèle ; Avancé — politique d'approbation, plafond d'itérations) ; chaque sauvegarde écrit `agent_versions` et une ligne d'audit.
Acceptation : un agent se configure entièrement depuis cette page, sans passer par les anciens écrans ; l'ancien CRUD d'agents du panneau redirige vers elle.
**P3-T04 — Onglet Chat et onglet Activity.**
Travail : Chat = le panneau de conversation lié à l'agent (composants de la Phase 2, identité de l'agent) ; Activity = table des `agent_runs` de l'agent (déclencheur libellé, statut, durée, modèle, tokens, skills), détail dépliable (étapes, lectures/écritures avec liens d'annulation existants, erreur en clair), filtres statut/déclencheur/période, bouton *Re-run* créant un run lié (`trigger_payload_json.rerun_of`).
Acceptation : un run en échec se débugue et se relance depuis Activity sans quitter la page.
**P3-T05 — Partage à trois niveaux.**
Fichiers : `agent_permissions`, dialogue de partage (patron des partages existants), sidebar, recherche.
Travail : appliquer la matrice du §7.4 ; la section *Agents* de la sidebar liste les agents accessibles triés par niveau ; la recherche globale inclut les agents (résultat typé, ouvrant la page agent) ; un utilisateur sans aucun accès ne voit pas l'agent, y compris dans les listes de délégation des autres agents.
Acceptation : tests par niveau (un `interact` ne peut ni modifier les Settings ni voir Activity au-delà de ses propres runs — décision : Activity est réservé à `full`, les runs **manuels** d'un `interact` lui restent visibles dans son Chat).
**P3-T06 — Versions, restauration, duplication.**
Travail : liste des versions (auteur, date, note auto-générée résumant les sections changées) ; restauration = nouvelle version créée depuis le snapshot choisi (on ne réécrit pas l'histoire) ; duplication selon les règles du parent §3.3 : copie **privée**, nom suffixé, modèle et instructions repris (page d'instructions **copiée**), ressources et déclencheurs filtrés par les accès **du duplicateur** (les ressources inaccessibles sont exclues et listées dans un rapport de duplication affiché), connexions d'outils, limites et historique **non** repris.
Acceptation : le rapport de duplication d'un agent contenant une ressource inaccessible énumère exactement ce qui a été écarté.
**P3-T07 — Bloc `agent_embed` et mentions d'agents.**
Fichiers : registre des types de blocs, rendu SSR du bloc, sélecteur de mentions de l'éditeur et des commentaires, tokens.
Travail : coller le lien d'un agent dans une page propose *Embed* → bloc `agent_embed` {agent_id} rendant le chat de l'agent (composant du panneau, mode embarqué) ; états explicites : pas d'accès à l'agent / agent suspendu / agent supprimé ; le bloc ne donne au modèle **aucune** lecture de la page hôte ; jeton `[[fdagent:ID]]` résolu au rendu (nom de l'agent), reconnu dans les pages, les propriétés texte des bases et les commentaires ; la sauvegarde d'un contenu contenant le jeton émet l'événement de mention (T09).
Acceptation : un lecteur sans accès à l'agent voit l'état « pas d'accès », jamais le chat ; l'agent embarqué ne peut pas répondre sur la page hôte sans y avoir été accordé.
### Déclencheurs
**P3-T08 — Dispatcher de déclencheurs.**
Fichiers : nouveau `app/services/agent_triggers_dispatch.py`.
Travail : abonnement unique à `fire_event()` ; correspondance (event_name, resource, filtres avec les opérateurs des automatisations) ; création du run via le même chemin que les runs manuels (mais régime `allowlist` et conversation journalisée dédiée à l'agent) ; déduplication par empreinte (trigger + identifiant d'événement, fenêtre glissante) ; file d'attente par workspace avec plafond de concurrence partagé avec les runs interactifs (priorité à l'interactif) ; plafond horaire par agent (réglage, défaut documenté) → au plafond, les événements suivants produisent des runs `budget_exceeded` visibles, pas des pertes silencieuses ; suspension d'agent (`status='suspended'`) = dispatcher muet pour cet agent.
Acceptation : déclencher 50 fois le même événement en rafale produit le nombre de runs attendu après déduplication, tous visibles dans Activity.
**P3-T09 — Les déclencheurs du workspace.**
Travail : brancher les émetteurs : lignes de collections (ajout/mise à jour de propriété/retrait — émis par les services de collections aux mêmes points que les événements d'automations), commentaires de page, `meeting.summarized` (déjà émis), mentions (T07) ; filtres propriété/vue/mots-clés évalués contre le payload ; l'acteur d'une écriture faite par un run autonome est marqué `agent:<id>` et, **par défaut**, les événements dont l'acteur est l'agent lui-même ne redéclenchent pas cet agent (anti-boucle), sauf si le déclencheur l'autorise explicitement (case « se redéclencher », déconseillée et signalée).
Acceptation : UC-06 de bout en bout ; le scénario de boucle (agent qui écrit dans la base qu'il surveille) s'arrête de lui-même et le signale.
**P3-T10 — Déclencheurs horaires : continuité.**
Travail : les horaires existants basculent sur le dispatcher (même file, mêmes plafonds) sans changer leur sémantique ; l'aperçu du prochain passage est calculé par le même code que le scheduler.
Acceptation : un agent planifié de la v7.69.8 continue de tourner après migration, à la même heure, avec ses runs désormais dans Activity.
### Délégation et création
**P3-T11 — Outil `delegate_to_agent`.**
Fichiers : registre d'outils, nouveau `app/services/agent_delegation.py`.
Travail : visibilité conditionnée à `delegation_json` non vide ; le run enfant est créé avec le régime et les accès **de l'enfant**, le contexte transmis = tâche rédigée + artefacts attachés explicitement ; profondeur max 3 par défaut (plafonnée à 5), budget tokens prélevé sur l'enveloppe du parent ; le résultat de l'enfant (réponse + statut) revient comme résultat d'outil ; échec de l'enfant = résultat d'échec motivé, le parent décide.
Acceptation : UC-07 ; un enfant ne peut pas lire une ressource du parent qui ne lui est pas accordée (test de fuite croisée) ; une délégation circulaire A→B→A est stoppée par la profondeur et journalisée.
**P3-T12 — Création assistée par l'IA et galerie d'agents.**
Travail : `/agents/new` en trois cartes (document parent §7.3) ; la création assistée est un run spécial de l'Agent personnel dont la sortie structurée est un brouillon {nom, instructions proposées (texte), déclencheurs proposés, accès proposés (ressources retrouvées par recherche, à cocher)} présenté en écran de relecture — **rien n'est enregistré ni activé avant validation**, et l'agent créé naît sans déclencheur actif ; la galerie propose les 6 modèles de l'Annexe C du parent, chacun étant un brouillon pré-rempli passant par le même écran de relecture.
Acceptation : décrire « trie les tickets de la base X » produit un brouillon dont les accès proposés sont exactement la base X (ou rien si X est introuvable/illisible — jamais un accès deviné).
**P3-T13 — Insights minimal et gouvernance de création.**
Travail : onglet Insights de la page agent : runs par statut et par période, modèles utilisés, tokens consommés, taux d'échec ; visible au niveau `full` ; la création d'agents continue de suivre les droits existants (la restriction workspace arrive en Phase 4, la donnée est préparée : ne pas coder de second mécanisme).
Acceptation : les compteurs d'Insights recoupent exactement `agent_runs` sur la même période.
## 6. API livrée par la phase
| Route | Ticket |
|---|---|
| `GET /agents/{id}` (SSR, 3 onglets) | T03/T04 |
| `PUT /api/agent/agents/{id}/settings` | T03 |
| `GET /api/agent/agents/{id}/activity` | T04 |
| `POST /api/agent/agents/{id}/runs/{run_id}/rerun` | T04 |
| `POST /api/agent/agents/{id}/duplicate` (+ rapport) | T06 |
| `GET /api/agent/agents/{id}/versions` · `POST …/versions/{vid}/restore` | T06 |
| `PUT /api/agent/agents/{id}/permissions` | T05 |
| `POST /api/agent/agents/generate` (brouillon) | T12 |
| `GET /api/agent/agents/{id}/insights` | T13 |
| `POST /api/v2/agents/{id}/trigger` (existant, renvoie `run_id`) | T08 |
## 7. Comportements détaillés
### 7.1 Matrice des régimes (normatif, extrait du parent §16.1)
| Contrôle | Run manuel (Chat, trigger API par un humain) | Run autonome (déclencheur, horaire, mention) |
|---|---|---|
| Identité | L'utilisateur | L'agent |
| Lecture | ACL de l'utilisateur | `agent_access` ∩ ACL du propriétaire |
| Écriture | ACL utilisateur + gates existants | Idem + `agent_access` en `edit` + politique d'approbation de l'agent |
| Recherche | Filtrée ACL utilisateur | Filtrée ACL **et** accès accordés, avant classement |
| Skills utilisables | Ceux de l'utilisateur | Uniquement les skills accordés à l'agent |
| Délégation | Vers les agents délégables de l'agent utilisé | Idem, profondeur comptée depuis le run racine |
### 7.2 Ce qu'« accorder » veut dire
`agent_access` est la **seule** source des accès d'un agent autonome. Lier une page dans les instructions n'accorde rien (les liens d'instructions vers du non-accordé rendent un jeton « sans accès » au moment de l'assemblage du contexte, comme dans la duplication). La ligne spéciale `workspace_shared` donne accès à ce qui est partagé avec tout le workspace **au moment du run** (évalué dynamiquement, pas photographié).
### 7.3 Le run autonome a une conversation
Chaque agent a une conversation journalisée permanente pour ses runs autonomes (une par agent, ou une par déclencheur si le créateur préfère — défaut : une par agent) : c'est là que *Re-run*, les approbations en attente et le débogage trouvent leur matière. Elle n'est visible qu'aux niveaux `full`/`edit`.
### 7.4 Matrice de partage
| Action | `full` | `edit` | `interact` | aucun accès |
|---|---|---|---|---|
| Discuter / lancer un run manuel | ✓ | ✓ | ✓ | ✗ |
| Voir les Settings (lecture) | ✓ | ✓ | ✓ | ✗ |
| Modifier les Settings | ✓ | ✓ | ✗ | ✗ |
| Partager / changer les niveaux | ✓ | ✗ | ✗ | ✗ |
| Activity complète / Insights | ✓ | ✗ | (ses runs manuels) | ✗ |
| Dupliquer | ✓ | ✓ | ✓ | ✗ |
| Supprimer / suspendre | ✓ | ✗ | ✗ | ✗ |
Exception héritée du modèle Notion : un utilisateur sans accès peut **déclencher indirectement** un agent (par exemple en ajoutant une ligne à une base surveillée) et voir ses sorties là où elles sont publiées — sans jamais voir l'agent lui-même.
### 7.5 Approbations en autonome
Le régime existant s'applique tel quel : une écriture couverte par `require_approval` suspend le run (`waiting_approval`, webhook) ; les approbations se décident depuis Activity ; expiration du run en attente selon le délai configuré (défaut proposé au parent : 72 h).
## 8. Tests
| # | Niveau | Cas |
|---|---|---|
| T-01 | Unitaire | Matrice complète de `can_access` (régimes × niveaux × modes) |
| T-02 | Intégration | **Fuite** : recherche, lecture directe, relation, mention — aucune voie ne laisse passer une ressource non accordée (le test central de la phase) |
| T-03 | Intégration | UC-06 : ligne ajoutée avec filtre propriété → un run, bonnes lectures, écriture dans le périmètre accordé |
| T-04 | Intégration | Filtre non satisfait → aucun run ; événement dupliqué → un seul run |
| T-05 | Intégration | Anti-boucle : l'agent écrit dans la base surveillée → pas de second run ; case « se redéclencher » → run fils compté et plafonné |
| T-06 | Intégration | Plafond horaire atteint → runs `budget_exceeded` visibles, aucun événement perdu silencieusement |
| T-07 | Intégration | `meeting.summarized` déclenche l'agent de suivi avec le résumé en payload |
| T-08 | Intégration | Partage : chaque ligne de la matrice §7.4 testée par niveau |
| T-09 | Intégration | Restauration de version : la configuration revient, l'historique garde la trace de la restauration |
| T-10 | Intégration | Duplication avec ressource inaccessible : rapport exact, copie privée, aucun accès fantôme |
| T-11 | Intégration | Délégation : périmètre de l'enfant respecté, profondeur plafonnée, coût agrégé au parent |
| T-12 | Intégration | Création assistée : brouillon non enregistré avant validation ; accès jamais devinés |
| T-13 | E2E | Configurer un agent de zéro (Settings), le tester dans Chat, l'activer sur déclencheur, lire son run dans Activity |
| T-14 | E2E | Embed dans une page : chat fonctionnel pour un utilisateur partagé, état « pas d'accès » pour un autre |
| T-15 | Non-régression | Les déclencheurs horaires et le trigger API v2 existants fonctionnent après bascule sur le dispatcher |
## 9. Critères d'acceptation
- [ ] UC-05, UC-06 et UC-07 passent de bout en bout.
- [ ] Le test de fuite (T-02) est vert sur les quatre voies d'accès à une ressource.
- [ ] Un agent événementiel tourne une semaine en autonomie sur l'instance de test avec un Activity propre (aucun run fantôme, aucune boucle).
- [ ] Un agent se partage, se duplique et se restaure conformément aux matrices du §7.
- [ ] Aucun accès n'est accordé par un lien d'instructions, une mention ou un embed — seulement par `agent_access`.
## 10. Risques spécifiques et vigilance
| Risque | Vigilance |
|---|---|
| Le filtre `allowlist` appliqué **après** classement laisse fuiter titres et extraits | T02 teste les voies une à une ; le filtrage se fait dans la requête d'index, pas dans le rendu |
| Le dispatcher double les automatisations existantes (une automation `agent_trigger` + un déclencheur d'agent sur le même événement = deux runs) | Comportement documenté et détecté : les Settings signalent les automatisations existantes qui lancent déjà cet agent sur le même événement |
| La file autonome affame l'interactif sur le mono-processus | Priorité stricte à l'interactif dans la file partagée ; plafond de concurrence réglable ; les runs autonomes sont interruptibles proprement |
| La duplication « perd » des accès sans le dire | Le rapport de duplication est un livrable du ticket, pas un log : affiché, téléchargeable |
| Les créateurs accordent « tout le workspace partagé » par facilité | L'interface propose par défaut un périmètre vide et nomme le risque ; l'Insights montre ce que l'agent a réellement lu (écart visible entre accordé et utilisé) |
## 11. Ordre d'exécution
```text
T01 ── T02 ──┬── T03 ── T04
├── T05 ── T06
└── T08 ── T09 ── T10
T07 après T03 ; T11 après T02 et T08 ; T12 après T03 ; T13 en dernier.
```
Chemin critique : T01 → T02 → T08 → T09. Tant que le test de fuite (T02) n'est pas vert, **aucun** déclencheur événementiel ne doit être activable en production — c'est la porte de la phase.
## 12. Définition de « terminé »
Critères du §9 verts, dont le test de fuite ; une semaine de fonctionnement autonome propre sur l'instance de test ; aide in-app (page agent, déclencheurs, accès) ; document parent annoté (« Phase 3 livrée ») avec les valeurs retenues (plafonds, profondeur de délégation).
---
*Document de phase 3/5 — dépend des Phases 1 et 2 ; prépare la Phase 4.*
@@ -0,0 +1,304 @@
# Agents & Skills pour Flowdeck — Phase 4 : Agir dehors, travailler sur fichiers, compter
| Champ | Valeur |
|---|---|
| Phase | **4 sur 5** — la parité complète (dernière phase de fonctionnalité) |
| Document parent | `architecture-agents-skills-notion-flowdeck.md` v1.1 — en particulier §3.2 et §3.5–3.6, §10.6–10.7, §14, §15, §16.3–16.5 |
| Version | 1.0 — 9 octobre 2026 |
| Auteur | Spark, pour Bruno |
| Statut | Prêt à implémenter **après la Phase 3** |
| Prérequis | Phase 3 (dispatcher, régime `allowlist`, approbations en autonome) |
| Migrations créées | **42** (modèles & usage) et **43** (fichiers des runs) |
| Débloque | Phase 5 (optimisations sur données réelles d'usage et de coûts) |
> **Résultat visible à la fin de cette phase.** L'Agent agit dans le monde extérieur, sous confirmation : il cherche et poste dans **Slack** (nouveau connecteur), rédige/envoie/archive dans **Gmail**, **trouve un créneau** entre participants avec des suggestions classées et une grille, puis réserve ; il **gère l'Inbox** Flowdeck par le chat ; les Custom Agents gagnent des déclencheurs messagerie/courriel/calendrier. Dans le chat, on **dépose des fichiers** (PDF, CSV, XLSX, DOCX, PPTX, ZIP) que l'Agent lit, analyse avec du code sandboxé, transforme en pages ou bases, et il **rend des fichiers téléchargeables**. Et tout est **compté** : modèles autorisés par surface, modèles premium activés par l'admin, allocation d'usage, crédits, limites par membre et par agent, tableaux d'usage.
---
## Table des matières
1. [Objectif](#1-objectif)
2. [Périmètre](#2-périmètre)
3. [État d'entrée](#3-état-dentrée)
4. [Modèle de données — migrations 42 et 43](#4-modèle-de-données--migrations-42-et-43)
5. [Tickets de travail](#5-tickets-de-travail)
6. [API livrée par la phase](#6-api-livrée-par-la-phase)
7. [Comportements détaillés](#7-comportements-détaillés)
8. [Tests](#8-tests)
9. [Critères d'acceptation](#9-critères-dacceptation)
10. [Risques spécifiques et vigilance](#10-risques-spécifiques-et-vigilance)
11. [Ordre d'exécution](#11-ordre-dexécution)
12. [Définition de « terminé »](#12-définition-de--terminé-)
---
## 1. Objectif
Trois chantiers indépendants mais livrés ensemble parce qu'ils partagent la même exigence — **ne jamais agir ou dépenser dans l'ombre** :
1. **Les actions externes** (écarts E8 du parent) : chaque écriture hors Flowdeck est un outil de classe `EXTERNAL_WRITE`, confirmé ou couvert par une politique explicite, et journalisé comme les écritures internes.
2. **L'espace fichiers** (écart E9 + E10) : l'Agent cesse d'être limité au texte du workspace ; il ingère, calcule et produit, en assemblant importers, sandbox Workers et exports existants.
3. **La comptabilité** (écart E13) : les modèles deviennent gouvernés (autorisés par surface, premium activés) et chaque run a un coût calculé depuis un journal — c'est ce qui rend les deux premiers chantiers soutenables en production.
## 2. Périmètre
### 2.1 Inclus
- Contrat « agent-ready » pour les connecteurs (sources / outils de lecture / outils d'écriture / déclencheurs / identité) appliqué à tous les connecteurs livrés dans la phase.
- Connecteur **Slack** natif : connexion workspace (admin) + connexion de compte (utilisateur) ; outils de l'Agent personnel (identité *utilisateur*) et des Custom Agents (canaux accordés) ; déclencheurs message/réaction/mention.
- Google/MS365 : second consentement pour les **scopes d'écriture** ; outils Gmail (brouillon, envoi, archive, étiquettes, corbeille, désabonnement) ; outils calendrier (trouver un créneau + grille, créer, préparer, déplacer si organisateur) ; dépôt d'un fichier produit dans Drive/Files.
- Outils **Inbox** : lire/résumer, regrouper, marquer, archiver (dont en masse avec confirmation et compte exact) ; bornes documentées (pas de décision d'approbation, pas de création de notification, pas de réglages).
- Promotion des connecteurs Discord/Telegram/Teams au contrat agent-ready (canaux comme sources, cibles d'envoi, déclencheurs).
- Déclencheurs Phase 4 dans le dispatcher de la Phase 3 : `message.posted`, `message.reaction`, `message.mention` (par connecteur), `mail.received`, `calendar.event_created/updated/cancelled`.
- Classe d'outils `EXTERNAL_WRITE` dans la gouvernance : approbation par défaut, politiques pré-approuvées bornées (outil × cible) pour les Custom Agents, envoi de courriel toujours confirmé pour l'Agent personnel.
- Espace fichiers : dépôt dans le chat (trombone + glisser-déposer), `agent_run_files`, extraction par les importers, profil tabulaire, outil `run_code_on_data` sur la sandbox Workers avec montage borné, `produce_file` (XLSX, PDF, DOCX, PPTX), `create_collection_from_file`, **tableau interactif de résultats** dans le chat, rétention et purge.
- Migration 42 : `workspace_ai_settings` (modèles autorisés par surface, premium, modèle par défaut, limites, qui peut créer des agents, accès web par défaut), `ai_usage_ledger` ; service `usage_meter` ; tarification par modèle en réglages ; allocation d'usage (fenêtre calculée depuis le journal) ; limites membre et agent appliquées au routage de modèle ; tableaux d'usage (admin) et coût par run dans Activity ; webhooks de seuil de crédits.
- Application de `credit_limit_monthly` (Phase 3, colonne créée) et de `who_can_create_agents`.
### 2.2 Exclu
- Le **serveur MCP** Flowdeck (chantier séparé déjà documenté côté Flowdeck) et le SDK d'agents externe → Phase 5 (préparation seulement).
- Les connecteurs métier nouveaux au-delà de Slack (CRM, billetterie…) : le contrat agent-ready et le client MCP les couvrent sans code dédié.
- La refacturation réelle : les crédits sont une unité de compte interne (question ouverte Q1 du parent : quotas de gouvernance ou reflet de coûts fournisseurs — le journal livré ici sert les deux, seuls les taux changent).
## 3. État d'entrée
| Élément | État |
|---|---|
| Connecteurs natifs (gitea, github, web, google, ms365) + personnels (custom, discord, telegram, mcp) ; OAuth Google/MS365 **lecture seule**, refresh auto, secrets Fernet | Existants (§16.4 de l'architecture) |
| `connectors.py` (fetch gardé SSRF), `oauth_connectors.py`, client MCP complet | Existants |
| Importers (PDF, DOCX, CSV/XLSX, ZIP via pipeline) ; exports (XLSX, PDF, DOCX…) | Existants (§21) |
| Workers sandboxés (AST, pas de réseau/filesystem, timeout 30 s, budget journalier) | Existants (§19.2) |
| Service de notifications + Inbox sidebar | Existant (§19.4) |
| `agent_approvals`, webhooks `agent.*`, dispatcher + régime `allowlist`, `agent_runs` avec `credits` à 0 | Livrés Phases 2–3 |
| `llm_config` (23 fournisseurs, précédence 4 niveaux) | Existant — les taux par modèle s'y ajoutent |
## 4. Modèle de données — migrations 42 et 43
```sql
-- Migration 42 — Phase 4 (gouvernance modèles & usage)
CREATE TABLE workspace_ai_settings (
workspace_id INTEGER PRIMARY KEY REFERENCES workspaces(id),
allowed_models_personal_json TEXT DEFAULT '[]', -- [] = tous les modèles configurés
allowed_models_custom_json TEXT DEFAULT '[]',
premium_models_json TEXT DEFAULT '[]', -- activés par l'admin
default_model TEXT,
member_credit_limit INTEGER, -- crédits / membre / mois ; NULL = illimité
allowance_json TEXT DEFAULT '{}', -- {window_hours, volume} de l'allocation incluse
who_can_create_agents TEXT DEFAULT 'everyone', -- 'everyone' | 'admins'
web_access_default INTEGER NOT NULL DEFAULT 0,
updated_by INTEGER REFERENCES users(id), updated_at TEXT
);
CREATE TABLE ai_usage_ledger (
id INTEGER PRIMARY KEY,
workspace_id INTEGER NOT NULL,
run_id INTEGER REFERENCES agent_runs(id),
user_id INTEGER REFERENCES users(id),
agent_id INTEGER REFERENCES agents(id),
provider TEXT, model TEXT,
tokens_in INTEGER DEFAULT 0, tokens_out INTEGER DEFAULT 0,
credits REAL NOT NULL DEFAULT 0,
bucket TEXT NOT NULL DEFAULT 'allowance', -- 'allowance' | 'premium'
created_at TEXT NOT NULL
);
-- Index : (workspace_id, created_at), (user_id, created_at), (agent_id, created_at).
-- Taux par modèle : table de correspondance dans les réglages (JSON versionné),
-- {"<provider>/<model>": {"in_per_1k": x, "out_per_1k": y, "premium": bool}}
-- le mode offline a un taux de 0 par construction.
-- Migration 43 — Phase 4 (espace fichiers)
CREATE TABLE agent_run_files (
id INTEGER PRIMARY KEY,
run_id INTEGER REFERENCES agent_runs(id),
conversation_id INTEGER REFERENCES agent_conversations(id),
direction TEXT NOT NULL, -- 'input' | 'output'
name TEXT NOT NULL, mime TEXT, size INTEGER,
storage_path TEXT NOT NULL, -- data/uploads/agent-files/<conversation_id>/…
created_at TEXT NOT NULL,
expires_at TEXT -- NULL = livrable conservé par l'utilisateur
);
```
Règles : le ledger est **append-only** (aucune correction d'écriture — un ajustement est une ligne négative motivée, créée par un admin) ; les agrégats (allocation consommée, crédits du mois) sont **toujours calculés** depuis le ledger ; `agent_run_files` ne stocke jamais le contenu en base.
## 5. Tickets de travail
### Le contrat connecteur et Slack
**P4-T01 — Contrat agent-ready et registre des capacités.**
Fichiers : `app/services/connectors.py` (extension), registre dans `tool_registry.py`.
Travail : formaliser la déclaration de capacités du parent §14.1 (sources, read_tools, write_tools, triggers, identité) ; le sélecteur *All sources* (Phase 2) et le Tool Registry consomment cette déclaration au lieu de listes codées en dur ; un connecteur sans déclaration reste utilisable en lecture comme aujourd'hui (compatibilité).
Acceptation : brancher un connecteur de test déclaratif suffit à le faire apparaître dans All sources et dans les outils, sans autre code.
**P4-T02 — Connecteur Slack natif : connexions.**
Fichiers : catalogue des connecteurs natifs, flux OAuth (patron des natifs existants).
Travail : connexion **workspace** par un admin (préalable aux agents) stockée comme les autres connecteurs workspace ; connexion **compte** par utilisateur pour l'Agent personnel (jeton utilisateur, Fernet) ; écran de statut distinguant les deux niveaux ; révocation indépendante.
Acceptation : sans connexion workspace, les déclencheurs Slack d'agents sont refusés proprement ; sans connexion compte, l'Agent personnel signale le manque au lieu d'échouer en cours de run.
**P4-T03 — Outils Slack.**
Travail : lecture (chercher des personnes ; lire canaux publics/privés et fils **dans la visibilité du jeton utilisé** ; lire les fichiers partagés récents) et écritures `EXTERNAL_WRITE` (poster, répondre en fil, éditer **ses propres** messages, réagir) ; identité *utilisateur* pour l'Agent personnel (les messages portent son nom), identité *agent* pour les Custom Agents limitée aux canaux présents dans `agent_access` (extension de `resource_type` à `'channel'`) ; l'outil d'édition refuse tout message non émis par la même identité.
Acceptation : test de borne — avec un jeton limité à un canal, aucune lecture ni écriture hors de ce canal n'est possible, y compris par recherche.
**P4-T04 — Déclencheurs Slack et messageries existantes.**
Travail : émission vers le bus — Slack : `message.posted` (filtres mots-clés, inclusion des fils), `message.reaction`, `message.mention` ; indicateur d'activité dans le canal pendant le run (`show_typing` du déclencheur) ; promotion de Discord/Telegram/Teams au même contrat : leurs événements existants alimentent les mêmes `event_name`, leurs canaux deviennent des ressources accordables et des cibles de `channel_post`.
Acceptation : UC-05 (rapport horaire posté) et un triage sur mention fonctionnent sur Slack **et** sur un connecteur de messagerie préexistant, sans code de dispatcher différent.
### Écritures Google / Microsoft 365 et Inbox
**P4-T05 — Second consentement d'écriture et outils Gmail.**
Fichiers : `app/services/oauth_connectors.py`.
Travail : demande de scopes d'écriture séparée et révocable indépendamment (l'état « lecture seule » reste le défaut et un état stable) ; outils : `mail_draft`, `mail_send`, `mail_archive`, `mail_label`, `mail_trash` (+ désabonnement d'expéditeur) sur Gmail et, en parité de contrat, Outlook/Graph ; l'envoi est **toujours** confirmé pour l'Agent personnel (carte d'approbation montrant destinataires, objet, corps complet) ; pour les Custom Agents, l'envoi exige une politique pré-approuvée explicite par boîte.
Acceptation : aucun envoi ne part sans que le contenu exact envoyé soit celui qui a été approuvé (hash du contenu dans la demande d'approbation, revérifié à l'exécution).
**P4-T06 — Outils calendrier d'agent.**
Travail : `calendar_find_time` (croise les disponibilités des participants sur les calendriers connectés, rend des suggestions **classées** avec les motifs du classement — chevauchements évités, préférences horaires — et la matière de la **grille interactive** rendue dans le panneau : créneaux × participants, sélection en un clic) ; `calendar_create_event` (organisateur = l'utilisateur ou l'agenda accordé) ; `calendar_prep` (assemble événement, participants, pages liées, dernière note de réunion) ; `calendar_move/cancel` bornés aux événements dont l'acteur est organisateur ; en multi-calendriers : calendrier par défaut, sinon question à l'utilisateur.
Acceptation : UC-09 (calendrier) passe ; déplacer un événement d'un tiers est refusé avec le motif, pas tenté.
**P4-T07 — Outils Inbox.**
Fichiers : service de notifications existant (ajout d'une API de service consommable par les outils, pas d'accès direct aux tables depuis le registre).
Travail : `inbox_read` (résumé structuré : nouveau, à répondre, peut être effacé), `inbox_group` (type / statut / projet / page), `inbox_mark`, `inbox_archive` (masse comprise) ; confirmation obligatoire au-delà d'un seuil d'éléments (défaut proposé : 10) avec **compte exact** affiché ; bornes : les notifications d'approbation/demande d'accès sont signalées mais jamais décidées ; aucun outil de création de notification ni de modification des préférences.
Acceptation : « archive tout ce qui est lu » sur une Inbox de test de 250 éléments archive exactement les éléments lus, après une confirmation affichant « 250 » — ni 249 ni 251 (test de comptage sur fixture).
**P4-T08 — Classe `EXTERNAL_WRITE` dans la gouvernance.**
Fichiers : `app/services/agent_policies.py`, `agent_approvals`.
Travail : quatrième classe d'outils ; par défaut approbation par action (le run passe en `waiting_approval`, carte dans le panneau **ou** dans Activity pour les runs autonomes) ; politiques pré-approuvées pour les Custom Agents : tuples (agent, outil, cible) accordés par un niveau `full`, révocables, expirant par défaut (durée réglable, défaut proposé : 90 jours) et affichés dans les Settings de l'agent ; toute écriture externe exécutée écrit dans `agent_actions` avec, quand le tiers le permet, l'action inverse (supprimer le message…) sinon la mention « non annulable ».
Acceptation : il n'existe aucun chemin d'exécution d'un outil `EXTERNAL_WRITE` qui contourne soit l'approbation, soit une politique en vigueur (test par énumération des points d'entrée du registre).
### Espace fichiers
**P4-T09 — Dépôt et lecture de fichiers de conversation.**
Fichiers : routes de conversation (téléversement), nouveau `app/services/agent_files.py`, importers.
Travail : dépôt par trombone et glisser-déposer ; validation (liste blanche de types alignée sur les importers, taille `AGENT_FILE_MAX_MB`) ; stockage sous `data/uploads/agent-files/` ; outil `read_uploaded_file` : extraction texte (PDF/DOCX/PPTX), profil tabulaire (CSV/XLSX : colonnes, types devinés, aperçu), inventaire ZIP (pas d'extraction récursive, plafond décompressé) ; le LLM ne reçoit que résumés et aperçus, jamais le binaire.
Acceptation : déposer le CSV d'UC-08 donne à l'Agent un profil fidèle (noms et types de colonnes, nombre de lignes exact).
**P4-T10 — Calcul sandboxé et production de fichiers.**
Travail : `run_code_on_data` sur le moteur des Workers avec l'unique écart documenté (montage en lecture des fichiers de la conversation, répertoire de sortie en écriture, quotas renforcés) ; `produce_file` pour XLSX/PDF/DOCX/PPTX avec les bibliothèques d'export existantes ; cartes téléchargeables dans le message (`GET …/files/{id}/download` avec contrôle d'ACL de la conversation) ; `create_collection_from_file` via le pipeline d'import (déduplication comprise).
Acceptation : UC-08 de bout en bout ; un code qui tente un accès réseau ou hors du montage est bloqué par la sandbox, et le run continue avec l'échec comme résultat d'outil.
**P4-T11 — Tableau interactif de résultats dans le chat.**
Fichiers : panneau, outil `query_collection` formalisé (Annexe B du parent).
Travail : quand un résultat d'outil est tabulaire (lignes × colonnes typées), le panneau le rend en tableau en lecture (tri client, ouverture de la ligne source) ; le tableau est un artefact du message, pas une collection ; plafond de lignes affichées avec mention du total.
Acceptation : une question « quelles tâches en retard ? » rend le tableau sans ouvrir la base, et chaque ligne ouvre la bonne tâche.
**P4-T12 — Rétention des fichiers.**
Travail : `expires_at` posé au dépôt (défaut `AGENT_FILE_RETENTION_DAYS`, 30 j) sauf pour les livrables que l'utilisateur épingle/conserve ; purge ajoutée au `trash_purge_scheduler` existant ; la suppression d'une conversation supprime ses fichiers et sa mémoire (règle du parent §16.6).
Acceptation : après purge simulée, les métadonnées de run subsistent (nom, taille) mais aucun fichier expiré n'est téléchargeable.
### Comptabilité et gouvernance des modèles
**P4-T13 — `usage_meter` et journal d'usage.**
Fichiers : nouveau `app/services/usage_meter.py`, migration 42, extension de `llm_config` (taux par modèle).
Travail : à la fin de chaque run (et, pour les runs longs, par paliers), écrire la ligne de ledger depuis `agent_runs` (tokens réels remontés par le client LLM) : bucket `allowance` si le modèle est inclus, `premium` sinon ; le taux zéro du mode offline est un cas de test permanent ; webhooks `agent.credits.threshold` aux seuils 50/80/100 % des limites concernées.
Acceptation : la somme du ledger recoupe les tokens des `agent_runs` sur toute période de test ; aucun run terminé sans ligne (ou ligne à zéro explicitement offline).
**P4-T14 — Modèles gouvernés : autorisés par surface, premium, replis.**
Travail : le routeur `Auto` (Phase 2) et les sélecteurs ne voient que les modèles **autorisés pour la surface** ; les premium exigent l'activation admin et puisent dans les crédits ; un modèle désactivé disparaît (grisé pendant la transition) et les agents qui l'utilisaient basculent au prochain run sur défaut/`Auto` (comportement annoncé dans les Settings de l'agent, pas silencieux pour son propriétaire : notification) ; à limite de crédits atteinte (membre ou agent), les modèles inclus continuent et les premium s'arrêtent avec un état explicite dans le run.
Acceptation : retirer un modèle utilisé par un agent ne casse aucun run suivant et laisse une trace visible pour le propriétaire.
**P4-T15 — Tableaux d'usage et réglages IA du workspace.**
Fichiers : page de réglages (section IA & Agents du parent §7.6).
Travail : réglages de `workspace_ai_settings` (deux listes de modèles, premium, défaut, limites, `who_can_create_agents` **appliqué**, accès web par défaut) ; tableaux : usage par membre, par agent, par modèle (période, bucket, crédits), export CSV ; le coût (tokens + crédits) rejoint l'onglet Activity des agents et le détail d'un run du panneau.
Acceptation : un admin répond en moins d'une minute, depuis l'interface, à « qui a dépensé quoi, sur quel modèle, cette semaine ? ».
## 6. API livrée par la phase
| Route | Ticket |
|---|---|
| `POST /api/agent/conversations/{id}/files` · `GET …/files/{fid}/download` | T09/T10 |
| `GET/PUT /api/settings/ai` (réglages IA du workspace, admin) | T15 |
| `GET /api/settings/ai/usage` (+ `?format=csv`) | T15 |
| `POST /api/agent/connectors/slack/connect` (workspace et compte) | T02 |
| Outils (pas des routes) : Slack, Gmail, calendrier, Inbox, fichiers | T03/T05/T06/T07/T09/T10 |
| Webhooks : `agent.credits.threshold` ; champs `credits` dans `agent.run.*` | T13 |
## 7. Comportements détaillés
### 7.1 Confirmation : ce que l'utilisateur voit
Une demande d'approbation montre **l'action exacte** : pour un courriel, destinataires/objet/corps ; pour un message de canal, le canal et le texte ; pour une archive en masse, le compte et le critère. Approuver exécute **ce** contenu (hash revérifié pour le courriel) ; toute modification du contenu par l'Agent après coup exige une nouvelle approbation. Un refus laisse le run se terminer proprement avec le motif « refusé » dans la réponse.
### 7.2 Allocation, crédits, limites : les trois compteurs en pratique
```text
Run démarre → modèle résolu (Phase 2/4)
├─ modèle inclus → le run puise dans l'ALLOCATION (fenêtre glissante du workspace)
│ allocation épuisée → le run est refusé proprement (« allocation épuisée,
│ renouvellement à … »), sauf si des crédits peuvent prendre le relais (réglage)
└─ modèle premium → le run puise dans les CRÉDITS
limite membre ou limite agent atteinte → premium refusé, repli annoncé
sur un modèle inclus (jamais d'arrêt sec quand un repli existe)
Fin du run → ligne de ledger (toujours), seuils vérifiés → webhooks éventuels
```
### 7.3 Identités externes — le tableau à ne jamais violer
| Contexte | Jeton utilisé | Signature visible chez le tiers |
|---|---|---|
| Agent personnel → Slack/Gmail | Celui de l'utilisateur | L'utilisateur |
| Custom Agent → canaux accordés | Celui de l'app/du connecteur workspace | L'agent (nommé) |
| Custom Agent → Gmail | Uniquement via politique pré-approuvée par boîte | La boîte accordée |
| Tout agent → calendrier | Organisateur = l'utilisateur ou l'agenda accordé | L'organisateur réel |
### 7.4 Ce que les fichiers ne sont pas
Un fichier déposé est une **donnée**, jamais une consigne : son contenu est délimité comme les autres contenus lus (§16.4 du parent). Un tableur contenant « envoie ce fichier à… » ne déclenche rien d'autre qu'un éventuel résultat d'outil refusé par la gouvernance.
## 8. Tests
| # | Niveau | Cas |
|---|---|---|
| T-01 | Intégration | Contrat connecteur : un connecteur de test déclaratif apparaît dans All sources et fournit ses outils |
| T-02 | Intégration | Slack borné : lecture/écriture hors canal accordé impossibles (jeton restreint) |
| T-03 | Intégration | Envoi Gmail : contenu approuvé = contenu envoyé (hash) ; refus = rien n'est parti |
| T-04 | Intégration | `calendar_find_time` sur disponibilités de fixture : classement explicable, grille cohérente ; déplacement d'un événement tiers refusé |
| T-05 | Intégration | Inbox : archive en masse au compte exact après confirmation ; notifications d'approbation jamais décidées |
| T-06 | Intégration | Énumération des points d'entrée `EXTERNAL_WRITE` : aucun ne contourne approbation/politique |
| T-07 | Intégration | UC-08 complet en mode offline (extraction et production déterministes, le calcul par un « faux » code sandboxé inclus) |
| T-08 | Intégration | Sandbox : tentative réseau et tentative de sortie du montage bloquées |
| T-09 | Intégration | Ledger : somme par période = somme des runs ; run offline = ligne à 0 ; allocation épuisée = refus propre |
| T-10 | Intégration | Limite membre atteinte : premium refusé avec repli annoncé, modèles inclus toujours utilisables |
| T-11 | Intégration | Modèle retiré : l'agent bascule, le propriétaire est notifié, Activity le montre |
| T-12 | E2E | Déposer un CSV dans le chat, obtenir le tableau de résultats et le XLSX produit téléchargeable |
| T-13 | E2E | Trouver un créneau à trois et réserver depuis la grille |
| T-14 | Non-régression | Les connecteurs en lecture seule fonctionnent inchangés pour les utilisateurs sans consentement d'écriture |
## 9. Critères d'acceptation
- [ ] UC-08, UC-09 et UC-10 passent de bout en bout.
- [ ] Aucune écriture externe sans approbation ou politique pré-approuvée en vigueur (T-06).
- [ ] Aucun run sans ligne au ledger ; les tableaux d'usage recoupent les runs.
- [ ] Un Custom Agent déclenché par une mention dans un canal accordé répond dans ce canal et nulle part ailleurs.
- [ ] La parité fonctionnelle du document parent §3 est atteinte : la Phase 5 n'a plus de fonctionnalité à ajouter, seulement à durcir.
## 10. Risques spécifiques et vigilance
| Risque | Vigilance |
|---|---|
| Consentements OAuth d'écriture trop larges demandés d'un bloc | Le second consentement est séparé, nommé par domaine (courriel **ou** calendrier), et la lecture seule reste un état complet et digne |
| Un envoi approuvé puis modifié par une reprise de run | Hash du contenu dans l'approbation (T05) ; un run repris après refus repart d'une demande nouvelle |
| Explosion des coûts par un déclencheur de messagerie bavard | Les plafonds de la Phase 3 s'appliquent, plus les budgets crédits désormais réels ; l'indicateur d'activité (`show_typing`) est désactivable et les filtres mots-clés sont proposés par défaut |
| Le montage fichiers de la sandbox devient une brèche | C'est l'unique écart au modèle Workers : revue de sécurité dédiée avant T10, chemins canonisés et vérifiés sous le répertoire de la conversation, quotas bas par défaut |
| L'allocation « fenêtre glissante » mal comprise crée des refus surprenants | L'état courant de l'allocation est visible par l'utilisateur dans ses réglages, avec l'heure de renouvellement calculée depuis le ledger — jamais un message « réessayez plus tard » sans date |
| Slack change ses API/scopes | Le connecteur isole les appels derrière son service ; les tests d'intégration Slack utilisent un double d'API, les tests réels sont manuels et listés dans la grille de test |
## 11. Ordre d'exécution
```text
T01 ──┬── T02 ── T03 ── T04
├── T05 ──┐
├── T06 ├── T08 (gouvernance, avant toute mise en service des écritures)
└── T07 ──┘
T09 ── T10 ── T11 ; T12 après T10
T13 ── T14 ── T15
Les trois chantiers (externe / fichiers / comptabilité) sont parallélisables,
mais T08 et T13 sont les portes : pas d'écriture externe en production sans
gouvernance complète, pas de premium sans comptage.
```
## 12. Définition de « terminé »
Critères du §9 verts ; revue de sécurité passée sur `EXTERNAL_WRITE` et sur le montage fichiers ; deux semaines de fonctionnement avec le ledger en observation (écarts ledger/runs à zéro) ; document parent annoté (« Phase 4 livrée — parité atteinte ») ; aide in-app complète pour les nouveaux réglages.
---
*Document de phase 4/5 — dépend des Phases 1 à 3 ; la Phase 5 ne fait que durcir et étendre.*
@@ -0,0 +1,158 @@
# Agents & Skills pour Flowdeck — Phase 5 : Durcissement et extensions
| Champ | Valeur |
|---|---|
| Phase | **5 sur 5** — optionnelle, fondée sur les données réelles des Phases 1 à 4 |
| Document parent | `architecture-agents-skills-notion-flowdeck.md` v1.1 — en particulier §16.4 (injection de prompt), §17 (exigences non fonctionnelles), §20 Phase 5 |
| Version | 1.0 — 9 octobre 2026 |
| Auteur | Spark, pour Bruno |
| Statut | À planifier après retour d'usage des Phases 1 à 4 — contrairement aux phases précédentes, son périmètre exact se **confirme avec les données**, il ne se devine pas |
| Prérequis | Phases 1 à 4 en production avec du trafic réel |
| Migration créée | **Aucune planifiée** (toute migration nécessaire serait numérotée à partir de 44, au moment du besoin) |
| Nature | Durcissement (sécurité, coûts, fiabilité), mesure avancée, extensions publiques |
> **Résultat visible à la fin de cette phase.** La solution complète tourne avec des preuves : le routeur de skills s'est amélioré sur les contre-exemples réels ; les *Insights* couvrent le workspace entier avec export à l'échelle ; une campagne de tests d'injection de prompt a été menée et ses résultats traités ; les runs et journaux ont une politique de rétention et d'archivage appliquée ; la Skills API publique permet de **publier une base de skills vers des agents externes** ; et les deux chantiers voisins — **serveur MCP Flowdeck** et **SDK d'agents** — sont soit lancés avec leur cadrage prêt, soit explicitement écartés, sur données plutôt que sur impressions.
---
## Table des matières
1. [Pourquoi cette phase est différente](#1-pourquoi-cette-phase-est-différente)
2. [Conditions d'entrée](#2-conditions-dentrée)
3. [Chantier A — Améliorer le routeur de skills sur données réelles](#3-chantier-a--améliorer-le-routeur-de-skills-sur-données-réelles)
4. [Chantier B — Insights à l'échelle et administration avancée](#4-chantier-b--insights-à-léchelle-et-administration-avancée)
5. [Chantier C — Sécurité offensive : campagne d'injection de prompt](#5-chantier-c--sécurité-offensive--campagne-dinjection-de-prompt)
6. [Chantier D — Rétention, archivage et performance des journaux](#6-chantier-d--rétention-archivage-et-performance-des-journaux)
7. [Chantier E — Skills API publique et publication externe](#7-chantier-e--skills-api-publique-et-publication-externe)
8. [Chantier F — Préparer (ou écarter) le serveur MCP et le SDK d'agents](#8-chantier-f--préparer-ou-écarter-le-serveur-mcp-et-le-sdk-dagents)
9. [Chantier G — Qualité des agents : évaluation continue](#9-chantier-g--qualité-des-agents--évaluation-continue)
10. [Tests transverses de la phase](#10-tests-transverses-de-la-phase)
11. [Critères de sortie](#11-critères-de-sortie)
12. [Ce que cette phase ne doit pas devenir](#12-ce-que-cette-phase-ne-doit-pas-devenir)
---
## 1. Pourquoi cette phase est différente
Les Phases 1 à 4 construisent des mécanismes ; leurs documents peuvent donc être écrits avant le code. La Phase 5, elle, **consomme les retours de ces mécanismes** : taux d'erreur du routeur, distribution réelle des coûts, tentatives d'injection observées, volume des journaux. Écrire aujourd'hui ses seuils ou ses priorités internes serait de la fausse précision.
Ce document fixe donc : les **conditions d'entrée** (quelles données doivent exister), les **chantiers** avec leur méthode et leurs critères de sortie, et les **décisions à prendre sur données**. Chaque chantier est indépendant : ils peuvent être menés dans n'importe quel ordre, partiellement, ou pas du tout — sans remettre en cause la complétude des Phases 1 à 4.
## 2. Conditions d'entrée
Avant d'ouvrir un chantier, réunir (une page de constat suffit) :
- Le nombre de runs par surface (chat, éditeur, déclencheurs, API) sur au moins 4 semaines, et leur taux d'échec.
- Les décisions du Skill Router : distribution des branches (`forced`, `high`, `grey`, `none`), nombre de contre-exemples « skill pas pertinent » collectés (mécanisme livré en Phase 2).
- La distribution des coûts : crédits par agent, par membre, par modèle ; part des runs autonomes.
- Les incidents : approbations expirées, boucles stoppées, plafonds atteints, écritures externes refusées.
- Le volume des tables de journaux (`agent_runs`, `ai_usage_ledger`, `agent_actions`, `skill_runs`) et les temps de requête des écrans Activity/Insights.
Chaque constat débouche sur un classement des chantiers A à G par bénéfice attendu — c'est ce classement, pas ce document, qui fixe l'ordre réel.
## 3. Chantier A — Améliorer le routeur de skills sur données réelles
**Méthode.**
1. Étendre le jeu d'essai de la Phase 2 (30 demandes) avec les cas réels : demandes ayant reçu un « pas pertinent », demandes où l'utilisateur a forcé un skill que le routeur n'avait pas retenu (signal faible d'un rappel insuffisant), demandes relancées juste après un skill automatique (signal faible d'une mauvaise application).
2. Rejouer le routeur hors ligne sur ce corpus élargi ; ajuster les seuils et, si le corpus le justifie, enrichir l'arbitrage (par exemple fournir à l'arbitre les **exemples** du skill, pas seulement sa description).
3. Traiter la cause avant le symptôme : une description qui attire de mauvais appariements se réécrit — fournir dans la Library un signalement « ce skill est souvent écarté » visible par son propriétaire, avec ses demandes fautives.
4. Toute modification des seuils par défaut est livrée avec le rapport du corpus (précision/rappel avant/après), comme un changement de modèle se livre avec son évaluation.
**Critères de sortie.** Le corpus dépasse 150 demandes étiquetées et fait partie des tests permanents ; les taux d'erreur mesurés ont baissé par rapport au constat d'entrée, ou l'analyse montre qu'ils sont dominés par des descriptions à réécrire (et la liste de ces descriptions a été traitée).
## 4. Chantier B — Insights à l'échelle et administration avancée
**Travaux.**
- Vue workspace des Insights : tous les agents (runs, statuts, modèles, crédits, taux d'échec), avec la règle d'accès déjà posée — un admin doit être ajouté à un agent pour voir le détail de **ses conversations** ; les agrégats anonymisés de contenu, eux, sont visibles sans cet ajout.
- Export CSV à l'échelle : conversations et runs sur une période, avec un plafond documenté par export et un export asynchrone (job, comme les imports) au-delà ; format stable, colonnes versionnées.
- Alertes d'administration : agent en échec répété, agent dont le coût dévie fortement de sa moyenne, skill partagé massivement édité, modèle devenu indisponible chez le fournisseur.
- Comparaison de modèles sur les données réelles : pour un même agent, qualité perçue (feedbacks), coût et latence par modèle — l'écran qui permet de choisir un modèle **pour une raison**, pas par habitude.
**Critères de sortie.** Un admin répond depuis l'interface, sans SQL, aux questions du constat d'entrée (qui coûte, qui échoue, quoi optimiser) ; les exports sont utilisés par au moins un processus réel (revue mensuelle) plutôt que seulement testés.
## 5. Chantier C — Sécurité offensive : campagne d'injection de prompt
**Méthode.** Constituer une suite d'attaques reproductibles (fixtures, exécutées en CI sur le mode offline quand c'est possible, et contre un fournisseur de test sinon) couvrant les trois surfaces du document parent §16.4 :
| Surface | Scénarios types |
|---|---|
| Contenus lus (pages, courriels, messages, fichiers, résultats web/MCP) | Instruction cachée demandant une écriture, une exfiltration vers un outil externe, ou la désactivation d'une approbation |
| Skills et instructions partagés | Skill qui tente d'élargir ses outils, de se rendre obligatoire, de faire taire la nomination du skill dans la réponse |
| Descriptions de skills | Description écrite pour capter des demandes hors sujet (détournement du routeur) |
| Délégations | Un sous-agent qui tente de faire exécuter par le parent une action hors de son propre périmètre |
| Données tabulaires / fichiers | Instructions dans les cellules, les noms de fichiers, les métadonnées |
**Règle de lecture des résultats.** La défense attendue n'est pas « le modèle refuse poliment » — c'est **la gouvernance refuse mécaniquement** : l'outil n'existe pas, l'accès n'est pas accordé, l'approbation n'a pas été donnée. Un scénario où le modèle « a failli » mais où le contrôle a tenu est un succès du système et doit être consigné comme tel ; un scénario où seul le modèle a tenu (aucun contrôle mécanique derrière) est une **faille à corriger** par un contrôle, pas par un meilleur prompt.
**Critères de sortie.** La suite tourne en CI ; chaque scénario est classé (contrôle mécanique / comportement modèle) ; les scénarios reposant sur le seul comportement ont donné lieu à un ticket de contrôle ou à une acceptation de risque explicite et datée.
## 6. Chantier D — Rétention, archivage et performance des journaux
**Travaux.**
- Politique de rétention par type de journal, réglable par workspace : runs interactifs, runs autonomes, ledger (le plus long — c'est une pièce comptable), actions et leurs snapshots d'annulation, fichiers de runs (déjà en Phase 4).
- Archivage : extraction périodique des journaux expirés vers un fichier du volume de sauvegarde avant suppression ; la suppression d'un run archivé laisse une tombe (identifiant, dates, agent, statut) pour garder les références des exports.
- Performance : vérifier sur les volumes réels les index posés aux Phases 2 et 4 ; ajouter les index que les requêtes d'Activity/Insights réclament (et seulement ceux-là) ; solder automatiquement les runs orphelins dans tous les chemins de redémarrage (le solde au démarrage existe depuis la Phase 2 — l'étendre aux états `waiting_approval` expirés).
- Limites de taille des champs JSON de journal (`sources_json`, `trigger_payload_json`) : tronquer par conception (références, pas de contenus), avec un test qui échoue si un payload dépasse le plafond.
**Critères de sortie.** Sur les volumes du constat d'entrée projetés à 12 mois, Activity et Insights répondent dans les cibles du document parent §17 ; la politique de rétention est appliquée par un job observable (compte rendu de purge), pas par une promesse.
## 7. Chantier E — Skills API publique et publication externe
**Travaux.**
- Enrichir `/api/v2/skills` : lister les skills d'une base de skills donnée (avec jeton Bearer scopé et ACL de la base), lire la sérialisation `SKILL.md` et le bundle complet par skill, et recevoir un **webhook** `skill.updated` permettant aux consommateurs externes de resynchroniser (le pendant serveur du badge de copie périmée de la Phase 1).
- Page de documentation d'API correspondante dans l'OpenAPI v2 existant, avec le format `SKILL.md` décrit comme le format d'échange externe de référence.
- Option de « publication » d'une base : un réglage de la base qui la rend listable par l'API avec un jeton dédié (lecture seule), sans ouvrir l'API générale du workspace.
**Critères de sortie.** Un agent externe de démonstration (par exemple un agent local) consomme une base de skills Flowdeck par l'API et se resynchronise sur un `skill.updated` sans intervention manuelle.
## 8. Chantier F — Préparer (ou écarter) le serveur MCP et le SDK d'agents
Ces deux sujets sont des chantiers **voisins déjà identifiés** côté Flowdeck (conception d'un serveur MCP officiel dans `FLOWDECK_MCP_SERVER_GUIDE.md`, non implémentée). Cette phase ne les réalise pas ; elle produit la **décision cadrée** :
- Serveur MCP : les prérequis produits par les Phases 1 à 4 sont réunis (API v2 agents/skills complète, jetons scopés, journal d'audit) ; le cadrage consiste à lister les outils que le serveur exposerait (lecture/écriture de pages et bases, skills, runs d'agents) et leur correspondance exacte avec l'API v2, puis à décider au vu de l'usage réel des agents externes.
- SDK d'agents : la question est de savoir si des applications externes doivent pouvoir **continuer une conversation** d'agent en streaming ; l'API v2 actuelle est synchrone (SSE tamponné) ; le cadrage évalue le besoin sur les intégrations réelles avant tout développement.
- Dans les deux cas, « écarter pour l'instant, avec la raison et la date de réexamen » est une sortie valide de la phase — l'important est que la décision soit prise sur les données du §2, pas laissée en suspens.
**Critères de sortie.** Une note de décision par sujet, rangée avec les documents de la solution, avec périmètre, prérequis vérifiés et date de réexamen.
## 9. Chantier G — Qualité des agents : évaluation continue
**Travaux.**
- Pour les Custom Agents les plus utilisés : constituer des **jeux d'évaluation** (entrées réelles anonymisées + sorties attendues ou critères de jugement), rejoués à chaque changement d'instructions, de modèle ou de version — le pendant des tests pour des objets configurés par des non-développeurs.
- Brancher ces évaluations sur l'écran de restauration de version (Phase 3) : avant de restaurer, on peut évaluer l'ancienne configuration sur le jeu de l'agent.
- Feedback 👍/👎 (existant) agrégé par agent, par skill et par modèle dans les Insights — en fermant la boucle ouverte dès la Phase 2 (les feedbacks existent, leur agrégation utile attendait les volumes).
**Critères de sortie.** Les trois agents les plus utilisés du workspace ont un jeu d'évaluation versionné ; un changement de modèle sur l'un d'eux a été décidé avec son rapport d'évaluation.
## 10. Tests transverses de la phase
| # | Cas |
|---|---|
| T-01 | La suite d'injection (chantier C) tourne en CI et classe chaque scénario |
| T-02 | Le corpus du routeur élargi (chantier A) est une fixture permanente ; les seuils livrés le passent |
| T-03 | Rétention : la purge archive avant de supprimer, et les tombes gardent les références des exports |
| T-04 | Skills API : un jeton scopé à une base ne peut pas lister une autre base |
| T-05 | Évaluation d'agent (chantier G) : rejouer un jeu donne le même verdict sur la même configuration (déterminisme en mode offline) |
## 11. Critères de sortie
- [ ] Le constat d'entrée (§2) existe et chaque chantier mené y répond explicitement.
- [ ] Au moins les chantiers **C** (sécurité) et **D** (rétention/performance) sont menés : ce sont les deux seuls qui relèvent de la dette plutôt que de l'opportunité.
- [ ] Chaque chantier non mené a une raison datée, pas un oubli.
- [ ] Le document parent passe en version finale de la série (« Phases 1 à 5 closes ») avec un bilan honnête : ce qui a été livré, ce qui a été écarté, ce qui reste ouvert.
## 12. Ce que cette phase ne doit pas devenir
- **Pas un fourre-tout de fonctionnalités ajournées.** Si une fonctionnalité visible manque, elle appartient à un correctif des Phases 1 à 4, pas à cette phase.
- **Pas d'optimisation sans mesure.** Aucun seuil, aucun cache, aucun index sans le constat qui le demande.
- **Pas de machine learning maison pour le routeur.** L'amélioration passe par le corpus, les seuils et les descriptions — les actifs que l'équipe peut relire et corriger.
- **Pas de seconde plateforme d'administration.** Tout ce que cette phase ajoute vit dans les écrans existants (Library, page agent, réglages IA).
---
*Document de phase 5/5 — le dernier de la série. La solution est complète dès la Phase 4 ; cette phase la rend durable.*
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,933 @@
# Architecture & instructions d'implémentation — Gestion et interface des Templates (modèle Notion) pour Flowdeck
| Champ | Valeur |
|---|---|
| Version | 1.0 |
| Date | 10 octobre 2026 |
| Auteur | Spark, pour Bruno — **à remettre à Hermes comme cahier des charges d'implémentation** |
| Statut | Proposition d'architecture et plan d'implémentation — à valider contre le code réel de Flowdeck (voir le ticket T0-1 d'audit) |
| Produit cible | Flowdeck **v7.69.8** (monolithe FastAPI / SSR Jinja2 + htmx + Alpine.js CSP, SQLite WAL, API publique v2) — d'après `ARCHITECTURE.md` fourni par Bruno le 9 octobre 2026 |
| Références fonctionnelles | Les templates de Notion d'après son centre d'aide officiel : templates de base de données, templates récurrents, templates d'équipe / galerie (liens en fin de document, consultés le 10 octobre 2026) |
| Référence visuelle | **1 capture de l'état actuel fournie par Bruno le 10 octobre 2026** : page vide « Untitled » de Flowdeck, rangée « Get started with », bouton-pilule **Templates** sous le curseur — conservée dans `templates-notion-reference/capture-etat-actuel-bouton-templates.png` |
| Documents compagnons | `architecture-meeting-notion-flowdeck.md` (v1.2), `architecture-agents-skills-notion-flowdeck.md` (v1.1), `architecture-vues-notion-flowdeck.md` (v1.0) — même méthode, même ancrage v7.69.8 |
> **Historique des versions**
>
> - **1.0 — 10 octobre 2026** : création. Point de départ double, formulé par Bruno : (1) il n'existe dans Flowdeck **aucune gestion ni interface digne de ce nom pour les templates** — le seul point d'entrée visible est la pilule « Templates » de la rangée *Get started with* d'une page vide ; (2) **ce bouton ne fonctionne pas**. Le document traite donc les deux : un **diagnostic encadré** du bouton cassé (phase 0, cause à établir par la preuve, pas par hypothèse) et la **conception complète** d'un système de templates unifié — registre, gestionnaire, sélecteur, éditeur de template, templates de base de données avec défaut et récurrence, variables dynamiques — ancré sur les trois tables de templates existantes de la v7.69.8. Migrations proposées **48 et 49**, reséquencées après les 39 à 43 (Agents & Skills) et les 44 à 47 (Vues) déjà proposées par les documents compagnons (voir §9.1 — le moteur de migrations n'applique que les versions supérieures à la version courante, actuellement **38**).
> **Avertissement méthodologique — à lire avant tout**
>
> L'architecture interne de Notion est propriétaire et n'est pas publique. Ce document ne prétend donc pas décrire « comment Notion est construit en interne ».
>
> Il contient trois choses distinctes, clairement séparées :
>
> 1. **Une analyse fonctionnelle** (section 3) de ce que font les templates dans Notion, établie à partir de sa documentation publique officielle consultée le 10 octobre 2026. C'est le *comportement observable* du produit, résumé avec mes mots.
> 2. **Un état des lieux Flowdeck** (section 4) tiré du document d'architecture v7.69.8 fourni par Bruno et de sa capture du 10 octobre 2026 : ce qui existe déjà, ce qui existe partiellement, ce qui manque, et ce qui est **constaté cassé**. La cause du bouton inopérant n'y est **pas** affirmée : elle fait l'objet d'un protocole de diagnostic en phase 0 (§4.3 et §15), parce qu'aucun élément disponible ne permet de la trancher à distance.
> 3. **Une architecture cible originale** (sections 5 à 20) pour porter Flowdeck à parité fonctionnelle avec Notion sur les templates. Les choix techniques, le modèle de données, les API et les maquettes sont des propositions qui respectent les invariants de Flowdeck (monolithe, SQLite, SQL brut, zéro bundler, libs vendorisées, CSP stricte sans `unsafe-eval`, un seul processus), pas une reproduction de l'existant Notion.
>
> **Note de vocabulaire.** *Template* est gardé en anglais, comme dans l'UI actuelle de Flowdeck (pilule « Templates ») et dans les noms de tables (`page_templates`, `database_templates`). On distingue quatre **genres** de templates, que Notion sépare et que Flowdeck mélange aujourd'hui : **template de page** (contenu d'une page libre), **template de ligne** (propre à une base : propriétés pré-remplies + corps de la page de ligne), **template de base** (une base entière prête à créer : schéma, vues, lignes d'exemple), **template de blocs** (un groupe de blocs à insérer dans la page courante). *Gestionnaire* désigne l'écran de gestion des templates (§7.2) ; *sélecteur* (picker), le panneau de choix et d'application d'un template (§7.1) ; *instanciation*, l'opération qui crée un objet réel à partir d'un template.
---
## 0. Brief pour Hermes — à lire en premier
**Mission.** Construire dans Flowdeck la gestion et l'interface des templates, au niveau d'expérience de Notion, et réparer le bouton « Templates » de la page vide. L'ordre des phases du §15 fait foi : ne pas commencer par l'interface complète avant le diagnostic (phase 0) et le registre unifié (phase 1).
**Ce qui est attendu, dans l'ordre :**
1. **Diagnostiquer avant de réparer** (phase 0). Reproduire le clic mort de la pilule « Templates » dans un test Playwright qui échoue, identifier la cause racine avec des preuves (console, réseau, code du handler), la consigner, puis seulement corriger. Aucune « réparation » par contournement (masquer la pilule, la remplacer par un lien mort ailleurs) n'est acceptée.
2. **Unifier avant d'ajouter** (phase 1). Les trois tables existantes (`database_templates`, `page_templates`, `page_global_templates`) restent la source des contenus historiques ; le nouveau **registre `templates`** (§9) les catalogue toutes derrière un seul service, `TemplateService` (§8). Toute nouvelle surface (UI, API v2, agent, automatisations, récurrence) passe par ce service — jamais par un accès direct à une des trois tables.
3. **Une seule implémentation de l'instanciation.** L'outil agent existant `apply_template`, l'UI humaine, l'API v2 et le scheduler de récurrence doivent tous appeler la même fonction d'instanciation transactionnelle (§8.3). C'est l'invariant déjà appliqué à l'API publique agent de Flowdeck (« une seule implémentation, jamais re-développée », §16.2 de l'architecture v7.69.8) ; l'étendre aux templates.
4. **Respecter les invariants Flowdeck** : SQLite + SQL brut sans ORM, migrations versionnées une par une et transactionnelles, Alpine **build CSP** (tout composant Alpine doit être enregistré via `Alpine.data` dans `base.html` — un gestionnaire inline non enregistré est une cause classique de clic silencieusement mort, voir H3 en §4.3), aucun bundler, icônes via le global Jinja `fd_icon`, cache-busting `?v=VERSION`, navigation partielle maison (`fdNavigate`) plutôt que des rechargements complets.
5. **Chaque clic produit un effet visible.** Critère d'acceptation transversal, né du bug actuel : tout point d'entrée « Templates » doit ouvrir le sélecteur, ou afficher un état vide explicite avec une action de création — **jamais un no-op silencieux**, y compris hors ligne, sans permission, ou avec un catalogue vide.
**Définition de « terminé »** (par phase) : tickets de la phase livrés, tests du §17 verts (dont le test de régression du clic), migrations appliquées sur une base v7.69.8 de test sans perte des templates existants, et parcours manuel de vérification du §17.4 effectué.
**Hors mandat** : ne pas refondre l'éditeur de blocs, ne pas toucher aux templates Jinja de `app/templates/` (le mot « templates » y désigne les gabarits HTML du serveur — voir le piège de vocabulaire en §4.4), ne pas implémenter de marketplace publique.
---
## Table des matières
1. [Résumé exécutif](#1-résumé-exécutif)
2. [Périmètre, personas et cas d'usage](#2-périmètre-personas-et-cas-dusage)
3. [Analyse fonctionnelle : les templates dans Notion](#3-analyse-fonctionnelle--les-templates-dans-notion)
4. [État des lieux Flowdeck v7.69.8, capture de Bruno et analyse d'écart](#4-état-des-lieux-flowdeck-v7698-capture-de-bruno-et-analyse-décart)
5. [Principes directeurs](#5-principes-directeurs)
6. [Vue d'ensemble du système (C4)](#6-vue-densemble-du-système-c4)
7. [Interface : sélecteur, gestionnaire, menus de base et éditeur de template](#7-interface--sélecteur-gestionnaire-menus-de-base-et-éditeur-de-template)
8. [Back-end : `TemplateService`, instanciation, variables dynamiques](#8-back-end--templateservice-instanciation-variables-dynamiques)
9. [Modèle de données et migrations 48 à 49](#9-modèle-de-données-et-migrations-48-à-49)
10. [API et événements](#10-api-et-événements)
11. [Templates de base de données : défaut, récurrence, bases liées](#11-templates-de-base-de-données--défaut-récurrence-bases-liées)
12. [Galerie système et templates fournis](#12-galerie-système-et-templates-fournis)
13. [Intégrations : agent, automatisations, réunions, recherche](#13-intégrations--agent-automatisations-réunions-recherche)
14. [Sécurité, permissions et vie privée](#14-sécurité-permissions-et-vie-privée)
15. [Plan d'implémentation par phases et tickets](#15-plan-dimplémentation-par-phases-et-tickets)
16. [Exigences non fonctionnelles, résilience, déploiement](#16-exigences-non-fonctionnelles-résilience-déploiement)
17. [Tests et critères d'acceptation](#17-tests-et-critères-dacceptation)
18. [Décisions d'architecture (ADR — résumé)](#18-décisions-darchitecture-adr--résumé)
19. [Risques et mitigations](#19-risques-et-mitigations)
20. [Questions ouvertes pour Bruno](#20-questions-ouvertes-pour-bruno)
- [Annexe A — Correspondance Notion → Flowdeck](#annexe-a--correspondance-notion--flowdeck)
- [Annexe B — Format de manifeste `flowdeck-template` v1](#annexe-b--format-de-manifeste-flowdeck-template-v1)
- [Annexe C — Glossaire](#annexe-c--glossaire)
- [Sources](#sources)
---
## 1. Résumé exécutif
Dans Notion, un template n'est pas un fichier modèle caché dans un menu : c'est un **objet de première classe, visible et gérable**. Une base possède ses propres templates, créés et édités comme des pages, listés dans le menu du bouton *New*, qu'on peut dupliquer, définir comme défaut, ou rendre récurrents. Une page vide propose des templates au démarrage, la barre latérale a une entrée *Templates* qui ouvre une galerie, et une page publiée peut être dupliquée comme template par d'autres. Le système est unifié : créer, gérer, appliquer et automatiser sont quatre vues du même objet.
**Le constat Flowdeck est presque inverse.** D'après l'architecture v7.69.8 (§10.2, §5.3), les *données* de templates existent — trois tables, un seed de bases prêtes, des lignes récurrentes, un outil agent `apply_template`, un module d'export/import `templates_io` dans l'API v2 — mais il n'y a **aucune surface de gestion** : pas d'écran qui liste les templates, pas d'endroit où les créer, les renommer, les dupliquer, choisir un défaut ou régler une récurrence autrement que par le code ou la base. Et le seul point d'entrée visible par l'utilisateur, la pilule « Templates » de la rangée *Get started with* d'une page vide (capture de Bruno, §4.2), **ne fonctionne pas** — Bruno le constate, la capture le montre figé au milieu d'entrées d'une autre nature (*Ask AI*, *AI meeting notes*, *Form*…).
**Les sept écarts structurants** (détaillés en section 4) :
| # | Écart | Nature du travail |
|---|---|---|
| **E1** | **Point d'entrée unique et cassé** : la pilule *Templates* de la page vide est le seul accès visible, et son clic est sans effet | **Diagnostic prouvé puis réparation** (phase 0), puis multiplication des points d'entrée légitimes (sélecteur depuis page vide, menu *New* des bases, menu de page, gestionnaire) |
| **E2** | **Trois silos de données sans catalogue commun** : `database_templates`, `page_templates`, `page_global_templates` (+ presets de réunion et canevas Design System à la marge), aucun registre, aucune recherche transverse | **Registre unifié `templates`** (migration 48) qui catalogue les trois silos sans les détruire, backfill idempotent (§9) |
| **E3** | **Aucune interface de gestion** : pas de liste, pas de CRUD, pas de portée (perso / workspace / teamspace), pas d'archivage, pas de compteur d'usage | **Gestionnaire de templates** (§7.2) : page dédiée `/templates` + onglet *Templates* dans la Library, sur les patrons UI existants |
| **E4** | **Aucun éditeur de template** : le contenu d'un template ne s'édite pas « comme une page » ; les propriétés par défaut d'un template de ligne ne se règlent pas dans l'UI de la base | **Édition par page-support** (§7.4, §9.3) : le contenu du template vit dans une page Flowdeck ordinaire mais masquée, éditée avec l'éditeur existant, bandeau de mode template |
| **E5** | **Instanciation fragmentée** : l'outil agent `apply_template` existe, le seed crée des bases, les lignes récurrentes ont leur mécanique — trois chemins qui ne partagent ni validation, ni journal, ni gestion d'erreur | **`TemplateService.instantiate()` unique** (§8.3), transactionnel, idempotent, journalisé dans `template_runs`, consommé par l'UI, l'API v2, l'agent et le scheduler |
| **E6** | **Valeurs dynamiques non systématisées** : Notion résout `@today`, `@now`, `@me` dans les titres et propriétés des templates ; Flowdeck a les briques (tokens de date `[[fddate:…]]`, fonctions `now`/`today` du moteur de formules) mais pas de contrat de variables pour les templates | **Contrat de variables** (§8.4) : variables système résolues à l'instanciation + variables déclarées demandées à l'utilisateur dans le sélecteur, validation par `validate_property_value()` existant |
| **E7** | **Défaut et récurrence invisibles** : un template de ligne peut être récurrent « éventuellement » (v7.69.8) mais rien ne permet de le voir, le régler ou l'arrêter ; aucun concept de template par défaut d'une base exposé dans le menu *New* | **Défaut + récurrence de première classe** (§11) : menu *New ▾* complet, éditeur RRULE réutilisant `recurrence.py`, scheduler et déduplication sur le patron des rappels |
**Recommandation principale.** Ne pas « réparer un bouton » : construire le système dont ce bouton n'est que la porte. D'abord le diagnostic (la cause du clic mort conditionne la confiance dans tout le reste de l'UI), puis le registre et le service d'instanciation, puis seulement les surfaces — sélecteur, gestionnaire, éditeur — dans cet ordre. Le plan en 5 phases (§15) rend le système **utilisable dès la phase 1** (la pilule réparée ouvre un sélecteur minimal qui applique réellement un template) et complet en phase 4.
---
## 2. Périmètre, personas et cas d'usage
### 2.1 Dans le périmètre
**Les quatre genres de templates, unifiés** (§5, principe P1) :
- **Template de page** : titre, icône, couverture et blocs d'une page libre ; proposé sur page vide, depuis le menu *New page*, et applicable à une page existante (ajout ou remplacement de contenu, avec confirmation).
- **Template de ligne** : propre à une collection ; propriétés par défaut + corps de la page-ombre de la ligne ; géré depuis la base (menu *New ▾*) et depuis le gestionnaire ; peut être **par défaut** et/ou **récurrent**.
- **Template de base** : crée une collection complète (schéma, propriétés, vues, dashboards simples, lignes d'exemple optionnelles) ; proposé à la création d'une base et dans la galerie.
- **Template de blocs** : un ensemble de blocs inséré à la position du curseur dans la page courante (successeur unifié des `page_global_templates` et, à terme, des canevas Design System — §13.4).
**La gestion** : créer (depuis zéro, depuis une page/ligne/base existante, par duplication), renommer, éditer le contenu et les réglages, changer la portée, définir/retirer le défaut, régler/arrêter la récurrence, dupliquer, archiver/restaurer, supprimer, exporter/importer, voir l'usage (nombre d'instanciations, dernière utilisation).
**L'application** : sélecteur avec recherche, aperçu et saisie des variables ; instanciation atomique avec retour visuel (navigation vers l'objet créé, ou insertion des blocs) ; journal des instanciations ; échecs explicites et récupérables.
**Les intégrations existantes** : outil agent `apply_template` rebranché sur le service ; action d'automatisation « créer depuis un template » ; presets de réunion enregistrés comme templates ; recherche Ctrl+K.
### 2.2 Hors périmètre
- Marketplace publique, templates payants, partage hors instance (le « dupliquer comme template » d'une page **publiée sur la même instance** est en phase 5 optionnelle, §15).
- Refonte de l'éditeur de blocs ou du moteur de vues (les documents compagnons couvrent les vues ; celui-ci consomme l'existant).
- Templates d'e-mails, de sites publiés ou de formulaires publics (objets distincts, déjà couverts par `sites` / `form_config_json`).
- Migration des gabarits HTML Jinja (`app/templates/`) — voir §4.4.
### 2.3 Personas et cas d'usage directeurs
| Persona | Cas d'usage directeur |
|---|---|
| **Bruno, seul sur son instance** | « Je crée une page vide, je clique *Templates*, je choisis *Revue hebdo*, la page se remplit. La semaine suivante, la ligne *Revue hebdo* apparaît toute seule dans ma base de tâches, datée du jour. » |
| **Bruno, gestion de projets dans Flowdeck** | « Ma base *Projets* a un template de ligne *Spécification* : propriétés pré-remplies (statut, priorité) et corps structuré. *New* l'applique par défaut ; *New ▾* me laisse choisir *Bug* ou *Spécification*. » |
| **Membre d'un teamspace** | « Les templates du teamspace sont visibles par les membres, invisibles dehors (404 comme les pages privées), et seuls les éditeurs de la base peuvent modifier son template par défaut. » |
| **L'agent Flowdeck** | « Quand on me demande de créer une note de réunion, j'utilise `apply_template` — le même service que l'UI — et l'action apparaît dans le journal, annulable comme mes autres actions. » |
---
## 3. Analyse fonctionnelle : les templates dans Notion
Synthèse, avec mes mots, de la documentation publique de Notion consultée le 10 octobre 2026 (liens en fin de document). Seuls les comportements qui fondent une décision de conception Flowdeck sont retenus.
### 3.1 Templates de base de données — le cœur du système
- Un template de base est **propre à une base** : il n'existe que pour elle et s'applique à ses nouvelles lignes. Chaque base peut en avoir plusieurs (par type de réunion, par type de tâche…).
- Il combine deux choses : des **valeurs de propriétés pré-remplies** et un **corps de page** structuré (titres, sections, checklists, blocs liés…) qui devient le contenu de la page de la ligne créée.
- **Création et gestion** se font depuis le menu déroulant accolé au bouton *New* de la base : *+ New template* pour créer ; chaque template listé a un menu *•••* avec **Edit**, **Duplicate**, **Set as default**, **Repeat…**, **Delete**.
- **Éditer** un template ouvre son contenu **comme une page**, dans l'éditeur ordinaire ; les modifications valent pour les créations futures, jamais rétroactivement pour les lignes existantes.
- Le template **par défaut** s'applique quand on clique *New* sans ouvrir le menu. Sans défaut, *New* crée une ligne vide.
- Un template peut être appliqué **après coup** à une page de base existante créée sans template.
- Les titres et propriétés acceptent des **mentions dynamiques** — date du jour, instant courant, utilisateur courant — résolues au moment de la création de la ligne, pas à la définition du template.
### 3.2 Templates récurrents
- Tout template de base peut être rendu **récurrent** depuis son menu (*Repeat*) : quotidien, hebdomadaire, mensuel, annuel, ou fréquence personnalisée (intervalle, jours de semaine, fin par date ou nombre d'occurrences).
- À l'échéance, une **nouvelle ligne** est créée depuis le template, avec les valeurs dynamiques résolues à cette date. On arrête la récurrence en la désactivant dans le même menu ou en supprimant le template ; les lignes déjà créées ne sont jamais touchées.
### 3.3 Templates de page et galerie
- À la création d'une page, Notion propose des **templates de démarrage** (page vide, ou partir d'un template de la galerie) ; une entrée *Templates* dans la barre latérale ouvre la **galerie** de l'espace de travail, prolongée par la galerie communautaire.
- Une page **publiée** peut autoriser sa **duplication comme template** : les visiteurs obtiennent un bouton *Duplicate* qui copie la page (ou le système de pages/bases) dans leur espace.
### 3.4 Boutons et insertion de blocs
- Notion a fondu l'ancien « template button » dans ses **boutons** génériques : un clic peut insérer des blocs, créer des pages ou modifier des entrées. Pour Flowdeck, l'équivalent fonctionnel existe déjà sous deux formes — le bloc `button` de l'éditeur (déclenche une automatisation) et la propriété `button` des bases — ce qui classe ce besoin en **intégration** (§13.2), pas en nouveau mécanisme de templates.
### 3.5 Ce que Notion ne fait pas (et que ce document ne demande pas)
- Pas de variables nommées à saisir à l'application (seules les mentions dynamiques existent) : les **variables déclarées** du §8.4 sont donc un **choix Flowdeck délibéré**, marqué comme tel — elles répondent au besoin de Bruno de paramétrer (projet, personne, échéance) sans multiplier les templates quasi identiques.
- Pas d'aperçu « diff » avant application : le sélecteur Flowdeck propose une prévisualisation du contenu résolu (§7.1), sans prétendre que Notion la fournit.
---
## 4. État des lieux Flowdeck v7.69.8, capture de Bruno et analyse d'écart
### 4.1 Ce qui existe déjà (d'après l'architecture v7.69.8)
| Brique | État décrit en v7.69.8 | Référence |
|---|---|---|
| Templates de bases prêtes | `database_templates` — bases prêtes à créer, alimentées par un seed `db_templates.py` | §10.2 |
| Templates de lignes | `page_templates` — lignes de base, « éventuellement récurrentes » | §10.2, §5.3 |
| Templates de blocs | `page_global_templates` — ensembles de blocs réutilisables | §10.2, §5.3 |
| Route legacy | `page-templates` dans le package `board/` | §4 |
| Export / import | Module `templates_io` dans l'API publique v2 (`app/routers/api_v2/`) | §3, §4 |
| Outil agent | `apply_template` parmi les 26 outils statiques du `ToolRegistry` ; chaque mutateur renvoie un snapshot d'undo | §16.3 |
| Presets de réunion | Instructions de résumé prédéfinies (auto, standup, team, sales, 1:1, interview) et modèles de notes par type de réunion prévus par le document Meetings | §18.1, doc. compagnon |
| Canevas Design System | `block_templates.py`, insertion de blocs depuis le menu + de l'assistant (phase 3 du hub, v7.53) | §16.4 |
| Briques de récurrence | `recurrence.py` (sous-ensemble RRULE : daily/weekly/monthly, interval, COUNT, UNTIL, BYDAY, fuseaux) ; rappels par scan périodique 60 s dédupliqué par journal | §19.3 |
| Briques de valeurs dynamiques | Tokens de mentions de date `[[fddate:YYYY-MM-DD[Thh:mm]]]` résolus au rendu ; fonctions `now` / `today` du moteur de formules | §14.1, §11.3 |
| Validation des propriétés | `validate_property_value()` par type + contraintes `validation_json` | §11.2 |
| Patrons d'interface réutilisables | Library à onglets avec colonnes configurables et side peek ; galerie de skills à presets installables (17 presets, format portable) ; menus contextuels de page façon Notion | §15.2, §16.3, §13.3 |
Conclusion de l'inventaire : **le contenu et les moteurs existent ; le catalogue, la gestion et les surfaces manquent.** C'est un travail d'unification et d'exposition, pas de création ex nihilo — exactement comme pour les vues (document compagnon).
### 4.2 Lecture de la capture de Bruno (10 octobre 2026)
Ce que la capture montre, élément par élément — c'est l'**état observable** du point d'entrée actuel :
- Une page libre « Untitled » ouverte dans l'éditeur (fil d'Ariane *Home / Untitled*), **vide** : le pied indique « 1 blocks » et « Saved offline ».
- Au centre-bas de la page, la rangée **« Get started with »** : pilules *Ask AI*, *AI meeting notes*, *Form*, un bouton **« ••• »**, et la pilule **Templates** (icône de page + libellé), sur laquelle le curseur en forme de main est positionné — Bruno est donc en train de cliquer dessus, ou vient de le faire.
- Un **menu déroulant est ouvert au-dessus**, listant *Table, Board, List, Timeline, Calendar, Gallery, Import* : c'est le menu de **création de base** (les types de vues + l'import), pas un menu de templates. Deux lectures possibles, à trancher par le diagnostic (§4.3) : soit ce menu appartient à une autre pilule de la rangée et il masque/perturbe la zone de clic de *Templates*, soit le clic sur *Templates* ouvre… ce menu-là, c'est-à-dire le mauvais.
- La **barre latérale** (panneau Home) ne contient **aucune entrée Templates** : sections *test, Manage Workspaces, Meetings, Recents, Favorites, Agents, Teamspaces, Shared, Published, Library, My Tasks, Trash, Help*. Dans Notion, l'entrée *Templates* de la barre latérale est un accès de premier niveau à la galerie ; ici, la Library est le seul endroit où un onglet Templates pourrait exister — il n'y en a pas (§15.2 : Recents, AI Meeting Notes, Favorites, Shared, Private, Published, Workspace, Repository).
- Aucun état « catalogue vide », aucun panneau, aucune notification visible : si des templates existent en base, rien ne les rend atteignables depuis cette page.
**Constat de Bruno, pris comme fait : le clic sur « Templates » ne fonctionne pas.** La capture corrobore au minimum ceci : même dans l'hypothèse où le clic déclencherait quelque chose, l'écran n'offre ni retour visible, ni liste, ni état vide — ce qui viole le critère transversal du brief (§0, point 5).
### 4.3 Diagnostic du bouton cassé — hypothèses à trancher par la preuve
Aucune cause n'est affirmée ici. Le tableau donne à Hermes les hypothèses **ordonnées par probabilité compte tenu des invariants connus de Flowdeck**, la vérification qui tranche chacune, et la correction attendue si elle se confirme. Le ticket T0-2 (§15) impose de toutes les passer, dans l'ordre, et de consigner le résultat.
| # | Hypothèse | Pourquoi elle est plausible chez Flowdeck | Vérification qui tranche | Correction si confirmée |
|---|---|---|---|---|
| H1 | **Aucun handler effectif** : la pilule est rendue (fragment de la rangée *Get started with*) mais son gestionnaire JS est absent, renommé ou jamais lié | La rangée mélange des actions de natures différentes (IA, réunion, formulaire, base) ; une pilule ajoutée « pour plus tard » sans câblage est le scénario le plus simple | Chercher la chaîne `Templates` / l'id de la pilule dans `static/js/page_editor_scripts.js` et les fragments `_page_editor_*.html` ; poser un point d'arrêt console sur le clic | Câbler le handler sur le sélecteur (§7.1) — pas sur un comportement ad hoc |
| H2 | **Composant Alpine non enregistré** : le clic passe par Alpine, mais le composant n'est pas déclaré via `Alpine.data` dans `base.html` | Invariant documenté de la build CSP : les enregistrements `Alpine.data` de `base.html` sont **obligatoires** ; un composant inline échoue **silencieusement** (pas d'erreur visible) | Console : avertissements Alpine ; comparer avec un composant voisin fonctionnel (*Ask AI*) ; vérifier la présence de l'enregistrement | Enregistrer le composant ; ajouter le test de régression T0-3 |
| H3 | **Endpoint absent ou en erreur** : le handler appelle une route templates inexistante (404) ou en échec (500), et l'erreur est avalée sans retour UI | Il n'existe dans l'architecture aucune route de gestion des templates côté UI (seuls `page-templates` legacy sous `/board` et `templates_io` en API v2) | Onglet réseau au clic : URL appelée, statut ; logs serveur corrélés | Créer la route du §10 ; **et** rendre toute erreur visible (toast + état d'erreur du sélecteur) |
| H4 | **Interception par le menu de création de base** : le menu *Table…Import* visible sur la capture recouvre la zone ou capte l'événement ; le clic atteint le mauvais élément | Le menu est ouvert exactement au-dessus de la pilule sur la capture ; les menus contextuels globaux (`_ctx_menu`) ont leur propre gestion de z-index et de fermeture | Inspecter l'élément au point de clic (`document.elementFromPoint`) avec le menu ouvert/fermé ; tester le clic menu fermé | Corriger la pile des menus (fermeture au clic extérieur, `pointer-events`) ; le sélecteur de templates doit être un panneau distinct, pas un menu partageant cette pile |
| H5 | **JS périmé servi par le service worker** : l'état « Saved offline » et les caches PWA peuvent servir une version de `page_editor_scripts.js` sans le handler | PWA : statique en cache-first, bump de cache à chaque release ; un décalage version HTML/JS produit des comportements « bouton mort » typiques | Comparer la version servie et la version au dépôt ; tester en navigation privée / cache désactivé | Bump de cache ; vérifier la cohérence `?v=VERSION` du fragment et du JS |
| H6 | **Abandon silencieux faute de contexte** : le handler exige un workspace/une collection que la page libre « Untitled » n'a pas, et sort sans rien afficher | La page est une page libre (pas une ligne de base) ; un handler pensé pour les templates de lignes n'aurait rien à appliquer ici | Lire le code du handler : conditions de sortie ; journaliser temporairement les sorties précoces | Le sélecteur doit s'ouvrir **quel que soit le contexte** ; le contexte filtre les genres proposés (page, blocs), il ne bloque jamais l'ouverture |
**Règle de sortie de diagnostic** : la phase 0 ne se termine que lorsque (a) le test Playwright de reproduction passe de rouge à vert pour la bonne raison (la cause consignée), et (b) le clic produit l'ouverture du sélecteur minimal de la phase 1 — ou, si la phase 1 n'est pas encore livrée, d'un panneau d'état explicite « catalogue » (même vide). Un bouton qui « ne fait plus d'erreur en console » mais n'ouvre rien n'est **pas** réparé.
### 4.4 Piège de vocabulaire à éviter pendant l'implémentation
`app/templates/` contient les **gabarits HTML Jinja** de Flowdeck (~35 fichiers) ; les routes SSR les rendent. Le présent document parle des **templates métier** (objets réutilisables par l'utilisateur). Toute nouvelle surface doit éviter la confusion : fichiers front nommés `templates_manager.html`, ` _template_picker.html`, JS `templates_manager.js` / `template_picker.js`, routes `/templates` et `/api/templates…` — jamais `template.html` seul, jamais de variable Python nommée `template` sans suffixe métier (`fd_template`, `tpl`) dans les modules partagés.
### 4.5 Les sept écarts, en détail
- **E1 — Point d'entrée unique et cassé.** Voir §4.2 et §4.3. Au-delà du bug : une fonctionnalité dont l'unique porte est une pilule contextuelle de page vide est **indécouvrable** (rien dans la sidebar, rien dans la Library, rien dans les bases). Notion multiplie les portes cohérentes : sidebar, page vide, menu *New* des bases.
- **E2 — Trois silos sans catalogue.** Impossible aujourd'hui de répondre à « quels templates ai-je ? » sans interroger trois tables aux sémantiques différentes, ni de chercher un template par son nom tous genres confondus. Le registre (§9) est la réponse ; la recherche du sélecteur et de Ctrl+K s'appuie dessus.
- **E3 — Aucune gestion.** Créer un template de ligne récurrent, aujourd'hui, suppose le seed ou la base de données. Il manque : liste, création guidée, renommage, duplication, portée, archivage, suppression, compteurs d'usage — tout ce que la galerie de skills possède déjà pour les skills (§16.3 de l'architecture), ce qui donne le patron à suivre.
- **E4 — Aucun éditeur de template.** Dans Notion, éditer un template = éditer une page. Flowdeck a un excellent éditeur de pages ; il faut un moyen d'y brancher le *contenu* d'un template sans créer une vraie page visible dans l'arbre, la recherche et les récents — d'où la page-support masquée (§9.3), qui réutilise aussi versions, temps réel et blocs synchronisés.
- **E5 — Instanciation fragmentée.** Trois chemins (seed, outil agent, récurrence des lignes) = trois comportements d'erreur et aucune journalisation commune. Le service unique (§8) factorise validation des propriétés, résolution des variables, création transactionnelle et journal `template_runs`.
- **E6 — Variables non systématisées.** Les mentions dynamiques existent comme tokens de rendu, les formules savent `now()`/`today()`, mais rien ne définit ce qui est résolu **à l'instanciation** (et figé dans l'objet créé) vs **au rendu** (et vivant). Le contrat du §8.4 tranche : un template fige ses valeurs à la création de l'objet — sinon une note de réunion « du 3 octobre » changerait de date en la rouvrant.
- **E7 — Défaut et récurrence invisibles.** Le menu *New* d'une base Flowdeck ne montre ni le défaut ni les templates disponibles ; la récurrence des `page_templates` n'a ni écran de réglage, ni prochain passage visible, ni bouton d'arrêt. §11 spécifie les trois surfaces manquantes.
---
## 5. Principes directeurs
- **P1 — Un objet, quatre genres, un registre.** Tout template, quel que soit son genre, est une ligne du registre `templates` avec un `kind`. Les surfaces ne connaissent que le registre ; les différences de genre vivent dans le manifeste (§9.2) et dans le service.
- **P2 — Le contenu reste dans les modèles existants.** Le corps d'un template de page/ligne est un contenu de page Flowdeck (blocs JSON / markdown legacy, `content_format`) porté par une page-support ; le schéma d'un template de base est un `schema_json` de collection ; les valeurs de propriétés sont un `property_values_json`. Aucun nouveau format de contenu.
- **P3 — Figer à l'instanciation.** Variables, dates dynamiques et valeurs par défaut sont résolues **au moment de créer l'objet**, dans la transaction d'instanciation. L'objet créé est ensuite un objet ordinaire, indépendant du template (le modifier ou supprimer le template ne l'affecte jamais).
- **P4 — Une instanciation, quatre appelants.** UI, API v2, agent, automatisations/récurrence : même fonction, même validation, même journal, mêmes erreurs (§8.3).
- **P5 — Aucun clic sans effet visible.** Ouverture, état vide, erreur explicite ou progression : jamais de no-op (§0 point 5, §4.3 règle de sortie).
- **P6 — Les permissions du template et de la cible se composent.** Lire le template + écrire la cible ; un template ne donne jamais accès à un contenu que son lecteur ne pourrait pas voir, et sa page-support n'apparaît dans aucune surface de navigation (§14).
- **P7 — Réutiliser les patrons d'UI existants.** Gestionnaire = patron Library (onglets, colonnes, side peek) ; galerie système = patron galerie de skills ; éditeur RRULE = patron des récurrences de propriétés ; sélecteur = patron des menus contextuels de l'éditeur. Aucun nouveau langage visuel.
- **P8 — L'historique ne casse pas.** Backfill idempotent des trois tables legacy vers le registre ; les objets créés avant la refonte restent valides ; les routes legacy `page-templates` et `templates_io` continuent de fonctionner (adaptateurs sur le service).
---
## 6. Vue d'ensemble du système (C4)
```mermaid
graph TD
subgraph Surfaces["Surfaces (SSR + JS existants étendus)"]
PICKER["Sélecteur de templates<br/>(page vide, menu de page, New ▾ de base)"]
MANAGER["Gestionnaire /templates<br/>+ onglet Library"]
TEDITOR["Éditeur de template<br/>(éditeur de page + bandeau)"]
DBMENU["Menu New ▾ d'une base<br/>(DBInstance)"]
end
subgraph Core["Noyau (nouveau)"]
REG["Registre templates<br/>(migration 48)"]
SVC["TemplateService<br/>preview / instantiate / duplicate<br/>create_from_* / resolve_variables"]
RUNS["template_runs<br/>(journal + dédup récurrence)"]
end
subgraph Existing["Existant Flowdeck réutilisé"]
HOLDER["Pages-support masquées<br/>(pages, search_excluded)"]
LEGACY["database_templates<br/>page_templates<br/>page_global_templates"]
VAL["validate_property_value()"]
REC["recurrence.py + scheduler 60 s"]
AGENT["Outil agent apply_template"]
AUTO["Automatisations (action create_from_template)"]
end
PICKER --> SVC
MANAGER --> REG
TEDITOR --> HOLDER
DBMENU --> PICKER
SVC --> REG
SVC --> HOLDER
SVC --> LEGACY
SVC --> VAL
SVC --> RUNS
REC --> SVC
AGENT --> SVC
AUTO --> SVC
```
**Flux directeur (application depuis une page vide)** : clic *Templates* → le sélecteur interroge le registre (portées visibles, genre `page`) → l'utilisateur choisit, l'aperçu résout les variables système et demande les variables déclarées → `POST /api/templates/{id}/instantiate` → `TemplateService` crée/met à jour la page dans une transaction, journalise dans `template_runs`, renvoie l'URL → le front navigue (ou reste, pour les blocs) avec un toast « Créé depuis le template X — Annuler » (l'annulation supprime l'objet créé, sur le patron des snapshots d'undo de l'agent).
---
## 7. Interface : sélecteur, gestionnaire, menus de base et éditeur de template
Quatre surfaces, toutes sur les patrons existants (P7). Les maquettes ASCII donnent la **disposition** attendue ; le style suit le Design System et les composants actuels (pilules de la rangée *Get started with*, menus contextuels de l'éditeur, tableaux de la Library).
### 7.1 Le sélecteur de templates (picker)
**Déclencheurs** (tous ouvrent le même composant, filtré par le contexte) :
| Contexte | Déclencheur | Filtre initial |
|---|---|---|
| Page vide | Pilule **Templates** de la rangée *Get started with* (**le bouton à réparer**) | `kind=page` + `kind=blocks` |
| Page quelconque | Menu *•••* de la page → *Appliquer un template…* ; et slash command `/template` dans l'éditeur | `kind=page` (mode ajout/remplacement) + `kind=blocks` (insertion au curseur) |
| Base (pleine page ou inline) | Bouton *New ▾* (§7.3) → nom d'un template, ou *Tous les templates…* | `kind=row`, `target_collection_id` = la base |
| Création de base | Menu de création de base → *Partir d'un template* | `kind=database` |
| Palette Ctrl+K | Action « Appliquer un template… » | selon la page courante |
**Disposition** (panneau modal centré, ou side peek sur écran étroit ; recherche autofocus, navigation clavier ↑/↓/Entrée/Échap) :
```text
┌─ Appliquer un template ──────────────────────────────────────── ✕ ─┐
│ 🔍 Rechercher un template… │
│ [ Tous ] [ Pages ] [ Lignes ] [ Bases ] [ Blocs ] ☐ Portée: ▾ │
├──────────────────────────────┬─────────────────────────────────────┤
│ RÉCENTS │ 📄 Revue hebdo │
│ 📄 Revue hebdo ★ │ Template de page · Workspace │
│ 🐞 Rapport de bug │ ───────────────────────────────── │
│ FAVORIS │ APERÇU (valeurs résolues) │
│ 📄 Note de réunion 1:1 ★ │ # Revue du {{date}} │
│ MON WORKSPACE │ ## Fait / En cours / Blocages │
│ 📄 Spécification produit │ ☐ Actions de la semaine │
│ 🗂 Base Projets (base) │ ───────────────────────────────── │
│ SYSTÈME │ VARIABLES │
│ 📄 Journal quotidien │ Semaine : [ 2026-W42 ] │
│ 📄 Compte rendu (système) │ Projet : [ Flowdeck ▾ ] │
│ │ │
│ + Nouveau template │ [ Annuler ] [ Utiliser ] │
├──────────────────────────────┴─────────────────────────────────────┤
│ Entrée : appliquer · ★ : favori · ••• : éditer / dupliquer / … │
└────────────────────────────────────────────────────────────────────┘
```
**Comportements obligatoires :**
- **Aperçu résolu** à droite : le contenu du template avec les variables système déjà résolues pour *maintenant* (dates, utilisateur) et les variables déclarées éditables inline ; les propriétés par défaut d'un template de ligne s'affichent en chips sous l'aperçu.
- **État vide par section** : si aucun template n'existe pour le genre filtré, la section *Système* reste proposée (galerie §12) et un bouton *+ Créer un template* est visible. Le panneau **s'ouvre toujours** (P5).
- **État d'erreur** : si l'API échoue, le panneau affiche l'erreur et un bouton *Réessayer* — c'est l'antidote direct de H3.
- Actions secondaires par ligne (menu *•••*, selon permissions) : *Éditer*, *Dupliquer*, *Renommer*, *Définir comme défaut* (lignes), *Répéter…* (lignes), *Archiver*, *Exporter*.
- Après application : toast avec *Annuler* (10 s) ; pour un template de page appliqué à la page courante vide, pas de navigation (la page se remplit en place, temps réel synchronisé) ; pour une création (ligne, base, nouvelle page), navigation vers l'objet créé via `fdNavigate`.
- **Mobile** (≤768 px) : le sélecteur devient la feuille plein écran « Insert block » déjà utilisée par l'éditeur, aperçu en second onglet.
### 7.2 Le gestionnaire de templates
**Accès** : nouvelle page **`/templates`** (SSR, `templates_manager.html` + `templates_manager.js`), atteinte par (a) une entrée **Templates** dans la sidebar, panneau Home, sous *Library* ; (b) un **onglet Templates** dans la Library (même données, patron Library : colonnes configurables par *Show columns*, sélection multiple, side peek d'aperçu) ; (c) le lien *Gérer les templates…* du sélecteur et du menu *New ▾* des bases.
```text
┌─ Templates ───────────────────────────────────── [ + Nouveau ▾ ] ─┐
│ 🔍 Rechercher… Genre: [Tous ▾] Portée: [Toutes ▾] │
│ Base: [Toutes ▾] ☐ Archivés Tri: [Dernière utilisation ▾]│
├────────────────────────────────────────────────────────────────────┤
│ Icône Nom Genre Portée Base Défaut/Répète │
│ 📄 Revue hebdo Page Workspace — — │
│ 🐞 Rapport de bug Ligne Workspace Support ★ Défaut │
│ 📅 Tâche hebdo Ligne Workspace Tâches ↻ Lun. 9 h │
│ 🗂 Projets (starter) Base Système — — │
│ ▦ Sommaire + actions Blocs Personnel — — │
├────────────────────────────────────────────────────────────────────┤
│ Ligne sélectionnée → aperçu en side peek : contenu, propriétés │
│ par défaut, variables, usage (42 utilisations, dernière le …), │
│ [ Utiliser ] [ Éditer ] [ Dupliquer ] [ Archiver ] [ Exporter ] │
└────────────────────────────────────────────────────────────────────┘
```
**Menu *+ Nouveau*** : *Template de page vierge*, *Template de ligne pour une base…* (choix de la base), *Template de base vierge*, *Template de blocs (depuis la sélection)*, *Depuis une page existante…*, *Depuis une ligne existante…*, *Importer un template (.json)*.
**Règles** : les templates **système** (§12) sont listés avec un badge, non éditables directement (*Dupliquer pour modifier*) ; l'archivage retire le template des sélecteurs sans le supprimer (restauration depuis le filtre *Archivés*) ; la suppression est un soft-delete aligné sur la corbeille unifiée (`deleted_at`, §15.4 de l'architecture) ; les compteurs d'usage viennent de `template_runs` (§9.4), jamais d'un compteur dénormalisé à maintenir à la main — une vue SQL d'agrégation suffit.
### 7.3 Le menu *New ▾* d'une base
Dans `DBInstance` (base pleine page, inline et bloc `database`) :
```text
[ New ] [ ▾ ]
┌──────────────────────────────┐
│ + Nouvelle ligne vide │
│ ──────────────────────────── │
│ 🐞 Rapport de bug ★ │ ★ = défaut (appliqué par [New])
│ 📄 Spécification │
│ 📅 Tâche hebdo ↻ Lun. │
│ ──────────────────────────── │
│ + Nouveau template… │
│ Gérer les templates… │
└──────────────────────────────┘
Sur chaque template, au survol : [ ••• ] → Éditer / Dupliquer /
Définir comme défaut / Répéter… / Supprimer
```
- Le bouton principal *New* applique le **défaut** s'il existe, sinon crée une ligne vide (comportement actuel préservé).
- Le même menu, ouvert depuis une **base liée** (`collection_data_sources`), liste les templates de la base **source** (§11.3).
- Dans la vue *Board/Calendar/Gallery/List*, le *New* d'une colonne/d'un jour pré-remplit en plus la propriété de regroupement ou de date — qui **prime** sur la valeur par défaut du template en cas de conflit, et le sélecteur l'affiche (« Date : imposée par la vue »).
### 7.4 L'éditeur de template
Éditer le contenu d'un template de page ou de ligne ouvre la **page-support** (§9.3) dans l'éditeur ordinaire, avec trois différences, et trois seulement :
```text
┌────────────────────────────────────────────────────────────────────┐
│ 🧩 Vous modifiez le template « Rapport de bug » — les changements │
│ s'appliquent aux prochaines créations. [ Terminé ] │
├────────────────────────────────────────────────────────────────────┤
│ 🐞 Rapport de {{titre}} │
│ ## Reproduction … (blocs éditables, éditeur inchangé) │
│ │
│ ── Panneau latéral « Réglages du template » ── │
│ Nom / Icône / Description / Portée : Workspace │
│ Propriétés par défaut (ligne) : Statut=[À trier] Priorité=[P2] │
│ Variables : {{titre}} texte requis · {{date}} = aujourd'hui │
│ ☐ Template par défaut de la base ↻ Répéter : [ Lun. 9 h ▾ ] │
└────────────────────────────────────────────────────────────────────┘
```
- **Bandeau de mode** permanent (composant du fragment `_page_editor_content.html`, conditionné par le contexte `template_id`), bouton *Terminé* qui ramène au gestionnaire ou à la base d'origine.
- **Panneau de réglages** (tiroir droit, même patron que les réglages de page) : métadonnées, portée, propriétés par défaut éditées avec les **éditeurs de cellule existants** de `DBInstance` (pour les lignes), variables (§8.4), défaut et récurrence (§11).
- La page-support est **exclue** de l'arbre, de la recherche (`search_excluded=1`), des récents et de la Library ; son URL directe renvoie un non-membre de la portée vers 404 (patron teamspace privé).
- Pour un template de **base**, l'édition ouvre un assistant en 3 étapes (schéma et propriétés — sur l'éditeur de schéma existant ; vues par défaut ; lignes d'exemple optionnelles), pas l'éditeur de page.
- Pour un template de **blocs**, l'édition ouvre la page-support en mode « blocs seuls » (pas de titre de page éditable).
### 7.5 La rangée *Get started with*, corrigée
La pilule *Templates* reste à sa place (c'est un bon réflexe produit, aligné sur Notion) mais son contrat change : **clic → sélecteur §7.1 filtré page/blocs, toujours**. Si la rangée affiche aussi le menu de création de base (*Table…Import* de la capture), les deux piles de menus doivent être mutuellement exclusives (ouvrir l'un ferme l'autre — antidote H4). La pilule reçoit un état de chargement bref si le registre met plus de 300 ms à répondre, jamais un silence.
---
## 8. Back-end : `TemplateService`, instanciation, variables dynamiques
### 8.1 Module et responsabilités
Nouveau service `app/services/templates.py` (logique métier, accès données — patron de l'architecture : les routeurs authentifient et rendent, les services portent la logique), routeurs :
- `app/routers/templates.py` — pages SSR (`/templates`, édition) et API interne cookie (`/api/templates…`, §10.1) ;
- extension de `app/routers/api_v2/templates_io.py` — l'import/export existant devient le transport du format de manifeste (§10.2, annexe B) ;
- le package `collections/` ne gagne qu'un endpoint mince pour le menu *New ▾* (liste des templates d'une base), qui délègue au service.
### 8.2 Fonctions du service (contrat)
```python
class TemplateService:
def list_templates(actor, workspace_id, *, kind=None, scope=None,
target_collection_id=None, query=None,
include_archived=False) -> list[TemplateDTO]
def get_template(actor, template_id) -> TemplateDTO # 404 si hors portée (P6)
def create_template(actor, spec) -> TemplateDTO # vierge ou depuis source
def create_from_page(actor, page_id, spec) -> TemplateDTO
def create_from_row(actor, collection_id, row_id, spec) -> TemplateDTO
def create_from_collection(actor, collection_id, spec) -> TemplateDTO
def update_template(actor, template_id, patch) -> TemplateDTO
def duplicate_template(actor, template_id, spec) -> TemplateDTO
def set_default(actor, template_id) -> None # exclusif par base (§11.1)
def set_recurrence(actor, template_id, rrule | None) -> None # lignes uniquement
def archive_template(actor, template_id) / restore / delete # soft-delete
def preview(actor, template_id, variables, context) -> PreviewDTO
def instantiate(actor, template_id, variables, context, *,
idempotency_key=None, run_source) -> InstantiateResult
def run_due_recurrences(now) -> list[InstantiateResult] # appelé par le scheduler
```
`context` porte la cible : `{target: {type: current_page|new_page|row|collection|insertion}, page_id?, collection_id?, parent_id?, view_hints?: {group_property, date_property}}` — les `view_hints` matérialisent la règle du §7.3 (la vue prime sur le défaut).
### 8.3 Instanciation — algorithme unique (P4)
1. **Charger et autoriser** : template visible par l'acteur (P6) ; cible inscriptible par l'acteur ; sinon erreur RFC 7807 explicite.
2. **Résoudre le manifeste** (§9.2) depuis la source du template (page-support ou silo legacy via adaptateur §9.5) ; vérifier `manifest.version`.
3. **Résoudre les variables** (§8.4) : système d'abord, déclarées ensuite (valeurs fournies ou défauts) ; toute variable requise manquante → erreur de validation listant les champs, **avant** toute écriture.
4. **Valider les propriétés par défaut** avec `validate_property_value()` contre le schéma **actuel** de la base cible ; une propriété disparue ou renommée depuis la création du template produit un **avertissement** dans le résultat (et dans l'aperçu), pas un échec — le reste s'applique (règle de dégradation §16).
5. **Créer dans une transaction** : page / ligne (+ sa page-ombre) / base (+ vues et lignes d'exemple) / insertion de blocs, avec les valeurs figées (P3). Les blocs copiés reçoivent de nouveaux identifiants ; les blocs synchronisés sont **copiés en blocs ordinaires** (jamais référencés — sinon éditer la copie modifierait la source synchronisée partout) ; les mentions de pages restent des tokens `[[fdpage:…]]` si la cible est lisible par l'acteur, sinon dégradées en texte simple.
6. **Journaliser** dans `template_runs` (§9.4) : source (`ui`, `api`, `agent`, `automation`, `recurrence`), acteur, cible créée, statut, avertissements, clé d'idempotence.
7. **Émettre** l'événement `template.applied` (webhooks sortants, catalogue existant) et, pour une ligne, les événements `collection.*` ordinaires de la création — les automatisations existantes se déclenchent donc normalement.
8. **Renvoi** : `{created_type, created_id, url, warnings[]}`. L'undo du toast (§7.1) supprime l'objet créé (soft-delete) et marque le run `undone`.
L'idempotence suit le patron API v2 existant (en-tête `Idempotency-Key`, table `idempotency_keys`, réponse rejouée) — indispensable pour le scheduler et les retries réseau.
### 8.4 Variables et valeurs dynamiques — le contrat
Deux familles, résolues à l'instanciation (P3), jamais au rendu :
**Variables système** (syntaxe dans les titres, valeurs de propriétés et textes de blocs) :
| Token | Résolution | Équivalent existant |
|---|---|---|
| `{{today}}` / `{{now}}` | Date / horodatage de l'instanciation, fuseau de l'utilisateur | `today()` / `now()` des formules |
| `{{me}}` | Utilisateur courant (propriétés `person`, texte « @Prénom ») | mentions de personnes |
| `{{date:+7d}}`, `{{date:-1w}}`, `{{date:+1mo}}` | Date décalée (jour/semaine/mois), format de sortie selon le type de la propriété cible | `dateAdd` des formules |
| `{{workspace}}`, `{{collection}}` | Nom du workspace / de la base cible | — |
| `{{title}}` | Titre saisi à la création (pour une ligne : le titre demandé dans le sélecteur) | — |
**Variables déclarées** (définies dans le panneau de réglages du template, stockées dans `templates.variables_json`, §9.2) : `{name, label, type: text|number|date|person|select, required, default?, options?, prompt?}`. Le sélecteur les rend en formulaire (§7.1) ; une variable déclarée s'utilise avec la même syntaxe `{{nom}}` dans le titre, les propriétés par défaut (par référence, pas par texte, pour les types non textuels) et les blocs.
**Frontière avec les tokens existants** : les tokens de **rendu** (`[[fdpage:…]]`, `[[fddate:…]]`) restent vivants dans l'objet créé ; les tokens `{{…}}` de **template** n'y survivent jamais — un `{{…}}` non résolu à l'instanciation est un échec de validation, pas un texte copié. Cette règle doit être testée (§17) car c'est la confusion la plus probable à l'implémentation.
---
## 9. Modèle de données et migrations 48 à 49
### 9.1 Séquencement des migrations
La version courante décrite par la v7.69.8 est **38**. Les documents compagnons proposent déjà : **39 à 43** (Agents & Skills), **44 à 47** (Vues). Les templates prennent donc **48** (registre et réglages) et **49** (journal des runs et récurrences), dans l'ordre des phases du §15 : le registre (48) en phase 1, le journal (49) dès la phase 1 aussi — il est le socle des compteurs et de la déduplication — mais sa partie récurrence n'est consommée qu'en phase 4 ; les deux migrations sont donc écrites en phase 1, dans cet ordre, pour ne jamais rééditer une migration inférieure (règle déjà établie par le document Agents & Skills v1.1). Comme toujours : `@register(version, name)`, une migration = une transaction, backfill séparé et idempotent (le backfill du §9.5 est une fonction du service appelée au boot si le registre est vide, pas du DDL).
### 9.2 Table `templates` — le registre (migration 48)
```sql
CREATE TABLE templates (
id TEXT PRIMARY KEY, -- tpl_<uuid court>
workspace_id INTEGER NOT NULL REFERENCES workspaces(id),
teamspace_id INTEGER REFERENCES teamspaces(id),
kind TEXT NOT NULL CHECK (kind IN ('page','row','database','blocks')),
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
icon TEXT,
cover_url TEXT,
category TEXT NOT NULL DEFAULT '', -- libre : Réunion, Projet, Perso…
scope TEXT NOT NULL DEFAULT 'workspace'
CHECK (scope IN ('personal','workspace','teamspace','system')),
owner_id INTEGER REFERENCES users(id), -- requis si scope='personal'
target_collection_id TEXT, -- requis si kind='row' ; la base propriétaire
content_page_id INTEGER REFERENCES pages(id), -- page-support (§9.3), kinds page/row/blocks
source_kind TEXT NOT NULL DEFAULT 'native'
CHECK (source_kind IN ('native','legacy_database','legacy_page','legacy_global')),
source_id TEXT, -- id dans le silo legacy, si source_kind != 'native'
manifest_json TEXT NOT NULL DEFAULT '{}', -- schémas/valeurs par genre (§9.2.1)
variables_json TEXT NOT NULL DEFAULT '[]', -- variables déclarées (§8.4)
is_default INTEGER NOT NULL DEFAULT 0, -- kind='row' uniquement, exclusif par base
is_system INTEGER NOT NULL DEFAULT 0, -- galerie fournie (§12), non éditable
position INTEGER NOT NULL DEFAULT 0, -- ordre dans les menus
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
archived_at TEXT,
deleted_at TEXT, -- soft-delete, corbeille unifiée
sync_version INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX idx_templates_ws_kind ON templates(workspace_id, kind) WHERE deleted_at IS NULL;
CREATE INDEX idx_templates_target ON templates(target_collection_id) WHERE kind='row';
CREATE UNIQUE INDEX uq_templates_default
ON templates(target_collection_id) WHERE is_default=1 AND deleted_at IS NULL AND archived_at IS NULL;
```
#### 9.2.1 Contenu de `manifest_json`, par genre
Le manifeste décrit **tout ce qui n'est pas le corps de blocs** (le corps vit dans la page-support, P2) :
```jsonc
// kind = 'row'
{
"format": "flowdeck-template", "version": 1,
"property_defaults": { "Status": "À trier", "Priority": "P2",
"DueDate": "{{date:+7d}}", "Assignee": ["{{me}}"] },
"title_template": "Bug — {{titre}}"
}
// kind = 'page'
{ "format": "flowdeck-template", "version": 1,
"title_template": "Revue du {{today}}", "full_width": false, "font_small": false }
// kind = 'database'
{ "format": "flowdeck-template", "version": 1,
"collection": { "name": "Projets", "icon": "🗂",
"schema_json": { /* schéma déclaratif existant */ },
"properties": [ /* définitions collection_properties */ ],
"views": [ { "view_type": "board", "config_json": { } } ],
"is_task": true, "task_mapping": { } },
"seed_rows": [ { "template_ref": "tpl_…", "variables": { } } ], // lignes d'exemple, optionnel
"child_pages": [ { "title": "Guide", "content_page_id": 0 } ] } // pages compagnes, optionnel
// kind = 'blocks'
{ "format": "flowdeck-template", "version": 1, "insert_mode": "at_cursor" }
```
### 9.3 La page-support — éditer un template comme une page (E4)
Chaque template natif de genre `page`, `row` ou `blocks` possède une **page-support** : une ligne ordinaire de `pages`, créée par le service, qui porte son contenu (blocs/markdown, `content_format`, icône, couverture). Quatre garde-fous la rendent invisible et sûre :
1. `pages.search_excluded = 1` (drapeau existant) et exclusion explicite de l'arbre sidebar, des récents, de la Library et de la FTS (les requêtes de ces surfaces filtrent déjà `search_excluded` pour la recherche ; le ticket T3-2 ajoute le filtre aux listes) ;
2. elle n'a **pas** de `parent_id` de navigation (racine technique du workspace), son accès passe **uniquement** par le registre : la route d'édition vérifie la visibilité du template (P6) avant de servir l'éditeur, l'URL directe de la page renvoie 404 aux non-ayants droit ;
3. ses **versions** (`page_versions`) donnent gratuitement l'historique du template ; la restauration d'une version = nouvelle version du template ;
4. à la suppression du template, la page-support suit le même soft-delete (et la même restauration).
Pour `kind='database'`, pas de page-support unique : le schéma vit dans `manifest_json` ; les éventuelles pages compagnes ont chacune leur page-support référencée dans le manifeste.
*Alternative écartée* : stocker les blocs directement dans `manifest_json`. Elle aurait imposé un éditeur de blocs dédié ou une sérialisation parallèle — contraire à P2/P7 — et perdu versions, temps réel et blocs synchronisés.
### 9.4 Tables `template_runs` et récurrences (migration 49)
```sql
CREATE TABLE template_recurrences (
template_id TEXT PRIMARY KEY REFERENCES templates(id),
rrule TEXT NOT NULL, -- sous-ensemble RRULE de recurrence.py
timezone TEXT NOT NULL, -- zoneinfo de l'utilisateur propriétaire
enabled INTEGER NOT NULL DEFAULT 1,
next_run_at TEXT, -- UTC, recalculé après chaque passage
last_run_at TEXT,
end_count INTEGER, -- miroir COUNT/UNTIL pour l'affichage
updated_by INTEGER REFERENCES users(id),
updated_at TEXT NOT NULL
);
CREATE TABLE template_runs (
id TEXT PRIMARY KEY,
template_id TEXT NOT NULL REFERENCES templates(id),
run_source TEXT NOT NULL CHECK (run_source IN
('ui','api','agent','automation','recurrence')),
actor_id INTEGER REFERENCES users(id), -- NULL = scheduler
occurrence_key TEXT, -- récurrence : '<template_id>@<occurrence ISO>'
created_type TEXT, -- page | row | collection | insertion
created_id TEXT,
status TEXT NOT NULL CHECK (status IN ('ok','error','undone')),
warnings_json TEXT NOT NULL DEFAULT '[]',
error TEXT,
idempotency_key TEXT,
created_at TEXT NOT NULL
);
CREATE UNIQUE INDEX uq_template_runs_occurrence
ON template_runs(occurrence_key) WHERE occurrence_key IS NOT NULL;
CREATE UNIQUE INDEX uq_template_runs_idem
ON template_runs(idempotency_key) WHERE idempotency_key IS NOT NULL;
```
`occurrence_key` unique = la déduplication des récurrences (même patron que `reminder_log` pour les rappels) ; `template_runs` sert aussi les compteurs du gestionnaire (§7.2) et l'audit.
### 9.5 Backfill des silos legacy (P8)
Fonction `TemplateService.backfill_registry()`, idempotente (clé : `source_kind + source_id`), exécutée au premier boot après la migration 48 et rejouable depuis l'admin :
| Source legacy | Ligne de registre créée |
|---|---|
| `database_templates` (seed `db_templates.py`) | `kind='database'`, `scope='system'` si issue du seed, `source_kind='legacy_database'`, manifeste construit depuis la définition du seed |
| `page_templates` (par base, récurrentes ou non) | `kind='row'`, `target_collection_id` de la base propriétaire, `source_kind='legacy_page'` ; la récurrence existante est transcrite dans `template_recurrences` (RRULE) et `next_run_at` recalculé |
| `page_global_templates` | `kind='blocks'`, `source_kind='legacy_global'`, page-support créée par copie des blocs |
| Presets de réunion (document Meetings) | `kind='page'`, `category='Réunion'`, `scope='system'` — simple enregistrement, le pipeline de réunion reste propriétaire de son exécution (§13.3) |
Tant qu'un adaptateur legacy n'a pas été remplacé par le natif (phase 2), `instantiate()` lit le contenu **depuis le silo d'origine** pour les lignes `source_kind != 'native'` : le registre catalogue, il ne déplace pas. La convergence des contenus vers le natif (pages-supports) se fait à la première **édition** du template via le nouvel éditeur, jamais par une migration silencieuse.
---
## 10. API et événements
### 10.1 API interne (cookie, consommée par le front)
```text
GET /api/templates?kind=&scope=&target_collection_id=&q=&include_archived=
GET /api/templates/{id} → DTO + aperçu du contenu (blocs résumés)
POST /api/templates → créer (vierge ou {from_page_id|from_row|from_collection})
POST /api/templates/{id}/duplicate
PATCH /api/templates/{id} → nom, description, icône, catégorie, portée, position
DELETE /api/templates/{id} → soft-delete (corbeille)
POST /api/templates/{id}/archive | /restore
PUT /api/templates/{id}/property-defaults → (row) manifeste des valeurs par défaut
PUT /api/templates/{id}/variables → variables déclarées
POST /api/templates/{id}/set-default → (row) défaut exclusif de la base
PUT /api/templates/{id}/recurrence → {rrule, timezone, enabled} ou null
POST /api/templates/{id}/preview → {variables, context} → contenu résolu, sans écrire
POST /api/templates/{id}/instantiate → {variables, context, Idempotency-Key} → §8.3 pas 8
GET /api/templates/{id}/runs → journal (gestionnaire)
GET /api/collections/{id}/templates → menu New ▾ (mince, délègue au service)
```
Erreurs au format des API internes existantes ; l'aperçu et l'instanciation renvoient les `warnings[]` (propriétés disparues, mentions dégradées) en plus du résultat.
### 10.2 API publique v2 (Bearer, parité avec l'interne)
Même contrat sous `/api/v2/templates…` (scopes `read`/`write`), construit sur `templates_io` existant : liste/détail en `read` ; instantiate/duplicate en `write` ; **export** `GET /api/v2/templates/{id}/export` et **import** `POST /api/v2/templates/import` au format de l'annexe B (RFC 7807, idempotence, audit `api_audit_log`, rate limiting par jeton — tout le contrat §4.1 de l'architecture s'applique sans exception). L'outil agent et les automatisations n'utilisent **pas** HTTP : ils appellent le service en processus (P4).
### 10.3 Événements
Nouveaux événements au catalogue des webhooks sortants : `template.created`, `template.updated`, `template.applied`, `template.deleted`, `template.recurrence_fired`, `template.recurrence_failed`. Le bus d'automatisations reçoit `template.applied` via `fire_event()` (patron `form.submitted`, `meeting.summarized`).
---
## 11. Templates de base de données : défaut, récurrence, bases liées
### 11.1 Template par défaut
- Au plus **un** défaut actif par base (index unique partiel §9.2). `set_default` est transactionnel : il retire l'ancien défaut et pose le nouveau dans la même transaction.
- Le défaut s'applique : au bouton *New* principal, à la création par l'agent sans template précisé **uniquement si** l'utilisateur l'a demandé ainsi (l'agent ne doit pas changer de comportement silencieusement — par défaut l'outil garde son comportement actuel : pas de template sauf demande), et aux créations par automatisation `create_page` sur la base **si** l'étape coche « appliquer le template par défaut » (défaut : non coché). Ces deux règles évitent le piège classique du défaut qui se met à créer du contenu partout.
- Retirer le défaut : menu *•••* du template → *Retirer le défaut*, ou réglage dans le panneau du template.
### 11.2 Récurrence
- Réglage depuis le menu *•••* du template (base ou gestionnaire) → **éditeur RRULE** reprenant les contrôles des récurrences de propriétés existantes (fréquence, intervalle, jours, fin par date/nombre), stocké dans `template_recurrences`, fuseau de l'utilisateur qui règle.
- Exécution : le scheduler mutualisé (60 s, celui des rappels et des agents planifiés) appelle `run_due_recurrences()` : pour chaque récurrence due et activée, calcul de l'occurrence par `recurrence.py`, instanciation avec `run_source='recurrence'` et `occurrence_key` dédupliqué, recalcul de `next_run_at`. Un échec journalise `template.recurrence_failed`, notifie le propriétaire (notification in-app, patron rappels) et **ne boucle pas** : le prochain passage est la prochaine occurrence, pas un retry immédiat.
- Le gestionnaire et le menu de base affichent l'état (« ↻ Lun. 9 h — prochain : 12 oct. ») et permettent pause/reprise/suppression de la récurrence sans toucher au template.
- Une récurrence dont la base cible est archivée/supprimée se désactive elle-même (et le journal le dit).
### 11.3 Bases liées et vues
Les templates appartiennent à la base **source** ; une base liée les expose en lecture/application (création dans la source), jamais en édition depuis la liée. Les `view_hints` (§8.2) couvrent Board (propriété de regroupement), Calendar (propriété de date), Gallery/List (aucun hint) ; en vue Table sans hint, défaut et valeurs du template s'appliquent tels quels.
---
## 12. Galerie système et templates fournis
Sur le patron de la galerie de skills (presets installables, §16.3 de l'architecture) :
- Les `database_templates` du seed actuel deviennent les premiers templates `scope='system'`, `kind='database'`, **sans changement de contenu**.
- Nouveaux presets `kind='page'` fournis (contenus courts, en français comme l'UI) : *Note de réunion* (alignée sur les presets du document Meetings), *Revue hebdo*, *Spécification produit*, *Rapport de bug* (en `kind='row'` de démonstration sur une base créée par le preset *Projets*), *Journal quotidien* (avec `{{today}}` dans le titre), *Décision (ADR)*, *1:1* — la liste exacte est un livrable du ticket T4-1, validée par Bruno.
- Un template système n'est jamais édité en place : *Dupliquer pour modifier* crée une copie `scope='personal'` (ou workspace selon le rôle). « Installer » un preset de page = le dupliquer dans la portée de l'utilisateur ; les presets de base s'utilisent directement (l'instanciation crée une base neuve, elle ne modifie pas le preset).
- Les mises à jour de presets entre versions de Flowdeck ne touchent que les lignes `is_system=1` jamais dupliquées.
---
## 13. Intégrations : agent, automatisations, réunions, recherche
### 13.1 Agent Flowdeck
L'outil `apply_template` du `ToolRegistry` est **rebranché** sur `TemplateService.instantiate()` (même validation, même journal `template_runs` avec `run_source='agent'`, en plus du journal `agent_actions` et de son snapshot d'undo existants). Ajouter un outil de lecture `list_templates` (lecture seule, filtré par les permissions de l'utilisateur appelant — l'agent n'agit jamais au-delà, §6.1 de l'architecture) pour que l'agent puisse proposer le bon template au lieu de l'exiger nommé. `AgentPolicies.check_tool()` s'applique aux deux, comme à tout outil.
### 13.2 Automatisations
Nouveau type d'action de step : **`create_from_template`** (9e type, après les 8 existants §19.1 de l'architecture) : paramètres `{template_id, variables figées ou mappées depuis le trigger, cible}`. Elle hérite de tout le moteur (conditions, délais, journal de run). Le bloc `button` de l'éditeur et la propriété `button` des bases peuvent la déclencher via leurs automatisations existantes — c'est la réponse Flowdeck au « template button » de Notion (§3.4), sans nouveau mécanisme.
### 13.3 Réunions
Les presets de notes de réunion existants (standup, 1:1…) sont **enregistrés** au registre (§9.5) pour être découvrables dans le sélecteur et le gestionnaire, mais le pipeline *AI Meeting Notes* (bloc `meeting`, `run_processing()`) reste le propriétaire de leur exécution : le template de réunion, appliqué depuis le sélecteur, crée la page avec le bloc `meeting` pré-configuré du bon preset — pas de second moteur de résumé.
### 13.4 Design System et canevas
Les canevas `design_system` (`block_templates.py`) sont des templates de blocs spécialisés (mise en page). Phase 5 : les enregistrer au registre (`kind='blocks'`, `category='Design System'`) pour les rendre trouvables dans le sélecteur, en gardant leur service d'insertion actuel comme adaptateur — même patron que les silos legacy.
### 13.5 Recherche et palette
Ctrl+K : les templates visibles par l'utilisateur deviennent cherchables par nom (requête sur le registre, pas d'index FTS dédié nécessaire à cette échelle), avec l'action *Appliquer* / *Ouvrir dans le gestionnaire*. La recherche sémantique/Ask AI n'indexe **pas** les pages-supports (cohérent avec leur exclusion, §9.3).
---
## 14. Sécurité, permissions et vie privée
- **Visibilité par portée** : `personal` → propriétaire seul ; `workspace` → membres du workspace ; `teamspace` → membres du teamspace (hors teamspace privé : 404, patron existant) ; `system` → tous les utilisateurs de l'instance, lecture seule.
- **Écriture** : créer/éditer un template `workspace` exige le rôle éditeur du workspace ; pour un `kind='row'`, éditer le template (et surtout changer le défaut ou la récurrence) exige le droit d'édition **de la base cible** — pas seulement du workspace.
- **Application** : double contrôle (lire le template, écrire la cible), §8.3 pas 1. Un template ne sert jamais de cheval de Troie : les propriétés par défaut qui référencent des personnes/pages inaccessibles à l'applicateur sont dégradées en avertissements (§8.3 pas 4-5).
- **Page-support** : jamais exposée par les API de navigation/recherche/Library ; les endpoints de l'éditeur vérifient l'appartenance au registre avant de servir son contenu (ticket T3-2, test dédié §17).
- **Contenu importé** : l'import de manifeste (annexe B) valide le schéma, borne la taille et la profondeur (sous-pages/blocs imbriqués), et neutralise les blocs à risque comme le fait l'import existant (SSRF sur `embed`/`bookmark` : gardien `url_fetch` existant). Un template importé arrive en `scope='personal'`, jamais `system`.
- **Audit** : créations/modifications via l'API interne et v2 suivent les journaux existants (`api_audit_log` en v2) ; `template_runs` couvre les instanciations, y compris celles du scheduler (acteur système explicite).
---
## 15. Plan d'implémentation par phases et tickets
Ordre contraignant : **0 diagnostic → 1 registre + sélecteur minimal → 2 gestion → 3 éditeur → 4 galerie/récurrences/intégrations**, phase 5 optionnelle. Chaque ticket est livrable et testable séparément ; les critères d'acceptation sont cumulatifs avec §17.
### Phase 0 — Diagnostic du bouton cassé (prérequis, petit)
| Ticket | Contenu | Acceptation |
|---|---|---|
| T0-1 | **Audit de l'existant réel** : inventorier dans le code les handlers de la rangée *Get started with*, les routes `page-templates` (board) et `templates_io` (v2), et les schémas exacts des trois tables legacy (colonnes réelles, via `docs/DATA_MODEL.md` et la base) ; consigner les écarts avec §4.1 | Note d'audit jointe au dépôt ; tout écart avec ce document est signalé avant la phase 1 |
| T0-2 | **Diagnostic H1→H6** (§4.3) dans l'ordre, avec preuves (console, réseau, `elementFromPoint`, comparaison de version SW) | Cause racine consignée par écrit ; si aucune hypothèse ne se confirme, escalade à Bruno avec les traces — pas de correction spéculative |
| T0-3 | **Test de reproduction Playwright** : ouvrir une page vide, cliquer la pilule *Templates*, attendre un panneau visible ; le test échoue avant correctif, passe après | Test rouge→vert pour la cause du T0-2 ; intégré à la suite E2E existante |
| T0-4 | **Correctif minimal de la cause** + garde-fou P5 : en attendant la phase 1, le clic ouvre un panneau d'état du catalogue (même vide, avec *Créer un template* désactivé proprement si la création n'existe pas encore — jamais un no-op) | Parcours manuel §17.4, étape 1 |
### Phase 1 — Registre unifié, service d'instanciation, sélecteur minimal (MVP utilisable)
| Ticket | Contenu | Acceptation |
|---|---|---|
| T1-1 | Migrations **48** (`templates`) et **49** (`template_recurrences`, `template_runs`) — §9 | Migrations appliquées sur une copie de base v7.69.8 ; idempotentes ; rollback documenté |
| T1-2 | `TemplateService` : `list/get/create/update/duplicate/archive`, adaptateurs legacy (§9.5), backfill idempotent rejouable | Backfill sur données du seed : registre complet, zéro doublon au second passage |
| T1-3 | `instantiate()` transactionnel pour `page` et `row` (§8.3), résolution des variables système (§8.4), validation des propriétés, journal `template_runs`, idempotence | Tests unitaires §17.2 ; un échec en cours de création ne laisse aucun objet partiel |
| T1-4 | API interne §10.1 (liste, détail, preview, instantiate) ; API v2 : liste/détail/instantiate | Contrat testé ; erreurs RFC 7807 en v2 |
| T1-5 | **Sélecteur minimal** (§7.1, sans l'aperçu riche : liste + recherche + variables déclarées en formulaire simple) branché sur : pilule *Templates* de page vide (**remplace le panneau d'état du T0-4**), menu *•••* de page, *New ▾* de base en lecture | Le parcours directeur de Bruno (§2.3, persona 1) fonctionne de bout en bout |
| T1-6 | Rebranchement de l'outil agent `apply_template` sur le service + outil `list_templates` (§13.1) | Un run agent créant depuis un template apparaît dans `template_runs` et dans `agent_actions` |
### Phase 2 — Gestionnaire et gestion par base
| Ticket | Contenu | Acceptation |
|---|---|---|
| T2-1 | Page `/templates` (§7.2) : liste filtrable (genre, portée, base, archivés), recherche, tri, sélection, side peek d'aperçu, compteurs depuis `template_runs` | Toutes les opérations de gestion (§2.1) faisables sans SQL ni API manuelle |
| T2-2 | Onglet **Templates** dans la Library (mêmes données, patron Library) + entrée **Templates** dans la sidebar (panneau Home, sous *Library*, respectant `sidebar_config`) | L'onglet et la page affichent des données identiques |
| T2-3 | Création depuis le gestionnaire : vierge (page/row/blocks), *depuis une page/ligne/base existante* (`create_from_*`), import JSON (annexe B) | Un template créé depuis une page existante reproduit fidèlement titre/icône/blocs à l'instanciation |
| T2-4 | Menu *New ▾* complet (§7.3) : défaut marqué, *•••* par template, *+ Nouveau template…* (crée et ouvre l'éditeur phase 3 en mode minimal : nom + propriétés par défaut), *Gérer les templates…* filtré sur la base | Le défaut s'applique par *New* ; l'exclusivité du défaut tient en concurrence (deux réglages simultanés) |
| T2-5 | Portées et partage : réglage de portée dans le gestionnaire, contrôles §14 (dont 404 hors teamspace privé) | Matrice de tests §17.3 verte |
### Phase 3 — Éditeur de template complet
| Ticket | Contenu | Acceptation |
|---|---|---|
| T3-1 | Page-support à la création des templates natifs (§9.3) + route d'édition avec bandeau de mode et bouton *Terminé* (§7.4) | Éditer un template = éditer une page ; historique des versions visible et restaurable |
| T3-2 | Masquage complet de la page-support : arbre, récents, Library, recherche, Ask AI, backlinks ; 404 direct hors ayants droit | Tests dédiés §17.3 ; aucune fuite du contenu d'un template personnel via la recherche d'un autre utilisateur |
| T3-3 | Panneau de réglages : métadonnées, portée, **propriétés par défaut** avec les éditeurs de cellule de `DBInstance` (row), **variables déclarées** (CRUD + aperçu de résolution), défaut | L'aperçu du sélecteur (T3-5) reflète exactement ce qui sera créé |
| T3-4 | Assistant d'édition `kind='database'` (schéma, vues, lignes d'exemple) ; édition `kind='blocks'` en mode blocs seuls | Un template de base édité crée une base conforme au schéma affiché |
| T3-5 | **Aperçu résolu** dans le sélecteur (§7.1) : contenu résolu, chips de propriétés, avertissements ; modes *ajouter / remplacer* sur page existante avec confirmation explicite pour *remplacer* | Aucun `{{token}}` non résolu ne peut atteindre l'objet créé (§8.4) |
| T3-6 | Application après coup à une ligne existante (§3.1) : depuis la page de ligne, *Appliquer un template…* (propriétés : ne remplit que les vides par défaut, case « écraser » explicite ; corps : ajouté à la fin) | Comportement par défaut non destructif, testé |
### Phase 4 — Récurrences, galerie système, automatisations
| Ticket | Contenu | Acceptation |
|---|---|---|
| T4-1 | Galerie système (§12) : presets convertis + nouveaux presets validés par Bruno, badges, *Dupliquer pour modifier* | Un nouvel utilisateur trouve ≥ 5 templates utilisables dès l'installation |
| T4-2 | Récurrences complètes (§11.2) : éditeur RRULE, affichage « prochain passage », pause/reprise, branchement scheduler + déduplication + notification d'échec | Une tâche hebdo de test se crée à l'heure dite, une seule fois, même si le scheduler redémarre entre-temps |
| T4-3 | Action d'automatisation `create_from_template` (§13.2) + option « template par défaut » des actions `create_page` (§11.1) | Un bouton de page et un bouton de base déclenchent chacun une création depuis template, journalisée |
| T4-4 | Enregistrement des presets de réunion au registre (§13.3) ; export/import v2 complets (§10.2) ; événements webhooks §10.3 | Export d'un template → import sur une autre instance → instanciation identique |
### Phase 5 — Extensions (optionnelle)
| Chantier | Contenu |
|---|---|
| A | *Dupliquer comme template* sur les pages publiées de l'instance (réglage dans *Share → Publish*, bouton *Duplicate* sur la page publique, copie vers le workspace du visiteur connecté) |
| B | Canevas Design System enregistrés au registre (§13.4) |
| C | Templates de dashboard (une mise en page `collection_dashboards` comme contenu de template de base) — à coordonner avec le document Vues (E4 Dashboard) |
| D | Suggestions de templates par l'agent (« cette page ressemble à un format récurrent, l'enregistrer comme template ? ») — proposition uniquement, jamais de création silencieuse |
---
## 16. Exigences non fonctionnelles, résilience, déploiement
- **Performance** : liste du registre filtrée < 100 ms à 1 000 templates (index §9.2) ; le sélecteur ne charge jamais les corps de blocs en liste (aperçu à la demande, un template à la fois) ; instanciation d'un template de page < 500 ms hors pièces jointes.
- **Dégradation** (§8.3 pas 4) : schéma de base ayant dérivé depuis la création du template → appliquer ce qui reste valide + avertissements nommés ; jamais d'échec total pour une propriété renommée, jamais d'application silencieusement partielle sans avertissement.
- **Hors ligne** : le sélecteur en mode offline affiche les métadonnées mises en cache mais **désactive** l'instanciation avec un message explicite (les écritures restent à la file de sync existante seulement pour les objets ordinaires ; une instanciation n'est pas rejouable telle quelle hors ligne). Le bouton ne doit en aucun cas redevenir « mort » hors ligne — c'est un cas de test (§17).
- **Fichiers et médias** : un template référençant des uploads (images, pièces jointes) les **copie** à l'instanciation (nouveaux fichiers), sur le patron de la duplication de page existante ; jamais de référence partagée dont la suppression du template casserait les objets créés.
- **Déploiement** : aucun nouveau service, aucune dépendance ; migrations dans le flux de boot existant ; le backfill est journalisé (nombre de lignes par `source_kind`) et son échec ne bloque pas le démarrage (le registre reste vide, les silos legacy continuent de servir leurs anciennes surfaces — dégradation, pas panne).
---
## 17. Tests et critères d'acceptation
### 17.1 Régression du bug d'origine
- Le test Playwright du T0-3 reste dans la suite : page vide → clic *Templates* → sélecteur visible. Variantes : catalogue vide, hors ligne, utilisateur sans aucun template personnel. **Dans tous les cas, un panneau visible.**
### 17.2 Tests unitaires / d'intégration du service
- Résolution des variables : système (dates au fuseau de l'utilisateur), déclarées (requise manquante → erreur avant écriture ; défaut appliqué), offsets de dates, `{{me}}` dans une propriété `person`.
- Aucun token `{{…}}` survivant dans un objet créé (assertion sur titre, propriétés et blocs).
- Propriétés par défaut : valide, renommée (avertissement), type changé (avertissement), relation vers cible inaccessible (dégradée).
- Transaction : panne simulée au milieu de la création d'une base depuis template → ni base ni lignes orphelines ; `template_runs` en `error`.
- Idempotence : double `instantiate` même clé → un seul objet, réponse rejouée.
- Défaut : exclusivité sous concurrence ; *New* applique le défaut ; bases liées : lecture des templates de la source.
- Récurrence : occurrence calculée au bon fuseau ; redémarrage du scheduler entre deux passages → pas de doublon (`occurrence_key`).
### 17.3 Tests de permissions
Matrice portée × rôle (propriétaire / membre workspace / membre teamspace / extérieur / agent au nom de chacun) sur : voir dans le sélecteur, éditer, changer le défaut et la récurrence, appliquer, accéder à l'URL directe de la page-support, chercher le contenu d'un template personnel d'autrui.
### 17.4 Parcours manuel de vérification (à exécuter par Hermes, résultat consigné)
1. Page vide → *Templates* → le sélecteur s'ouvre (le bug d'origine, refermé).
2. Créer un template de page depuis la page courante ; l'appliquer à une nouvelle page ; vérifier titre daté résolu et blocs.
3. Dans une base de tâches : créer deux templates de ligne, en définir un comme défaut, vérifier *New* vs *New ▾*.
4. Rendre un template récurrent (quotidien) ; forcer le passage du scheduler ; vérifier **une** ligne créée, datée du jour.
5. Ouvrir le gestionnaire `/templates` et l'onglet Library : mêmes données, compteurs à jour.
6. Demander à l'agent de créer une note depuis un template nommé ; vérifier `template_runs` (`run_source='agent'`) et l'undo.
---
## 18. Décisions d'architecture (ADR — résumé)
| # | Décision | Alternative écartée | Motif |
|---|---|---|---|
| ADR-T1 | **Registre `templates` fédérant les silos**, contenus conservés à leur place puis convergés à l'édition | Fusion immédiate des trois tables en une seule | Zéro perte, livrable en phase 1, conforme à P8 |
| ADR-T2 | **Contenu des templates de page/ligne dans une page-support masquée** | Blocs sérialisés dans `manifest_json` | Réutilise éditeur, versions, temps réel ; aucun éditeur parallèle (P2, P7) |
| ADR-T3 | **Instanciation unique dans `TemplateService`**, HTTP seulement pour les surfaces externes | Logique par surface (UI, agent, scheduler) | L'invariant « une seule implémentation » déjà appliqué à l'API agent de Flowdeck |
| ADR-T4 | **Variables `{{…}}` figées à l'instanciation**, distinctes des tokens de rendu `[[…]]` | Résolution au rendu | Un objet créé depuis un template doit être stable dans le temps (P3) |
| ADR-T5 | **Récurrence portée par le template** (table dédiée + scheduler 60 s + dédup par `occurrence_key`) | Une automatisation générée par récurrence | Le réglage « Repeat » de Notion est une propriété du template ; le patron rappels prouve le mécanisme dans Flowdeck |
| ADR-T6 | **Gestionnaire = page `/templates` + onglet Library**, pas un écran Settings | Réglages dans Settings | Les templates sont des objets de travail quotidiens, pas de la configuration ; Library est le patron « toutes mes ressources » |
| ADR-T7 | **Le défaut ne s'applique pas implicitement à l'agent ni aux automatisations existantes** (opt-in explicite, §11.1) | Défaut global à toute création | Éviter qu'un réglage d'UI change silencieusement le comportement de systèmes déjà en production chez Bruno |
---
## 19. Risques et mitigations
| Risque | Mitigation |
|---|---|
| La cause du bouton cassé est plus profonde (fragment partagé par d'autres pilules de la rangée) | Phase 0 avant tout ; le test T0-3 couvre la rangée entière, pas seulement *Templates* |
| Fuite de contenu via la page-support (recherche, backlinks, partage) | Garde-fous §9.3 + matrice §17.3 ; revue spécifique du ticket T3-2 |
| Dérive des schémas de bases rendant les templates de ligne partiellement inapplicables | Règle de dégradation §16 + avertissements dans l'aperçu et le résultat ; le gestionnaire signale les templates « à revoir » (propriété par défaut introuvable) |
| Doublons de récurrence après redémarrage ou double instance | `occurrence_key` unique + scheduler mono-processus existant |
| Confusion des deux sens du mot « template » (Jinja vs métier) chez les contributeurs | Convention de nommage §4.4, à ajouter à `AGENTS.md` du dépôt Flowdeck |
| Le périmètre gonfle vers un marketplace | Hors périmètre §2.2 ; la phase 5 est explicitement optionnelle et fermée |
---
## 20. Questions ouvertes pour Bruno
1. **Presets système** : la liste de départ du §12 (7 presets) te convient-elle, ou veux-tu tes propres formats dès la phase 4 (par ex. tes formats de notes Flowdeck/ObsiGate) ?
2. **Portée par défaut d'un nouveau template** : `personal` (sûr, mais invisible aux autres membres) ou `workspace` (pratique en équipe) ? Proposition du document : `personal`, promotion explicite — à confirmer vu que tu es souvent seul sur l'instance.
3. **Application sur page non vide** : le mode par défaut du sélecteur doit-il être *ajouter à la fin* (proposition, non destructif) ou demander à chaque fois ?
4. **Récurrence et défaut des bases de tâches liées à My Tasks** : un template récurrent sur une base de tâches doit-il hériter du mapping `is_task` (assigné/statut/échéance) automatiquement ? Proposition : oui, via les `view_hints` et le mapping existant, sans réglage supplémentaire.
5. **Nom de la route du gestionnaire** : `/templates` est proposé ; si une route legacy `page-templates` sous `/board` doit rester l'URL canonique pour compatibilité de liens existants, le signaler avant la phase 2.
---
## Annexe A — Correspondance Notion → Flowdeck
| Notion | Flowdeck (cible) |
|---|---|
| Template de base (propre à la base) | Template `kind='row'` + `target_collection_id` (§9.2) |
| Menu *New ▾* : *+ New template*, *•••* (Edit / Duplicate / Set as default / Repeat / Delete) | Menu *New ▾* §7.3, mêmes actions, mêmes libellés |
| Édition du template comme une page | Page-support + bandeau de mode (§7.4, §9.3) |
| Template par défaut | `is_default` exclusif par base (§11.1) |
| *Repeat* (daily/week/month/year/custom) | `template_recurrences` RRULE + scheduler (§11.2) |
| Mentions dynamiques `@today/@now/@me` dans le titre | Variables système `{{today}}/{{now}}/{{me}}` (§8.4) |
| Galerie *Templates* de la sidebar | Gestionnaire `/templates` + onglet Library (§7.2) + galerie système (§12) |
| Page publiée « Allow duplicate as template » | Phase 5, chantier A (§15) |
| Boutons (ex-template button) | Bloc/propriété `button` + action d'automatisation `create_from_template` (§13.2) |
| Appliquer un template à une page existante | Sélecteur en mode ajout/remplacement (§7.1) ; lignes : T3-6 |
## Annexe B — Format de manifeste `flowdeck-template` v1
Transport d'export/import (API v2 §10.2) — un objet JSON autonome :
```jsonc
{
"format": "flowdeck-template",
"version": 1,
"kind": "row",
"name": "Rapport de bug",
"description": "Structure standard d'un rapport de bug",
"icon": "🐞",
"category": "Support",
"target": { "match_by": "collection_name", "collection_name": "Support" },
// à l'import, la base cible est résolue par nom dans le workspace choisi,
// jamais par id externe ; si absente, l'import crée le template « orphelin »
// en scope personnel et le signale.
"variables": [ { "name": "titre", "type": "text", "required": true } ],
"manifest": { "property_defaults": { "Status": "À trier" },
"title_template": "Bug — {{titre}}" },
"content": { "content_format": "blocks", "blocks": [ /* blocs de la page-support */ ] },
"recurrence": { "rrule": "FREQ=WEEKLY;BYDAY=MO", "timezone": "America/Toronto" } // optionnel
}
```
Règles : `version` supérieure non reconnue → refus explicite ; les `content_page_id` et identifiants internes ne voyagent jamais ; les blocs synchronisés sont exportés **matérialisés** (contenu copié).
## Annexe C — Glossaire
- **Template** : objet réutilisable du registre, de genre page, ligne, base ou blocs.
- **Page-support** : page technique masquée portant le contenu d'un template (§9.3).
- **Instanciation** : création d'un objet réel depuis un template, par `TemplateService.instantiate()` (§8.3).
- **Sélecteur** : panneau de choix/aperçu/application d'un template (§7.1).
- **Gestionnaire** : écran de gestion des templates, page `/templates` et onglet Library (§7.2).
- **Variable système / déclarée** : valeur résolue automatiquement / demandée à l'utilisateur, à l'instanciation (§8.4).
- **Run** : une instanciation journalisée dans `template_runs` (§9.4).
## Sources
Analyse fonctionnelle de Notion (section 3), documentation publique officielle consultée le 10 octobre 2026 :
- *Using database templates* — https://www.notion.com/help/guides/using-database-templates
- *Using database templates to help cement your team's process* — https://www.notion.com/help/guides/using-database-templates-for-teams
- *Automate work with repeating database templates* — https://www.notion.com/help/guides/automate-work-repeating-database-templates
- *Getting started with templates for your team* — https://www.notion.com/help/guides/getting-started-with-templates-for-your-team
État Flowdeck : `ARCHITECTURE.md` v7.69.8 fourni par Bruno (§§ cités dans le texte) et capture de l'état actuel fournie le 10 octobre 2026 (`templates-notion-reference/capture-etat-actuel-bouton-templates.png`).
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,56 @@
# Flowdeck Agents & Skills — Phase 1 : découpage en tickets prêts à développer
_Dérivé du document d'architecture v1.0 (`architecture-agents-skills-notion-flowdeck.md`, §20 Phase 1). Chaque ticket cite la section source. Estimations relatives (S/M/L), pas des engagements. Périmètre strict de la Phase 1 : « le skill devient une page » (écarts E1, E3, E4 côté modèle). Le routeur automatique (§13.2), `agent_runs` (migration 40) et la personnalisation de l'Agent personnel sont en Phase 2 et ne sont **pas** dans ce découpage._
## Critère de sortie de la phase (§20)
UC-02 (skill manuel dans le chat : `/` → choisir un skill → résultat au format du skill) et UC-04 (skill dans l'éditeur, sur sélection ou bloc) fonctionnent entièrement sur des pages ; un playbook existant devient un skill sans copier-coller.
## Avant de coder : 1 spike de décision
- **SPIKE-A — Skills intégrés et AI Writing** (§23 Q3) : les prompts des 6 actions d'`ai_writing.py` deviennent-ils littéralement les pages des skills intégrés (éditables par l'admin), le service restant seulement pour l'autocomplétion inline (latence) ? C'est la voie proposée par le document pour les quatre skills d'édition. _Sortie : une page de décision qui conditionne A1-13, pas du code._
## Modèle de données — migration 41 (§10.3, §10.4)
- **A1-1 (M)** Migration 41 — skills (§10.3) : `collections.is_skills_db` + correspondance des propriétés dans `collections.schema_json` (`Description` / `Files` / `Tags`) ; recréation `agent_skills` → `agent_skills_v2` par copie (`page_id` unique, `name_cached`, `description`, `files_json`, `tools_json`, `is_builtin`, `auto_use_default`, `editor_menu_default`, `status`, `source`). _Acceptation : migration transactionnelle ; aucun drapeau `is_skill` sur `pages` — être un skill = avoir une ligne `agent_skills` (décision §10.3 n°1)._
- **A1-2 (S)** Activation par utilisateur (§10.4) : table `user_skill_enablements` (`enabled`, `auto_use` et `editor_menu` en NULL = suit le réglage du skill). _Acceptation : désactiver pour soi ne modifie ni la page ni les réglages des autres utilisateurs._
- **A1-3 (M)** Migration du contenu existant (§10.3 n°3, §22) : chaque skill `agent_skills` sans page reçoit une page générée conservant le prompt d'origine à l'identique (`source='legacy'`) ; les 17 presets de la galerie deviennent des pages dans la base de skills privée de l'utilisateur à l'installation. _Acceptation : vérification par comptage et échantillon avant bascule (§22, risque n°1) ; aucun skill ne survit hors du modèle page._
- **A1-4 (S)** Indexation (§10.3 n°4) : titre + `description` indexés dans `semantic_embeddings` avec `resource_type='skill'` par le moteur d'indexation incrémental existant. _Acceptation : créer/modifier un skill met à jour son index sans réindexation globale — c'est l'index que lira le routeur en Phase 2._
## Page, base et bannière (§7.5, §13.1)
- **A1-5 (M)** Marquer / démarquer une page (§7.5, §13.1) : entrée `•••` → *Use as a skill* ; marquer = insérer la ligne `agent_skills`, décocher = la supprimer, le contenu de la page ne bouge jamais. Créateur activé automatiquement (`Enable for me` implicite).
- **A1-6 (M)** Bannière de skill (§7.5, maquette §7A.2) : nom, base, état activé pour moi, interrupteurs *Use automatically* / *Add to text editor menu*, bouton *Download for local agents*, rappel qu'un skill partagé en édition est mutable par ses éditeurs pour tous. Aucun nouvel éditeur : on écrit un skill comme une page.
- **A1-7 (M)** Base de skills (§7.5) : conversion d'une collection ordinaire via le dialogue de correspondance `Description` / `Files` / `Tags` (créer ou mapper), case « activer les pages existantes comme skills pour moi » ; gabarit *Database → Skills* à la création ; badge « Skills » sur la collection. Dans une base, le skill est la page-ombre de la ligne (`pages.collection_row_id`), `Description`/`Files` relues depuis `property_values_json`.
## Library (§7.4)
- **A1-8 (M)** Onglet Skills de la Library : table des skills activés/créés (Nom, Base, Description tronquée, *Use automatically*, *Menu éditeur*, Propriétaire, Dernière utilisation), actions *New skill*, ouvrir, désactiver pour soi ; recherche/filtres par nom, description, base, propriétaire, `Tags`.
- **A1-9 (S)** Sous-vue Discover (§7.4, §13.5) : skills accessibles en lecture mais non activés ; bouton **Enable for me** par ligne, confirmation immédiate. `Discover` = accès lecture sans ligne `user_skill_enablements` active ; le partage reste celui de la page/base, aucun nouveau droit.
## Exécution manuelle — Skill Runner minimal (§13.3, §11.3)
- **A1-10 (L)** Skill Runner, invocation manuelle seulement : construction de la consigne depuis la page rendue en Markdown (blocs éligibles §13.4, contenu imbriqué inclus) + extraits des fichiers de support `shareable` dans le budget + entrée du run ; contrainte d'outils en **intersection seulement** (§13.7 : un skill n'élargit jamais les outils, ne modifie pas les consignes système, ses fichiers sont des données, pas des instructions). La réponse nomme le skill utilisé.
- **A1-11 (M)** Surface chat — UC-02 (§9.2 pour l'éditeur, §11.3) : `POST /api/agent/skills/{id}/run-chat`, menu `/` alimenté par `GET /api/agent/skills/menu` (activés d'abord). _Acceptation UC-02 : `/` → Project Brief Writer → brief au format d'équipe, entièrement depuis la page du skill._
- **A1-12 (M)** Surface éditeur — UC-04 (§7.2, §13.4) : `POST /api/agent/skills/{id}/run-editor`, résultat en diff ou insertion ; éligibilité déclarée par type de bloc (`skill_eligible: bool` dans le registre des types) : texte, H1–H3, citation, callout, listes, toggle, image, bloc synchronisé. _Acceptation UC-04 : les skills personnels d'abord, puis les intégrés, dans le menu de sélection et le menu de bloc._
## Skills intégrés (§20, annexe C) — dépend de SPIKE-A
- **A1-13 (M)** Les 6 actions AI Writing exposées comme skills intégrés (`is_builtin=1`, seed, non supprimables mais désactivables) : Améliorer l'écriture · Corriger · Expliquer · Reformater · Traduire · Résumer ; unification des trois points d'entrée éditeur existants sur le Skill Runner (A1-10).
## Export / import `SKILL.md` (§13.6, §11.3)
- **A1-14 (M)** Export : sérialisation `SKILL.md` (front matter `name` + `description`, corps de page en Markdown) + bundle avec les fichiers de support `shareable=1` ; `GET /api/agent/skills/{id}/download`. Le format `flowdeck-skill` v1 (JSON) reste le format d'échange Flowdeck ↔ Flowdeck (ADR-08).
- **A1-15 (S)** Import : `POST /api/agent/skills/import` accepte `SKILL.md` externe et v1 ; un `SKILL.md` importé devient une page-skill dans la base privée de l'importateur, ses fichiers joints deviennent la propriété `Files`. Suivi de version : ligne `skill_local_downloads` (§10.4) à chaque téléchargement, badge « copie locale périmée » quand l'empreinte de page a changé. _Note : l'écriture directe chez les agents locaux est la question ouverte Q4 (§23) — la Phase 1 livre le téléchargement seul, pas d'utilitaire compagnon._
## API (§11.3) — récapitulatif des routes nouvelles livrées par les tickets ci-dessus
`POST|DELETE /api/agent/pages/{id}/skill` · `POST /api/agent/skills/from-page` · `GET /api/agent/skills/menu` · `PUT /api/agent/skills/{id}/enablement` · `POST …/run-chat` · `POST …/run-editor` · `GET …/download` · `POST /api/agent/skills/import` · `PUT /db/{id}/skills-db` · v2 : `GET /api/v2/skills` expose `page_id`, `description` et la sérialisation `SKILL.md` (`Accept: text/markdown`).
## Ordre conseillé
`SPIKE-A → A1-1 → A1-3 → A1-2/A1-4 → A1-5 → A1-6/A1-7 → A1-8/A1-9 → A1-10 → A1-11/A1-12 → A1-13 → A1-14/A1-15`
## Hors Phase 1 (ne pas ouvrir maintenant)
Routeur automatique et journal de décision (§13.2, Phase 2), migration 40 / `agent_runs` (Phase 2 limitée, Phase 3 complète), page « Mon Flowdeck AI » et instructions-page de l'Agent personnel (Phase 2), déclencheurs événementiels et Custom Agents autonomes (Phase 3), crédits / `ai_usage_ledger` migration 42 et espace fichiers migration 43 (Phase 4). Les questions Q1 (crédits), Q2 (modèle par défaut), Q5 (Slack vs messagerie existante) et Q6 (rétention des runs) ne bloquent **pas** la Phase 1.
+166 -3
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.57.0"
"version": "7.69.4"
},
"paths": {
"/auth/register": {
@@ -1098,7 +1098,7 @@
"dashboard"
],
"summary": "Help Page",
"description": "Comprehensive help & documentation page.",
"description": "Help & documentation page — settings-style panel with side navigation.",
"operationId": "help_page_help_get",
"responses": {
"200": {
@@ -15368,6 +15368,97 @@
}
}
},
"/api/pages/{page_id}/reactions": {
"get": {
"tags": [
"collaboration"
],
"summary": "List Reactions",
"description": "Réactions regroupées par plage de texte : {block_id, start, end, emoji, count, mine}.",
"operationId": "list_reactions_api_pages__page_id__reactions_get",
"parameters": [
{
"name": "page_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Page Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"post": {
"tags": [
"collaboration"
],
"summary": "Toggle Reaction",
"description": "Ajoute ou retire la réaction de l'utilisateur sur une plage de texte (toggle).",
"operationId": "toggle_reaction_api_pages__page_id__reactions_post",
"parameters": [
{
"name": "page_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Page Id"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/custom-emojis": {
"get": {
"tags": [
@@ -16299,6 +16390,78 @@
}
}
},
"/api/agent/plugins": {
"get": {
"tags": [
"agent"
],
"summary": "List Plugins Route",
"description": "Catalogue des plugins de l'instance (menu + → « Add plugins »).",
"operationId": "list_plugins_route_api_agent_plugins_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/api/agent/plugins/{slug}": {
"patch": {
"tags": [
"agent"
],
"summary": "Set Plugin Route",
"description": "Bascule un plugin : l'effet est réel (routes/UI/outils), pas un drapeau.",
"operationId": "set_plugin_route_api_agent_plugins__slug__patch",
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Slug"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/agent/connectors/{connector_id}": {
"patch": {
"tags": [
@@ -30372,4 +30535,4 @@
}
}
}
}
}
+163
View File
@@ -0,0 +1,163 @@
// Mobile regression: hamburger drawer, wheel scroll, settings side-nav, help page.
const { test, expect } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-pass-123';
// One login for the whole file (app rate limiter: 60 req/min per IP) — reused
// as storageState by every test.
let STATE = null;
test.beforeAll(async ({ browser }) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page.waitForURL('**/workspaces', { timeout: 10000 }).then(() => true).catch(() => false);
if (!ok) {
const resp = await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E Mobile' } });
if (!resp.ok() && resp.status() !== 409) throw new Error(`register ${resp.status()}`);
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
await page.waitForURL('**/workspaces', { timeout: 10000 });
}
STATE = await ctx.storageState();
await ctx.close();
});
async function login(page) {
// Session reused from the single beforeAll login (rate limiter: 60 req/min/IP)
await page.context().addCookies(STATE.cookies);
}
test.describe('mobile', () => {
test.use({ viewport: { width: 390, height: 844 } });
test('hamburger opens mobile sidebar drawer', async ({ page }) => {
const errs = [];
page.on('pageerror', (e) => errs.push(e.message));
await login(page);
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
const burger = page.locator('.hamburger-btn');
await expect(burger).toBeVisible();
// Sidebar starts off-canvas
const sidebar = page.locator('#sidebar');
const boxBefore = await sidebar.boundingBox();
expect(boxBefore.x).toBeLessThan(0);
await burger.click();
await expect(sidebar).toHaveClass(/mobile-open/, { timeout: 5000 });
const boxAfter = await sidebar.boundingBox();
expect(boxAfter.x).toBeGreaterThanOrEqual(0);
expect(errs).toEqual([]);
// Overlay tap closes it
await page.locator('.sidebar-overlay').click({ position: { x: 350, y: 400 } });
await expect(sidebar).not.toHaveClass(/mobile-open/, { timeout: 5000 });
});
test('mobile sidebar drawer scrolls with mouse wheel', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
await page.locator('.hamburger-btn').click();
await page.waitForSelector('#sidebar.mobile-open');
const sc = page.locator('.sidebar-scroll');
await expect.poll(async () => sc.evaluate((el) => el.scrollHeight > el.clientHeight), { timeout: 3000 }).toBe(true);
const box = await sc.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + Math.min(box.height / 2, 400));
await page.mouse.wheel(0, 300);
await expect.poll(async () => sc.evaluate((el) => el.scrollTop), { timeout: 3000 }).toBeGreaterThan(0);
});
test('settings page fits mobile viewport', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
const panel = page.locator('.settings-panel');
await expect(panel).toBeVisible();
const box = await panel.boundingBox();
expect(box.width).toBeLessThanOrEqual(390);
expect(box.height).toBeLessThanOrEqual(844);
// No horizontal page overflow
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow).toBeLessThanOrEqual(0);
});
test('settings mobile nav is a hamburger side drawer', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
const burger = page.locator('.settings-menu-btn');
await expect(burger).toBeVisible();
const nav = page.locator('.settings-nav');
const before = await nav.boundingBox();
expect(before.x + before.width).toBeLessThanOrEqual(1);
await burger.click();
await expect(nav).toHaveClass(/nav-open/);
// Nav item switches section AND closes the drawer
await page.locator('.settings-nav-item', { hasText: 'Tags' }).click();
await expect(nav).not.toHaveClass(/nav-open/);
await expect(page.locator('h2', { hasText: 'Tags' }).first()).toBeVisible();
// Re-open and dismiss via backdrop
await burger.click();
await expect(nav).toHaveClass(/nav-open/);
await page.locator('.settings-nav-backdrop').click({ position: { x: 375, y: 500 } });
await expect(nav).not.toHaveClass(/nav-open/);
});
test('help page uses settings-style side navigation', async ({ page }) => {
const errs = [];
page.on('pageerror', (e) => errs.push(e.message));
await login(page);
await page.goto(`${FD_BASE}/help`, { waitUntil: 'domcontentloaded' });
// Same panel skeleton as settings
await expect(page.locator('.settings-panel')).toBeVisible();
await expect(page.locator('.settings-menu-btn')).toBeVisible();
// Mobile: nav drawer behind hamburger
const nav = page.locator('.settings-nav');
await expect(nav).not.toHaveClass(/nav-open/);
await page.locator('.settings-menu-btn').click();
await expect(nav).toHaveClass(/nav-open/);
await page.locator('.settings-nav-item', { hasText: 'Databases' }).click();
await expect(nav).not.toHaveClass(/nav-open/);
await expect(page.locator('h2', { hasText: 'Databases & Views' })).toBeVisible();
// App-wide coverage: all sections exist in the nav
const sections = ['Getting Started', 'Pages & Editor', 'Databases & Views', 'Tasks & Dependencies',
'Workspaces & Forges', 'Collaboration', 'Library, Trash & Search', 'AI Agent & Automations',
'Integrations & API', 'Offline & PWA', 'Accounts & SSO', 'Keyboard Shortcuts', 'Tips'];
const labels = (await nav.locator('.settings-nav-item').allInnerTexts()).join(' ');
for (const s of sections) expect(labels, s).toContain(s);
expect(errs).toEqual([]);
});
});
test.describe('desktop', () => {
test.use({ viewport: { width: 1440, height: 900 } });
test('settings and help keep the 2-column layout on desktop', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.settings-menu-btn')).toBeHidden();
const nav = page.locator('.settings-nav');
await expect(nav).toBeVisible();
const navBox = await nav.boundingBox();
expect(navBox.height).toBeGreaterThan(navBox.width);
await page.goto(`${FD_BASE}/help`, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.settings-menu-btn')).toBeHidden();
await page.locator('.settings-nav-item', { hasText: 'SSO' }).click();
await expect(page.locator('h2', { hasText: 'Accounts & SSO' })).toBeVisible();
await expect(page.locator('.settings-content')).toContainText('IndexedDB');
});
});
+37
View File
@@ -0,0 +1,37 @@
const { test } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
test.use({ viewport: { width: 390, height: 844 } });
test('diagnose mobile sidebar wheel scroll', async ({ page }) => {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', process.env.FD_USER || '[email protected]');
await page.fill('#password', process.env.FD_PASS || 'e2e-pass-123');
await page.click('.btn-primary');
await page.waitForURL('**/workspaces', { timeout: 10000 }).catch(() => {});
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.querySelector('.hamburger-btn'));
await page.locator('.hamburger-btn').click();
await page.waitForSelector('#sidebar.mobile-open');
const info = await page.evaluate(() => {
const sb = document.getElementById('sidebar');
const sc = document.querySelector('.sidebar-scroll');
const cs = getComputedStyle(sb);
const ccs = sc ? getComputedStyle(sc) : null;
return {
sidebar: { display: cs.display, height: cs.height, overflow: cs.overflow, sh: sb.scrollHeight, ch: sb.clientHeight },
scroll: sc ? { top: sc.scrollTop, sh: sc.scrollHeight, ch: sc.clientHeight, oy: ccs.overflowY, flex: ccs.flex, rect: JSON.stringify(sc.getBoundingClientRect()) } : null,
wheelTarget: (() => { const el = document.elementFromPoint(150, 400); return el ? el.className : 'none'; })(),
};
});
console.log('DIAG:', JSON.stringify(info, null, 1));
await page.mouse.move(150, 400);
await page.mouse.wheel(0, 300);
await page.waitForTimeout(300);
const after = await page.evaluate(() => {
const sc = document.querySelector('.sidebar-scroll');
return { scTop: sc.scrollTop, scSH: sc.scrollHeight, scCH: sc.clientHeight, bodyTop: document.scrollingElement.scrollTop };
});
console.log('AFTER WHEEL:', JSON.stringify(after));
});
@@ -0,0 +1,121 @@
/**
* /local-workspace — le chemin du header suit la navigation dans les
* dossiers, et une navigation partielle vers un sous-dossier remonte la
* page Alpine (avant : « Cannot redefine property: $nextTick » → tous les
* composants restés visibles à la fois).
*
* Parcours : sidebar Docs → sidebar Sub → clic dans la vue sur Leaf →
* breadcrumb interne Docs → crumb workspace du header.
*/
const { test, expect } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const FD_PASS = process.env.FD_PASS || 'e2e-secret-123';
test('local-workspace: chemin header + navigation dossiers (partielle et interne)', async ({ page }) => {
test.setTimeout(180000);
const errors = [];
page.on('console', m => { if (m.type() === 'warning' && m.text().includes('Cannot redefine')) errors.push(m.text()); });
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
// ── 1. Utilisateur + workspace + arborescence Docs/Sub/Leaf ──
await page.goto(`${FD_BASE}/auth/login?provider=local`);
await page.click('button:has-text("Register")');
await page.fill('#name', 'LWBC');
await page.fill('#email', 'lwbc' + Date.now() + '@flowdeck.local');
await page.fill('#password', FD_PASS);
await page.click('.btn-primary');
await page.waitForURL('**/workspaces', { timeout: 20000 });
await page.waitForTimeout(800);
await page.keyboard.press('Escape').catch(() => {});
const ids = await page.evaluate(async () => {
const csrf = decodeURIComponent((document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '');
const H = { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf };
const r = await fetch('/api/workspaces', {
method: 'POST', headers: H, body: JSON.stringify({ name: 'LWBC WS ' + Date.now() })
});
const d = await r.json();
await fetch('/api/workspaces/' + d.id + '/select', { method: 'POST', headers: H });
const mk = (name, type, parent) => fetch('/api/local-workspace/items', {
method: 'POST', headers: H, body: JSON.stringify({ name, type, parent_id: parent || null })
}).then(x => x.json());
const docs = await mk('Docs', 'folder');
const sub = await mk('Sub', 'folder', docs.id);
const leaf = await mk('Leaf', 'folder', sub.id);
await mk('Page In Leaf', 'page', leaf.id);
return { wsName: '', docs: docs.id, sub: sub.id, leaf: leaf.id, ws: d.id };
});
await page.goto(`${FD_BASE}/local-workspace`);
await page.waitForFunction(() => typeof window._wsData !== 'undefined', { timeout: 15000 });
const wsName = await page.evaluate(() => {
const el = document.querySelector('.ws-main .unified-breadcrumb .bc-root');
return el ? el.textContent.trim() : '';
});
expect(wsName).toBeTruthy();
const headerPath = () => page.evaluate(() => {
const el = document.querySelector('.header-breadcrumb');
return el ? el.innerText.replace(/\s+/g, ' ').replace(/…/g, '...').trim() : null;
});
expect(await headerPath()).toContain(wsName);
// Composants Alpine peints à l'état brut (x-show non évalué) — hors pages
// légitimement visibles (arbre, ligne « parent », barre de tags).
// checkVisibility() prend en compte les ancêtres (display:contents sur
// .bc-edit-wrap ferme l'input sans que son propre display change).
const rawPainted = () => page.evaluate(() => {
const sels = ['.ws-dropdown', '.ws-select-menu', '.upload-progress', '.undo-toast',
'.move-modal-overlay', '.ws-search-bar', '.ws-select-bar', '.more-menu', '.filter-row',
'.bc-edit-wrap'];
return sels.filter(s => [...document.querySelectorAll(s)]
.some(e => e.checkVisibility({ opacityProperty: true, visibilityProperty: true })));
});
await page.goto(`${FD_BASE}/local-workspace`);
await page.waitForFunction(() => typeof window._wsData !== 'undefined', { timeout: 15000 });
await expect.poll(headerPath, { timeout: 10000 }).toContain(wsName);
// ── 2. Sidebar : clic sur Docs puis sur Sub (navigation partielle) ──
const clickSidebarFolder = async (id) => {
await page.evaluate((fid) => {
const el = document.querySelector('.sidebar-item.ws-tree-item[data-id="' + fid + '"] .tree-folder-link');
if (el) el.click();
}, id);
await page.waitForURL('**folder=' + id, { timeout: 15000 });
await page.waitForTimeout(1200);
};
await clickSidebarFolder(ids.docs);
expect(await headerPath()).toBe(`Home / ${wsName} / Docs`);
expect(await rawPainted()).toEqual([]);
await clickSidebarFolder(ids.sub);
expect(await headerPath()).toBe(`Home / ${wsName} / Docs / Sub`);
expect(await rawPainted()).toEqual([]);
// ── 3. Clic sur un sous-dossier DANS la vue (sans rechargement) ──
await page.evaluate((fid) => {
const el = document.querySelector('.ws-main [data-folder-id="' + fid + '"] .name.folder');
if (el) el.click();
}, ids.leaf);
await expect.poll(headerPath, { timeout: 10000 }).toContain('/ Sub / Leaf');
expect(await headerPath()).toContain(wsName);
expect(await rawPainted()).toEqual([]);
// ── 4. Breadcrumb interne : clic sur Docs → les deux chemins suivent ──
const inPage = () => page.evaluate(() =>
[...document.querySelectorAll('.ws-main .unified-breadcrumb a')].map(a => a.textContent.trim()));
expect(await inPage()).toEqual([expect.stringContaining(wsName), 'Docs', 'Sub', 'Leaf']);
await page.click('.ws-main .unified-breadcrumb a:has-text("Docs")');
await expect.poll(headerPath, { timeout: 10000 }).toBe(`Home / ${wsName} / Docs`);
expect(await inPage()).toEqual([expect.stringContaining(wsName), 'Docs']);
// ── 5. Crumb workspace du header → retour à la racine ──
await page.click('.header-breadcrumb a:has-text("' + wsName + '")');
await page.waitForURL('**/local-workspace', { timeout: 15000 });
await expect.poll(headerPath, { timeout: 10000 }).toBe(`Home / ${wsName}`);
expect(await rawPainted()).toEqual([]);
expect(errors).toEqual([]);
});
+186
View File
@@ -0,0 +1,186 @@
// T0-3 — Régression du bouton « Templates » (page vide).
// Avant correctif : la pilule « Templates » basculait `gsMoreOpen` (même flag
// que « ••• ») et ouvrait le menu de création de base — aucun sélecteur.
// Après : le clic ouvre TOUJOURS le sélecteur unifié (liste, état vide ou
// erreur explicite — jamais un no-op silencieux, P5).
const { test, expect } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`);
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
await page.waitForURL('**/workspaces', { timeout: 15000 });
}
async function createEmptyPage(page) {
return await page.evaluate(async () => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
const r = await fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ name: 'Tpl regression ' + Date.now(), type: 'page' }),
});
return await r.json();
});
}
async function setupWorkspace(page, name) {
return await page.evaluate(async (n) => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
const r = await fetch('/api/workspaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ name: n }),
});
const ws = await r.json();
await fetch(`/api/workspaces/${ws.id}/select`, { method: 'POST', headers: { 'X-CSRF-Token': csrf } });
return ws;
}, name);
}
test.describe('Templates picker (T0-3)', () => {
test('le clic sur la pilule Templates ouvre le sélecteur', async ({ page }) => {
await login(page);
const created = await createEmptyPage(page);
await page.goto(`${FD_BASE}/pages/${created.id}`);
await page.waitForSelector('#_blocksCt', { timeout: 15000 });
// La rangée « Get started with » est visible sur une page vide.
const pill = page.locator('.get-started-toolbar .gs-pill', { hasText: 'Templates' });
await expect(pill).toBeVisible({ timeout: 10000 });
await pill.click();
// Le sélecteur unifié s'ouvre — liste, état vide OU erreur : jamais rien.
const picker = page.getByTestId('tpl-picker');
await expect(picker).toBeVisible({ timeout: 10000 });
// Le catalogue contient les presets système (ou un état vide explicite).
const items = page.getByTestId('tpl-item');
const n = await items.count();
if (n === 0) {
await expect(picker.getByText(/Aucun template|Chargement impossible/)).toBeVisible();
} else {
// Choisir le premier template et l'appliquer à la page courante.
await items.first().click();
await page.getByTestId('tpl-apply').click();
// Effet visible : toast de confirmation (la page se remplit en place).
await expect(page.locator('.sd-toast, .toast').first()).toBeVisible({ timeout: 15000 });
}
});
test('le gestionnaire /templates liste le catalogue', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/templates`);
await expect(page.getByRole('heading', { name: 'Templates' })).toBeVisible({ timeout: 15000 });
// Au moins les presets système, même pour un utilisateur non-admin.
await expect(page.locator('.lib-container').getByText(/Note de réunion|Revue hebdo|Journal quotidien/).first()).toBeVisible({ timeout: 15000 });
});
test('les filtres Tous/page/blocks/row/database filtrent la liste', async ({ page }) => {
await login(page);
const created = await createEmptyPage(page);
await page.goto(`${FD_BASE}/pages/${created.id}`);
await page.waitForSelector('#_blocksCt', { timeout: 15000 });
await page.locator('.get-started-toolbar .gs-pill', { hasText: 'Templates' }).click();
await page.getByTestId('tpl-picker').waitFor({ state: 'visible', timeout: 10000 });
// Filtre « Bases » : ne liste que des bases.
await page.locator('#fd-tpl-picker button', { hasText: /^Bases$/ }).click();
await expect(page.getByTestId('tpl-item').first()).toContainText('database', { timeout: 10000 });
// Filtre « Pages » : ne liste que des pages.
await page.locator('#fd-tpl-picker button', { hasText: /^Pages$/ }).click();
await expect(page.getByTestId('tpl-item').first()).toContainText('page', { timeout: 10000 });
});
test('un template Database s’insère dans le document courant', async ({ page }) => {
await login(page);
const created = await createEmptyPage(page);
await page.goto(`${FD_BASE}/pages/${created.id}`);
await page.waitForSelector('#_blocksCt', { timeout: 15000 });
await page.locator('.get-started-toolbar .gs-pill', { hasText: 'Templates' }).click();
await page.getByTestId('tpl-picker').waitFor({ state: 'visible', timeout: 10000 });
await page.locator('#fd-tpl-picker button', { hasText: /^Bases$/ }).click();
await page.getByTestId('tpl-item').first().click();
await page.getByTestId('tpl-apply').click();
// Effet visible sans navigation : la base est insérée dans le document.
await expect(page.locator('.sd-toast, .toast').first()).toContainText(/insérée|créé/i, { timeout: 15000 });
expect(page.url()).toContain(`/pages/${created.id}`);
});
test('le bouton « New page » crée un document vide (pas le panneau template)', async ({ page }) => {
await login(page);
await setupWorkspace(page, 'NewPage ' + Date.now());
await page.goto(`${FD_BASE}/local-workspace`);
await page.waitForTimeout(1500);
const btn = page.locator('button[title="New page"]').first();
await expect(btn).toBeVisible({ timeout: 10000 });
await btn.click();
// Création directe → navigation vers l'éditeur, aucun panneau template.
await page.waitForURL('**/pages/**', { timeout: 15000 });
expect(await page.locator('#fd-global-tpl-picker, .tpl-picker-overlay').count()).toBe(0);
});
test('le menu New > From template ouvre le sélecteur unifié', async ({ page }) => {
await login(page);
await setupWorkspace(page, 'NewTpl ' + Date.now());
await page.goto(`${FD_BASE}/local-workspace`);
await page.waitForTimeout(1500);
await page.locator('.fd-new-btn').click();
await page.locator('.fd-new-pop-item', { hasText: /From template/ }).click();
const picker = page.locator('#fd-global-tpl-picker');
await expect(picker).toBeVisible({ timeout: 10000 });
// Fermeture explicite : le ✕ fonctionne (jamais un no-op).
await picker.locator('[data-gtpl="close"]').click();
await expect(picker).toHaveCount(0, { timeout: 5000 });
});
test('la commande / template ouvre le sélecteur sans les pages', async ({ page }) => {
await login(page);
const created = await createEmptyPage(page);
await page.goto(`${FD_BASE}/pages/${created.id}`);
await page.waitForSelector('#_blocksCt .block-content', { timeout: 15000 });
const block = page.locator('#_blocksCt .block-content').first();
await block.click();
await page.waitForTimeout(300);
await page.keyboard.type('/');
await page.waitForSelector('.slash-item', { timeout: 10000 });
await page.keyboard.type('template');
const item = page.locator('.slash-item[data-sid="template"]');
await expect(item).toBeVisible({ timeout: 10000 });
await item.click();
const picker = page.getByTestId('tpl-picker');
await expect(picker).toBeVisible({ timeout: 10000 });
// Filtres : base / ligne / blocs seulement — pas de « Pages ».
const labels = await picker.locator('.fd-tpl-filter').allInnerTexts();
expect(labels).toEqual(expect.arrayContaining(['Bases', 'Lignes', 'Blocs']));
expect(labels).not.toContain('Pages');
// Aucun template de page représenté (genres base/ligne/blocs uniquement).
const texts = await picker.getByTestId('tpl-item').allInnerTexts();
expect(texts.length).toBeGreaterThan(0);
expect(texts.every((t) => !/page ·/.test(t))).toBeTruthy();
});
test('appliquer un template de page règle le titre sans variable', async ({ page }) => {
await login(page);
const created = await createEmptyPage(page);
await page.goto(`${FD_BASE}/pages/${created.id}`);
await page.waitForSelector('#_blocksCt .block-content', { timeout: 15000 });
await page.locator('.get-started-toolbar .gs-pill', { hasText: 'Templates' }).click();
const picker = page.getByTestId('tpl-picker');
await picker.waitFor({ state: 'visible', timeout: 10000 });
await picker.locator('.fd-tpl-filter', { hasText: /^Pages$/ }).click();
await picker.getByTestId('tpl-item').filter({ hasText: 'Compte rendu client' }).first().click();
await page.getByTestId('tpl-apply').click();
// Pas d'échec « variable requise », et le titre suit le template.
const toast = page.locator('.sd-toast, .toast').first();
await expect(toast).toContainText(/remplie depuis/i, { timeout: 15000 });
await expect(page.locator('#_titleEl')).toContainText('CR —');
});
});
+133
View File
@@ -0,0 +1,133 @@
const { test, expect } = require('@playwright/test');
/**
* v7.62 — menu contextuel au highlight de texte (toolbar façon Notion).
* Vérifie : les 4 lignes + Skills + pied, le menu « … », le sous-menu
* « Turn into », et surtout la PERSISTANCE du formatage après reload
* (sync() relit le DOM formaté via gtMd → source markdown).
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
*/
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
test.use({ serviceWorkers: 'block' });
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page
.waitForURL('**/workspaces', { timeout: 8000 })
.then(() => true)
.catch(() => false);
if (!ok) {
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
data: { email: USER, password: PASS, name: 'E2E' },
});
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
}
}
// Sélectionne tout le bloc et simule la fin du glisser-souris
async function selectBlock(page) {
await page.evaluate(() => {
const el = document.querySelector('#_blocksCt [data-bid]');
const r = document.createRange();
r.selectNodeContents(el);
const s = getSelection();
s.removeAllRanges();
s.addRange(r);
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
});
await expect(page.locator('.format-toolbar')).toBeVisible({ timeout: 5000 });
}
test('toolbar de sélection : structure, sous-menus, persistance', async ({ page }) => {
await login(page);
const cookies = await page.context().cookies();
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
const created = await page.request.post(
`${FD_BASE}/board/api/pages?title=Menu highlight ${Date.now()}`,
{ headers: { 'X-CSRF-Token': csrf } }
);
expect(created.status(), await created.text()).toBeLessThan(400);
const { id } = await created.json();
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
await page.click('#_blocksCt [data-bid]');
await page.keyboard.type('Menu lors d un highlight de texte');
// ── 1. structure : 4 lignes + Skills + pied ──
await selectBlock(page);
const bar = page.locator('.format-toolbar');
await expect(bar.locator('.ft-row')).toHaveCount(4);
await expect(bar.locator('.ft-style')).toContainText('Normal Text');
await expect(bar.locator('.ft-group')).toHaveText('Skills');
await expect(bar.locator('.ft-skill')).toHaveCount(4);
await expect(bar.locator('.ft-skill').first()).toHaveText('Improve writing');
await expect(bar.locator('.ft-foot')).toContainText('Edit with AI');
// refonte v7.67 : ligne 4 = Comment (libellé) à gauche, réaction à droite ;
// entête Skills avec ≡ ; plus de bouton ✨ Ask AI (doublon du pied)
await expect(bar.locator('.ft-comment')).toContainText('Comment');
await expect(bar.locator('.ft-row-split .ft-react')).toHaveCount(1);
await expect(bar.locator('.ft-skills-head')).toHaveText('Skills');
await expect(bar.locator('[data-tooltip^="Ask AI"]')).toHaveCount(0);
// géométrie : sous la sélection, entièrement dans le viewport
const barBox = await bar.boundingBox();
const selBox = await page.evaluate(() => {
const r = getSelection().getRangeAt(0).getBoundingClientRect();
return { bottom: r.bottom };
});
expect(barBox.y).toBeGreaterThanOrEqual(selBox.bottom);
expect(barBox.y + barBox.height).toBeLessThanOrEqual(900);
expect(barBox.x).toBeGreaterThanOrEqual(0);
expect(barBox.x + barBox.width).toBeLessThanOrEqual(1440);
await page.screenshot({ path: 'shots/v762-toolbar.png' });
// ── 2. ligne 1 → sous-menu « Turn into » ──
await bar.locator('.ft-style').click();
const sub = page.locator('#blockMenuSub');
await expect(sub).toBeVisible();
await expect(sub).toContainText('Heading 4');
await expect(sub.locator('.bm-check')).toHaveCount(1); // type courant coché
await page.screenshot({ path: 'shots/v762-turn-submenu.png' });
await page.keyboard.press('Escape');
// ── 3. « … » → menu contextuel de bloc (recherche + pied) ──
await selectBlock(page);
await bar.locator('.ft-more').click();
const menu = page.locator('#blockMenu');
await expect(menu).toBeVisible();
await expect(menu.locator('.bm-search')).toHaveAttribute('placeholder', 'Search actions...');
await expect(menu).toContainText('Copy link to block');
await expect(menu.locator('.bm-foot')).toContainText('Last edited by');
await page.screenshot({ path: 'shots/v762-more-menu.png' });
await page.keyboard.press('Escape');
await expect(menu).toBeHidden();
// ── 4. persistance : B (gras) survit au reload ──
await selectBlock(page);
await page.getByRole('button', { name: 'B', exact: true }).click();
await expect(page.locator('#_blocksCt [data-bid] b, #_blocksCt [data-bid] strong')).toHaveCount(1);
await page.waitForTimeout(2500); // autosave 1,5 s
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
await expect(page.locator('#_blocksCt [data-bid] strong')).toHaveCount(1); // rendu mdEsc
// ── 5. persistance : S (surlignage) survit au reload ──
await selectBlock(page);
await bar.locator('.ft-hl').click();
await expect(page.locator('#_blocksCt [data-bid] mark')).toHaveCount(1);
await page.waitForTimeout(2500);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
await expect(page.locator('#_blocksCt [data-bid] mark')).toHaveCount(1);
// ── 6. fermeture hors sélection ──
await page.mouse.click(10, 400);
await expect(page.locator('.format-toolbar')).toBeHidden();
});
+125
View File
@@ -0,0 +1,125 @@
const { test, expect } = require('@playwright/test');
/**
* v7.63 — menus mobiles façon Notion (viewport 390×844) :
* - « / » → barre horizontale défilante sous le curseur (15 boutons, ordre du doc)
* - « + » → feuille plein écran « Insert block » (titre centré + Cancel, groupes,
* raccourcis) → choix d'un type = applySlash
* - « Turn into » / « Color » → feuilles réutilisant les sous-menus de bloc
* - « More » → menu contextuel de bloc (desktop)
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
*/
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
test.use({ serviceWorkers: 'block', viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page
.waitForURL('**/workspaces', { timeout: 8000 })
.then(() => true)
.catch(() => false);
if (!ok) {
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
data: { email: USER, password: PASS, name: 'E2E' },
});
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
}
}
async function newPage(page) {
const cookies = await page.context().cookies();
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
const created = await page.request.post(
`${FD_BASE}/board/api/pages?title=Mobile menus ${Date.now()}`,
{ headers: { 'X-CSRF-Token': csrf } }
);
expect(created.status(), await created.text()).toBeLessThan(400);
const { id } = await created.json();
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
return id;
}
test('barre mobile au / + feuilles Insert block / Turn into / More', async ({ page }) => {
await login(page);
await newPage(page);
await page.click('#_blocksCt [data-bid]');
await page.keyboard.type('/');
// ── 1. la barre apparaît, 15 boutons dans l'ordre de la description ──
const bar = page.locator('#fdMobileBar');
await expect(bar).toBeVisible({ timeout: 5000 });
const keys = await page.evaluate(() =>
Array.from(document.querySelectorAll('#fdMobileBar [data-mtb]')).map((b) => b.dataset.mtb));
expect(keys).toEqual(['plus', 'turn', 'at', 'comment', 'image', 'trash',
'left', 'right', 'up', 'down', 'undo', 'redo', 'color', 'more', 'kbd']);
// défilement latéral
const overflowX = await bar.evaluate((el) => getComputedStyle(el).overflowX);
expect(overflowX).toBe('auto');
// pas de popup desktop en parallèle
await expect(page.locator('#_slashMenu')).toBeHidden();
await page.screenshot({ path: 'shots/v763-bar.png' });
// ── 2. « + » → feuille plein écran « Insert block » ──
await bar.locator('[data-mtb="plus"]').click();
const sheet = page.locator('.fd-msheet');
await expect(sheet).toBeVisible();
await expect(sheet.locator('.fd-msheet-title')).toHaveText('Insert block');
await expect(sheet.locator('.fd-msheet-cancel')).toHaveText('Cancel');
await expect(sheet.locator('.sm-group').first()).toHaveText('Basic blocks');
await expect(sheet.locator('.slash-item', { hasText: 'Heading 1' })).toHaveCount(1);
await page.screenshot({ path: 'shots/v763-insert.png' });
// ── 3. choix d'un type → applySlash (le bloc devient un titre) ──
await sheet.locator('.slash-item', { hasText: 'Heading 1' }).click();
await expect(sheet).toHaveCount(0);
await expect(bar).toBeHidden();
expect(await page.evaluate(() => window.E.blocks[0].type)).toBe('heading_1');
// ── 4. « Turn into » → feuille réutilisant les items du sous-menu de bloc ──
await page.keyboard.type('/');
await expect(bar).toBeVisible();
await bar.locator('[data-mtb="turn"]').click();
await expect(sheet.locator('.fd-msheet-title')).toHaveText('Turn into');
await expect(sheet.locator('.bm-item', { hasText: 'Bulleted list' })).toHaveCount(1);
await expect(sheet.locator('.bm-check')).toHaveCount(1); // type courant coché
await page.screenshot({ path: 'shots/v763-turn.png' });
await sheet.locator('.fd-msheet-cancel').click();
await expect(sheet).toHaveCount(0);
expect(await page.evaluate(() => window.E.blocks[0].type)).toBe('heading_1');
// ── 5. « Color » → feuille avec les 3 groupes ──
await page.evaluate(() => localStorage.setItem('fdLastBlockColor', 'color|#E5484D'));
await bar.locator('[data-mtb="color"]').click();
for (const g of ['Last used', 'Text color', 'Background color']) {
await expect(sheet.locator('.bm-group', { hasText: g })).toHaveCount(1);
}
await sheet.locator('.fd-msheet-cancel').click();
// ── 6. « More » → menu contextuel de bloc (recherche + pied) ──
await bar.locator('[data-mtb="more"]').click();
const menu = page.locator('#blockMenu');
await expect(menu).toBeVisible();
await expect(menu.locator('.bm-search')).toHaveAttribute('placeholder', 'Search actions...');
await expect(menu.locator('.bm-foot')).toContainText('Last edited by');
await page.keyboard.press('Escape');
await expect(menu).toBeHidden();
// ── 7. ↑ déplace réellement le bloc ── (bloc 0 = titre, bloc 1 = paragraphe)
await page.click('#_blocksCt [data-bid]'); // bloc 0 (contient '/')
await page.keyboard.press('Enter');
await page.waitForTimeout(150); // le focus du nouveau bloc est différé (60 ms)
await page.keyboard.type('/'); // bloc neuf et vide → slash exact = ouverture
await expect(bar).toBeVisible(); // SM._i = 1 (bloc du bas)
await bar.locator('[data-mtb="up"]').click();
expect(await page.evaluate(() => window.E.blocks.map((b) => b.type).join(',')))
.toBe('paragraph,heading_1');
await page.screenshot({ path: 'shots/v763-moved.png' });
});
+80
View File
@@ -0,0 +1,80 @@
const { test, expect } = require('@playwright/test');
/**
* v7.64.1 — Delete sur un bloc vide supprime le bloc (comme Backspace).
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
*/
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
test.use({ serviceWorkers: 'block' });
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page
.waitForURL('**/workspaces', { timeout: 8000 })
.then(() => true)
.catch(() => false);
if (!ok) {
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
data: { email: USER, password: PASS, name: 'E2E' },
});
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
}
}
test('Delete : vide le bloc puis supprime le bloc vide', async ({ page }) => {
await login(page);
const cookies = await page.context().cookies();
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
const created = await page.request.post(
`${FD_BASE}/board/api/pages?title=Delete key ${Date.now()}`,
{ headers: { 'X-CSRF-Token': csrf } }
);
expect(created.status(), await created.text()).toBeLessThan(400);
const { id } = await created.json();
const bs = await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, {
headers: { 'X-CSRF-Token': csrf },
data: { title: 'Delete key', blocks: [
{ id: 'b1', type: 'paragraph', content: 'hello' },
{ id: 'b2', type: 'paragraph', content: 'world' },
] },
});
expect(bs.status(), await bs.text()).toBeLessThan(400);
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
// ── 1. Delete avec du texte : suppression avant le curseur (comportement natif) ──
await page.locator('#_blocksCt [data-bid]').nth(1).click();
await page.keyboard.press('Control+a');
await page.keyboard.press('Delete');
await page.waitForTimeout(200);
const contents = await page.evaluate(() => {
window.E.sync();
return window.E.blocks.map((b) => b.content);
});
expect(contents[0]).toBe('hello');
expect(contents[1].trim()).toBe(''); // bloc vidé (un <br> résiduel), mais toujours là
// ── 2. Delete sur le bloc vide : le bloc est supprimé ──
await page.keyboard.press('Delete');
await page.waitForTimeout(300);
expect(await page.evaluate(() => window.E.blocks.length)).toBe(1);
expect(await page.evaluate(() => window.E.blocks[0].content)).toBe('hello');
await page.screenshot({ path: 'shots/v7641-delete.png' });
// ── 3. dernier bloc vide : jamais supprimé (l'éditeur garde un bloc) ──
await page.locator('#_blocksCt [data-bid]').first().click();
await page.keyboard.press('Control+a');
await page.keyboard.press('Delete');
await page.waitForTimeout(150);
await page.keyboard.press('Delete');
await page.waitForTimeout(300);
expect(await page.evaluate(() => window.E.blocks.length)).toBe(1);
expect(await page.evaluate(() => window.E.blocks[0].type)).toBe('paragraph');
});
+201
View File
@@ -0,0 +1,201 @@
const { test, expect } = require('@playwright/test');
/**
* v7.64 — commentaires & réactions façon Notion :
* - sélection → bouton 🙂+ (« React to selected text ») → sélecteur d'émoji avec
* le texte sélectionné en en-tête → pastille + plage jaune, contenu inchangé
* - sélection → 💬 → tiroir refondu (avatar, nom, ✓, ⋯, saisie avec @ et ↑) →
* fil ancré + plage jaune, clic sur l'ancre rouvre le fil, ✓ résout
* - reload : ancres et pastilles repeintes depuis la base
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
*/
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
test.use({ serviceWorkers: 'block' });
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page
.waitForURL('**/workspaces', { timeout: 8000 })
.then(() => true)
.catch(() => false);
if (!ok) {
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
data: { email: USER, password: PASS, name: 'E2E' },
});
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
}
}
async function newPage(page, body) {
const cookies = await page.context().cookies();
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
const created = await page.request.post(
`${FD_BASE}/board/api/pages?title=Comments ${Date.now()}`,
{ headers: { 'X-CSRF-Token': csrf } }
);
expect(created.status(), await created.text()).toBeLessThan(400);
const { id } = await created.json();
if (body) {
const bs = await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, {
headers: { 'X-CSRF-Token': csrf },
data: { title: 'Comments', blocks: body },
});
expect(bs.status(), await bs.text()).toBeLessThan(400);
}
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
return id;
}
// sélectionne une SOUS-chaîne du bloc (ancre = plage, pas le bloc entier)
async function selectSub(page, sub) {
await page.evaluate((sub) => {
const el = document.querySelector('#_blocksCt [data-bid]');
const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let n;
while ((n = w.nextNode())) {
if (n.parentElement && n.parentElement.closest('.fd-react-chip')) continue;
const i = n.nodeValue.indexOf(sub);
if (i < 0) continue;
const r = document.createRange();
r.setStart(n, i);
r.setEnd(n, i + sub.length);
const s = getSelection();
s.removeAllRanges();
s.addRange(r);
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
return;
}
throw new Error('sous-chaîne introuvable : ' + sub);
}, sub);
await expect(page.locator('.format-toolbar')).toBeVisible({ timeout: 5000 });
}
// sélectionne tout le bloc et simule la fin du glisser-souris
async function selectBlock(page) {
await page.evaluate(() => {
const el = document.querySelector('#_blocksCt [data-bid]');
const r = document.createRange();
r.selectNodeContents(el);
const s = getSelection();
s.removeAllRanges();
s.addRange(r);
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
});
await expect(page.locator('.format-toolbar')).toBeVisible({ timeout: 5000 });
}
test('réaction emoji + commentaires ancrés', async ({ page }) => {
await login(page);
await newPage(page, [{ id: 'b1', type: 'paragraph', content: 'Prise de reaction a un texte' }]);
await expect(page.locator('#_blocksCt [data-bid]')).toHaveText('Prise de reaction a un texte');
// ── 1. bouton réaction dans la toolbar ──
await selectBlock(page);
const react = page.locator('.format-toolbar .ft-react');
await expect(react).toHaveAttribute('data-tooltip', 'React to selected text');
await page.screenshot({ path: 'shots/v764-toolbar-react.png' });
await react.click();
// ── 2. sélecteur : en-tête = texte sélectionné, Filter…, Recent/People ──
const picker = page.locator('.fd-icon-picker');
await expect(picker).toBeVisible();
await expect(picker.locator('.fdip-title-text')).toHaveText('Prise de reaction a un texte');
await expect(picker.locator('.fdip-search')).toHaveAttribute('placeholder', 'Filter...');
await expect(picker.locator('.fdip-tabs')).toBeHidden(); // mode réaction : emoji only
await page.screenshot({ path: 'shots/v764-picker.png' });
await picker.locator('.fdip-emoji').first().click();
await expect(picker).toBeHidden();
// ── 3. pastille + plage jaune, contenu du bloc inchangé ──
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1);
await expect(page.locator('#_blocksCt .fd-anchor-react')).toHaveCount(1);
const content = await page.evaluate(() => {
window.E.sync();
return window.E.blocks[0].content;
});
expect(content).toBe('Prise de reaction a un texte'); // la pastille n'est pas du contenu
await page.screenshot({ path: 'shots/v764-chip.png' });
// ── 4. commentaire sur une SOUS-sélection : toolbar 💬 → tiroir refondu → envoi ──
await expect(page.locator('#_commentSelBtn')).toHaveCount(0); // bouton bleu retiré
await selectSub(page, 'reaction a un');
await page.locator('.format-toolbar .ft-item[data-tooltip="Comment — Ctrl+⇧+M"]').click();
const drawer = page.locator('.comments-drawer');
await expect(drawer).toBeVisible();
await drawer.locator('textarea').fill('Ceci est mon premier commentaire');
await expect(drawer.locator('.ct-send')).toBeEnabled();
await drawer.locator('.ct-send').click();
const thread = page.locator('.comment-thread');
await expect(thread).toHaveCount(1);
await expect(thread.locator('.ct-avatar')).toHaveCount(1);
await expect(thread.locator('.ct-name')).not.toHaveText('');
await expect(thread.locator('.ct-act')).toHaveCount(2); // ✓ + ⋯
await expect(thread.locator('.ct-body')).toHaveText('Ceci est mon premier commentaire');
// l'ancre jaune = UNIQUEMENT la plage sélectionnée, pas le bloc
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveCount(1);
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveText('reaction a un');
await page.screenshot({ path: 'shots/v764-drawer.png' });
// ── 5. menu ⋯ du fil (Copy link / Delete) ──
await thread.locator('.ct-act').nth(1).click();
await expect(thread.locator('.ct-menu')).toBeVisible();
await expect(thread.locator('.ct-menu-item').first()).toContainText('Copy link');
await page.keyboard.press('Escape');
await page.locator('.comments-drawer-header .topbar-btn').click();
await expect(drawer).toBeHidden();
// ── 6. clic sur l'ancre jaune → le fil se rouvre ──
await page.locator('#_blocksCt .fd-anchor[data-cid]').click();
await expect(drawer).toBeVisible();
await expect(page.locator('.comment-thread')).toHaveCount(1);
// ── 7. ✓ résout le fil (ancre en gris) ──
await page.locator('.comment-thread .ct-act').first().click();
await expect(page.locator('.comment-thread.is-resolved')).toHaveCount(1);
await expect(page.locator('#_blocksCt .fd-anchor.fd-anchor-done')).toHaveCount(1);
// ── 8. reload : ancres + pastilles rechargées depuis la base ──
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveCount(1, { timeout: 10000 });
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveText('reaction a un');
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1, { timeout: 10000 });
// ── 9. v7.67 : la réaction vit aussi dans le tiroir Comments ──
await page.locator('.topbar-btn[title="Comments"]').click();
const re = page.locator('.ct-reactions');
await expect(re).toBeVisible();
await expect(re.locator('.ct-re-emoji')).toHaveCount(1);
await expect(re.locator('.ct-re-text')).toHaveText('Prise de reaction a un texte');
await page.screenshot({ path: 'shots/v764-drawer-reactions.png' });
// ── 10. suppression A : ✕ de la section Réactions ──
await re.locator('.ct-reaction .ct-act').click();
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(0);
await expect(re).toBeHidden();
// ── 11. suppression B : la croix survolée de la pastille ──
await page.locator('.topbar-btn[title="Comments"]').click(); // referme le tiroir
await selectBlock(page);
await page.locator('.format-toolbar .ft-react').click();
const picker2 = page.locator('.fd-icon-picker');
await expect(picker2).toBeVisible();
await picker2.locator('.fdip-emoji').first().click();
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1);
const emo = page.locator('#_blocksCt .fd-react-emo.is-mine');
await expect(emo).toHaveCount(1);
// la croix n'apparaît qu'au survol (::after)
await expect.poll(() => emo.evaluate((el) => getComputedStyle(el, '::after').display)).toBe('none');
await page.locator('#_blocksCt .fd-react-chip').hover();
await expect.poll(() => emo.evaluate((el) => getComputedStyle(el, '::after').display)).toBe('flex');
await emo.click();
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(0);
});
+99
View File
@@ -0,0 +1,99 @@
const { test, expect } = require('@playwright/test');
/**
* v7.66 — deux ajustements éditeur :
* - « / » ouvre le menu slash → Escape ferme le menu ET retire le « / »
* - survol d'un bloc → bouton à droite (bulle + ) qui commente le bloc
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
*/
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
test.use({ serviceWorkers: 'block' });
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page
.waitForURL('**/workspaces', { timeout: 8000 })
.then(() => true)
.catch(() => false);
if (!ok) {
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
data: { email: USER, password: PASS, name: 'E2E' },
});
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
}
}
async function newPage(page, body) {
const cookies = await page.context().cookies();
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
const created = await page.request.post(
`${FD_BASE}/board/api/pages?title=v766 ${Date.now()}`,
{ headers: { 'X-CSRF-Token': csrf } }
);
expect(created.status(), await created.text()).toBeLessThan(400);
const { id } = await created.json();
if (body) {
const bs = await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, {
headers: { 'X-CSRF-Token': csrf },
data: { title: 'v766', blocks: body },
});
expect(bs.status(), await bs.text()).toBeLessThan(400);
}
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
return id;
}
test('Escape du menu slash retire le caractère /', async ({ page }) => {
await login(page);
await newPage(page, [{ id: 'b1', type: 'paragraph', content: '' }]);
const blk = page.locator('#_blocksCt [data-bid]').first();
await blk.click();
await page.keyboard.type('/');
await expect(page.locator('#_slashMenu')).toBeVisible({ timeout: 5000 });
await page.keyboard.press('Escape');
await expect(page.locator('#_slashMenu')).toBeHidden();
// le « / » a été retiré — pas de caractère résiduel
await expect(blk).toHaveText('');
await page.screenshot({ path: 'shots/v766-slash-esc.png' });
});
test('bouton commenter à droite du bloc', async ({ page }) => {
await login(page);
// 2 blocs : l'éditeur focalise le 1er au chargement (`:focus-within` le
// montre) → on mesure sur le 2e, ni focalisé ni survolé.
await newPage(page, [
{ id: 'b1', type: 'paragraph', content: 'Premier bloc' },
{ id: 'b2', type: 'paragraph', content: 'Un bloc a commenter' },
]);
await expect(page.locator('#_blocksCt .block-comment-btn')).toHaveCount(2);
const wrap = page.locator('#_blocksCt .block-wrapper').nth(1);
const btn = page.locator('#_blocksCt .block-comment-btn').nth(1);
// masqué tant qu'on ne survole pas / ne focalise pas le bloc
await expect.poll(() => btn.evaluate((el) => getComputedStyle(el).opacity)).toBe('0');
await wrap.hover();
await expect.poll(() => btn.evaluate((el) => getComputedStyle(el).opacity)).toBe('1');
// bien à droite du bloc (gouttière 64px)
const wb = await wrap.boundingBox();
const bb = await btn.boundingBox();
expect(bb.x).toBeGreaterThan(wb.x + wb.width - 1);
await page.screenshot({ path: 'shots/v766-comment-btn.png' });
// clic → tiroir → envoi → ancre = le bloc visé (et pas le 1er)
await btn.click();
const drawer = page.locator('.comments-drawer');
await expect(drawer).toBeVisible();
await drawer.locator('textarea').fill('Commentaire via le bouton de droite');
await drawer.locator('.ct-send').click();
await expect(page.locator('.comment-thread')).toHaveCount(1);
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveText('Un bloc a commenter');
});
+185
View File
@@ -0,0 +1,185 @@
const { test, expect } = require('@playwright/test');
/**
* v7.68 — colonnes rendues (slash + Turn into), tables IA sans pipe de tête,
* copier/couper/coller d'une sélection multi-blocs (Ctrl+C/X/V + clic droit).
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
*/
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
test.use({ serviceWorkers: 'block', permissions: ['clipboard-read', 'clipboard-write'] });
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page
.waitForURL('**/workspaces', { timeout: 8000 })
.then(() => true)
.catch(() => false);
if (!ok) {
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
data: { email: USER, password: PASS, name: 'E2E' },
});
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
}
}
async function newPage(page, body) {
const cookies = await page.context().cookies();
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
const created = await page.request.post(
`${FD_BASE}/board/api/pages?title=v768 ${Date.now()}`,
{ headers: { 'X-CSRF-Token': csrf } }
);
expect(created.status(), await created.text()).toBeLessThan(400);
const { id } = await created.json();
if (body) {
const bs = await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, {
headers: { 'X-CSRF-Token': csrf },
data: { title: 'v768', blocks: body },
});
expect(bs.status(), await bs.text()).toBeLessThan(400);
}
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
return id;
}
// VRAIE sélection multi-blocs : glisser la souris du 1er bloc jusqu'au 3e
// (le navigateur seul ne franchit jamais deux contenteditable)
async function dragAcross(page, last = 2) {
const b1 = await page.locator('#_blocksCt [data-bid]').first().boundingBox();
const b3 = await page.locator('#_blocksCt [data-bid]').nth(last).boundingBox();
// on part du tout début du 1er bloc et on va jusqu'à la fin du dernier
await page.mouse.move(b1.x + 2, b1.y + b1.height / 2);
await page.mouse.down();
for (let i = 1; i <= 8; i++) {
await page.mouse.move(b1.x + 2, b1.y + b1.height / 2 + ((b3.y - b1.y) * i) / 8);
}
await page.mouse.move(b3.x + b3.width - 2, b3.y + b3.height / 2);
await page.mouse.up();
}
// sélection multi-blocs au CLAVIER : caret en fin de bloc 1, Maj+↓ Maj+↓
async function shiftExtend(page) {
// VRAI geste : un clic met le caret dans le bloc, End va à sa fin,
// Maj+↓ l'étend ensuite dans les blocs suivants.
const el = page.locator('#_blocksCt [data-bid]').first();
const b = await el.boundingBox();
await page.mouse.click(b.x + 2, b.y + b.height / 2);
await page.keyboard.press('End');
await page.keyboard.press('Shift+ArrowDown');
await page.keyboard.press('Shift+ArrowDown');
await page.waitForTimeout(80);
}
// état de la sélection côté éditeur
const clipState = (page) => page.evaluate(() => window.E._clipState());
test('colonnes : slash + Turn into 2 colonnes', async ({ page }) => {
await login(page);
await newPage(page, [{ id: 'b1', type: 'paragraph', content: '' }]);
await page.click('#_blocksCt [data-bid]');
await page.keyboard.type('/');
await expect(page.locator('#_slashMenu')).toBeVisible({ timeout: 5000 });
await page.keyboard.type('columns');
await page.locator('#_slashMenu .slash-item[data-sid="columns"]').click();
// le bloc colonnes est RENDU (il tombait dans le rendu générique = vide)
await expect(page.locator('#_blocksCt .block-columns')).toHaveCount(1);
await expect(page.locator('#_blocksCt .block-column')).toHaveCount(1);
await page.locator('#_blocksCt .columns-add-btn').click();
await expect(page.locator('#_blocksCt .block-column')).toHaveCount(2);
await page.screenshot({ path: 'shots/v768-columns-slash.png' });
// Turn into → 2 colonnes
await newPage(page, [{ id: 'b1', type: 'paragraph', content: 'Pour le menu' }]);
await page.locator('#_blocksCt [data-bid]').first().click({ button: 'right' });
const menu = page.locator('#blockMenu');
await expect(menu).toBeVisible();
await menu.locator('[data-sub="turn"]').hover();
const sub = page.locator('#blockMenuSub');
await expect(sub).toBeVisible();
await sub.locator('[data-bm-act="turn-type"][data-cols="2"]').click();
await expect(page.locator('#_blocksCt .block-column')).toHaveCount(2);
});
test('table IA sans pipe de tête devient un vrai tableau', async ({ page }) => {
await login(page);
await newPage(page, [{ id: 'b1', type: 'paragraph', content: '' }]);
await page.evaluate(() => {
window.E.applyAIBlocks('Nom | Rôle\n--- | ---\nAlice | Dev\nBob | Test');
});
await expect(page.locator('#_blocksCt .ftable-editor')).toHaveCount(1);
await expect(page.locator('#_blocksCt .ftable-cell').first()).toHaveText('Nom');
await expect(page.locator('#_blocksCt .ftable-cell').last()).toHaveText('Test');
// la ligne « texte » suivie d'« --- » ne doit PAS devenir un tableau
await page.evaluate(() => {
window.E.applyAIBlocks('juste du texte\n---\nune phrase');
});
await expect(page.locator('#_blocksCt .ftable-editor')).toHaveCount(1);
await page.screenshot({ path: 'shots/v768-ai-table.png' });
});
test('Ctrl+C / Ctrl+X / Ctrl+V sur une sélection multi-blocs', async ({ page }) => {
await login(page);
await newPage(page, [
{ id: 'b1', type: 'paragraph', content: 'Premier bloc' },
{ id: 'b2', type: 'heading_2', content: 'Titre deux' },
{ id: 'b3', type: 'paragraph', content: 'Troisieme bloc' },
]);
// sélection multi-blocs au CLAVIER (Maj+↓) — geste réel
await shiftExtend(page);
const st = await clipState(page);
expect(st.si).toBe(0);
expect(st.ei).toBe(2);
// VRAI raccourci clavier (pas execCommand) : c'est lui qui était cassé
await page.keyboard.press('Control+c');
await page.waitForTimeout(250);
const clip = await page.evaluate(() => navigator.clipboard.readText());
expect(clip).toContain('## Titre deux'); // le titre sort en markdown
expect(clip).toContain('Troisieme bloc');
// coupe (raccourci Ctrl+X) : on recouvre les 3 blocs du début à la fin
await dragAcross(page);
await page.keyboard.press('Control+x');
await page.waitForTimeout(300);
await expect(page.locator('#_blocksCt [data-bid]')).toHaveCount(2);
await expect(page.locator('#_blocksCt [data-bid]').first()).toHaveText('');
// colle : les 3 blocs reviennent APRÈS le bloc du curseur (comme l'entrée
// Paste du menu) → 2 blocs restants + 3 collés = 5
await page.keyboard.press('Control+v');
await page.waitForTimeout(300);
await expect(page.locator('#_blocksCt [data-bid]')).toHaveCount(5, { timeout: 5000 });
await expect(page.locator('#_blocksCt [data-bid]').nth(1)).toContainText('Premier bloc');
await expect(page.locator('#_blocksCt [data-bid]').nth(2)).toContainText('Titre deux');
});
test('clic droit avec une sélection → groupe Clipboard', async ({ page }) => {
await login(page);
await newPage(page, [
{ id: 'b1', type: 'paragraph', content: 'Alpha bloc' },
{ id: 'b2', type: 'paragraph', content: 'Beta bloc' },
]);
await dragAcross(page, 1);
// la sélection multi-blocs doit SURVIVRE au clic droit (le navigateur
// déplacerait sinon le curseur dans le bloc cliqué)
expect((await clipState(page)).ei).toBe(1);
await page.locator('#_blocksCt [data-bid]').nth(1).click({ button: 'right', position: { x: 30, y: 6 } });
expect((await clipState(page)).ei).toBe(1);
const menu = page.locator('#blockMenu');
await expect(menu).toBeVisible();
await expect(menu.locator('[data-bm-act="clip-copy"]')).toBeVisible();
await expect(menu.locator('[data-bm-act="clip-cut"]')).toBeVisible();
await expect(menu.locator('[data-bm-act="clip-paste"]')).toBeVisible();
await page.screenshot({ path: 'shots/v768-ctx-clipboard.png' });
await menu.locator('[data-bm-act="clip-copy"]').click();
const clip = await page.evaluate(() => navigator.clipboard.readText());
expect(clip).toContain('Alpha bloc');
expect(clip).toContain('Beta bloc');
});
+62
View File
@@ -0,0 +1,62 @@
// v7.69.3 — la section Aide doit avoir le MÊME positionnement que Settings
const { test, expect } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
await page.waitForURL('**/workspaces', { timeout: 8000 }).catch(() => {});
}
// géométrie de l'overlay + du panneau, telle que perçue à l'écran
const geo = (page) =>
page.evaluate(() => {
const ov = document.querySelector('.settings-overlay');
const pn = document.querySelector('.settings-panel');
const o = getComputedStyle(ov);
const p = getComputedStyle(pn);
const r = pn.getBoundingClientRect();
return {
ovPosition: o.position,
ovBgSet: o.backgroundColor !== 'rgba(0, 0, 0, 0)',
pnWidth: Math.round(r.width),
pnHeight: Math.round(r.height),
pnCentered: Math.abs(r.left - (window.innerWidth - r.width) / 2) < 24,
pnTop: Math.round(r.top),
closeTop: Math.round(document.querySelector('.settings-close').getBoundingClientRect().top - r.top),
closeRight: Math.round(r.right - document.querySelector('.settings-close').getBoundingClientRect().right),
};
});
test('Aide et Settings partagent le même positionnement', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/help`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(400);
const h = await geo(page);
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(400);
const s = await geo(page);
console.log('HELP =', JSON.stringify(h));
console.log('SETTING=', JSON.stringify(s));
// l'overlay de Aide n'est plus désactivé en inline
expect(h.ovPosition).toBe(s.ovPosition);
expect(h.ovPosition).toBe('fixed');
expect(h.ovBgSet).toBe(true);
// panneau centré et de la même taille que Settings
expect(h.pnCentered).toBe(true);
expect(h.pnWidth).toBe(s.pnWidth);
expect(h.pnHeight).toBe(s.pnHeight);
// le bouton close occupe la même place relative au panneau
expect(h.closeTop).toBe(s.closeTop);
expect(h.closeRight).toBe(s.closeRight);
await page.goto(`${FD_BASE}/help`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(300);
await page.screenshot({ path: 'shots/v7693-help-overlay.png' });
});
+72
View File
@@ -0,0 +1,72 @@
// v7.69.4 — le bouton close de Settings doit fermer le panneau, y compris
// quand Settings est ouvert depuis le MENU UTILISATEUR (navigation partielle).
//
// `shouldIntercept()` accepte `/settings`, donc ce clic passe par fdLoad()
// (htmx + history.pushState) SANS rechargement de page. L'ancien closeSettings()
// faisait `history.back()` → popstate → swap PARTIEL de .main-wrapper, et le
// panneau `position:fixed` restait peint : « ça ferme mais ça reste ouvert ».
// Depuis /workspaces, c'est un chargement complet → pas de symptôme.
const { test, expect } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
await page.waitForURL('**/workspaces', { timeout: 8000 }).catch(() => {});
}
const csrfOf = async (page) => {
const c = await page.context().cookies();
return (c.find((x) => x.name === 'csrf_token') || {}).value || '';
};
async function makeWorkspace(page) {
const r = await page.request.post(`${FD_BASE}/api/workspaces`, {
headers: { 'X-CSRF-Token': await csrfOf(page) },
data: { name: `e2e-cls-${Date.now()}-${Math.random().toString(16).slice(2, 6)}` },
});
const j = await r.json();
return j.id || (j.workspace && j.workspace.id);
}
// ouvre Settings depuis le menu utilisateur — le chemin qui provoque le bug
async function openSettingsFromUserMenu(page) {
await page.locator('.workspace-avatar, .user-avatar').first().click();
await page.locator('.um-item[href="/settings"]').first().click();
await page.waitForSelector('button.settings-close', { timeout: 8000 });
}
test('close ferme Settings ouvert depuis le menu utilisateur (nav partielle)', async ({ page }) => {
await login(page);
const id = await makeWorkspace(page);
await page.goto(`${FD_BASE}/local-workspace?ws=${id}`, { waitUntil: 'domcontentloaded' });
await openSettingsFromUserMenu(page);
expect(page.url()).toContain('/settings');
await page.locator('button.settings-close').click();
await page.waitForTimeout(900);
// plus sur /settings…
expect(page.url()).not.toContain('/settings');
expect(page.url()).toContain('/local-workspace');
// …et surtout : le panneau `position:fixed` ne doit PLUS être peint. C'est
// le symptôme exact rapporté (« ça ferme mais ça reste ouvert »).
expect(await page.locator('.settings-overlay').count()).toBe(0);
await expect(page.locator('button.settings-close')).toHaveCount(0);
});
test('close ferme Settings ouvert par chargement complet depuis /workspaces', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await openSettingsFromUserMenu(page);
expect(page.url()).toContain('/settings');
await page.locator('button.settings-close').click();
await page.waitForTimeout(900);
expect(page.url()).not.toContain('/settings');
expect(await page.locator('.settings-overlay').count()).toBe(0);
});
+164
View File
@@ -0,0 +1,164 @@
// v7.69.0 — la sélection multi-blocs SURVIT aux gestes qui l'effaçaient :
// 1) glisser la souris d'un bloc à l'autre
// 2) Ctrl+C sur cette sélection
// 3) clic droit → le menu n'efface plus le surlignage
const { test, expect } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
test.use({ serviceWorkers: 'block', permissions: ['clipboard-read', 'clipboard-write'] });
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page.waitForURL('**/workspaces', { timeout: 8000 }).then(() => true).catch(() => false);
if (!ok) {
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
}
}
async function newPage(page, body) {
const cookies = await page.context().cookies();
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
const created = await page.request.post(`${FD_BASE}/board/api/pages?title=sel ${Date.now()}`, {
headers: { 'X-CSRF-Token': csrf },
});
const { id } = await created.json();
if (body) {
await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, {
headers: { 'X-CSRF-Token': csrf },
data: { title: 'sel', blocks: body },
});
}
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
}
const BLOCKS = [
{ id: 'b1', type: 'paragraph', content: 'Premier bloc' },
{ id: 'b2', type: 'heading_2', content: 'Titre deux' },
{ id: 'b3', type: 'paragraph', content: 'Troisieme bloc' },
];
// état natif de la sélection
const nativeSel = (page) =>
page.evaluate(() => {
const s = window.getSelection();
return { rc: s.rangeCount, coll: s.isCollapsed, txt: s.toString().slice(0, 40) };
});
const clipState = (page) => page.evaluate(() => window.E._clipState());
// VRAI glisser de souris : le navigateur seul ne franchit jamais deux
// contenteditable — c'est _buildSelAt qui reconstruit la plage.
async function dragAcross(page, last = 2) {
const b1 = await page.locator('#_blocksCt [data-bid]').first().boundingBox();
const bEnd = await page.locator('#_blocksCt [data-bid]').nth(last).boundingBox();
// on part du tout début du 1er bloc et on va jusqu'à la fin du dernier
await page.mouse.move(b1.x + 2, b1.y + b1.height / 2);
await page.mouse.down();
for (let i = 1; i <= 8; i++) {
await page.mouse.move(b1.x + 2, b1.y + b1.height / 2 + ((bEnd.y - b1.y) * i) / 8);
}
await page.mouse.move(bEnd.x + bEnd.width - 2, bEnd.y + bEnd.height / 2);
await page.mouse.up();
await page.waitForTimeout(80);
}
test('glisser la souris sur 3 blocs construit une sélection multi-blocs qui survit', async ({ page }) => {
await login(page);
await newPage(page, BLOCKS);
await dragAcross(page);
// la plage reconstruite ne doit PAS être effacée par le « click » suivant
const s = await nativeSel(page);
expect(s.coll).toBe(false);
expect(s.rc).toBe(1);
const st = await clipState(page);
expect(st.si).toBe(0);
expect(st.ei).toBe(2);
await page.screenshot({ path: 'shots/v769-drag.png' });
});
// VRAI raccourci clavier (pas execCommand) : c'est ce qui était cassé.
// Maj+↓ depuis la fin du bloc 1 → la sélection traverse les 3 blocs.
async function keyboardExtend(page) {
const b1 = await page.locator('#_blocksCt [data-bid]').first().boundingBox();
await page.mouse.click(b1.x + 2, b1.y + b1.height / 2);
await page.keyboard.press('End');
await page.keyboard.press('Shift+ArrowDown');
await page.keyboard.press('Shift+ArrowDown');
await page.waitForTimeout(80);
}
test('Ctrl+C copie la sélection multi-blocs en markdown', async ({ page }) => {
await login(page);
await newPage(page, BLOCKS);
await keyboardExtend(page);
expect((await clipState(page)).ei).toBe(2);
await page.keyboard.press('Control+c'); // le vrai raccourci
await page.waitForTimeout(250);
const clip = await page.evaluate(() => navigator.clipboard.readText());
expect(clip).toContain('## Titre deux'); // le titre sort en markdown
expect(clip).toContain('Troisieme bloc');
// Ctrl+X retire les blocs traversés (tête + queue restent)
await page.keyboard.press('Control+x');
await page.waitForTimeout(300);
await expect(page.locator('#_blocksCt [data-bid]')).toHaveCount(2);
});
test("clic droit : le menu s'ouvre SANS effacer le surlignage", async ({ page }) => {
await login(page);
await newPage(page, BLOCKS);
await dragAcross(page);
expect((await nativeSel(page)).coll).toBe(false);
await page.locator('#_blocksCt [data-bid]').nth(1).click({ button: 'right', position: { x: 30, y: 6 } });
await page.waitForTimeout(150);
// le surlignage reste visible
const s = await nativeSel(page);
expect(s.coll).toBe(false);
expect(s.txt.length).toBeGreaterThan(0);
// …et le groupe Clipboard est bien proposé
const menu = page.locator('#blockMenu');
await expect(menu).toBeVisible();
await expect(menu).toContainText('Clipboard');
await expect(menu.locator('[data-bm-act="clip-copy"]')).toBeVisible();
await page.screenshot({ path: 'shots/v769-rightclick.png' });
});
test('clic droit → Copy via le menu colle bien le markdown figé', async ({ page }) => {
await login(page);
await newPage(page, BLOCKS);
await dragAcross(page);
await page.locator('#_blocksCt [data-bid]').nth(1).click({ button: 'right', position: { x: 30, y: 6 } });
const menu = page.locator('#blockMenu');
await expect(menu.locator('[data-bm-act="clip-copy"]')).toBeVisible();
await menu.locator('[data-bm-act="clip-copy"]').click();
await page.waitForTimeout(200);
const clip = await page.evaluate(() => navigator.clipboard.readText());
expect(clip).toContain('Premier bloc');
expect(clip).toContain('## Titre deux');
});
test('un clic simple dans un bloc place quand même le curseur (pas de sélection fantôme)', async ({ page }) => {
await login(page);
await newPage(page, BLOCKS);
await dragAcross(page);
// le clic suivant est un NOUVEL geste : il doit effacer la sélection
const b1 = await page.locator('#_blocksCt [data-bid]').first().boundingBox();
await page.mouse.click(b1.x + 15, b1.y + b1.height / 2);
await page.waitForTimeout(120);
const st = await clipState(page);
expect(st).toBeNull();
});
+113
View File
@@ -0,0 +1,113 @@
"""Vendor highlight.js (common bundle + extra languages + themes) into static/.
Run: python3 scripts/vendor_hljs.py
Fetches from cdnjs 11.9.0 (pinned for stability) and writes:
static/js/highlight.min.js (common bundle, ~40 languages)
static/js/highlight-extra.min.js (extra languages concatenated)
static/css/highlight.css (github light + github-dark scoped on [data-theme])
"""
import re
import urllib.request
VERSION = "11.9.0"
BASE = f"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/{VERSION}"
COMMON_JS = f"{BASE}/highlight.min.js"
LANG_URL = BASE + "/languages/{lang}.min.js"
LIGHT_CSS = BASE + "/styles/github.min.css"
DARK_CSS = BASE + "/styles/github-dark.min.css"
# Extra languages NOT in the common bundle, covering a maximum of file types.
# NOTE (verified 11.9.0): "fish", "toml" and "cobol" have NO standalone grammar
# on cdnjs for this version — code-highlight.js maps them to the closest
# bundled grammar (fish→shell, toml→ini, cobol→plaintext).
EXTRA_LANGS = [
# shells / ops
"powershell", "dos", "fish",
# build / containers / config
"dockerfile", "cmake", "toml", "graphql", "protobuf", "thrift",
# web servers / protocols (absent du bundle common cdnjs 11.9.0)
"apache", "http", "nginx",
# JVM / functional / misc popular
"scala", "groovy", "haskell", "elixir", "erlang", "clojure",
"ocaml", "fsharp", "nim", "d", "julia", "matlab",
"dart", "latex", "vim", "awk", "delphi",
# asm / hdl / legacy
"armasm", "x86asm", "vhdl", "verilog", "ada", "fortran", "cobol",
"gcode", "stata", "stan",
]
def fetch(url: str) -> str:
req = urllib.request.Request(url, headers={"User-Agent": "FlowDeck-vendor/1.0"})
with urllib.request.urlopen(req, timeout=30) as r:
return r.read().decode("utf-8", "replace")
def scope_css(css: str, scope: str) -> str:
"""Prefix every rule whose selector mentions .hljs with the theme scope."""
out = []
for chunk in css.split("}"):
if "{" not in chunk:
out.append(chunk)
continue
selectors, body = chunk.split("{", 1)
parts = []
for sel in selectors.split(","):
sel = sel.strip()
if not sel:
continue
if ".hljs" in sel:
parts.append(f"{scope} {sel}")
else:
parts.append(sel)
out.append(", ".join(parts) + "{" + body)
return "}".join(out)
def main() -> None:
from pathlib import Path
root = Path(__file__).resolve().parent.parent
js_dir = root / "static" / "js"
css_dir = root / "static" / "css"
common = fetch(COMMON_JS)
(js_dir / "highlight.min.js").write_text(common, encoding="utf-8")
print(f"highlight.min.js: {len(common)} bytes")
extra_parts = []
missing = []
for lang in EXTRA_LANGS:
try:
code = fetch(LANG_URL.format(lang=lang))
extra_parts.append(f"\n/* hljs lang: {lang} */\n" + code)
print(f" + {lang}: {len(code)} bytes")
except Exception as exc: # noqa: BLE001
missing.append(lang)
print(f" ! {lang} missing: {exc}")
(js_dir / "highlight-extra.min.js").write_text(
"/* FlowDeck — highlight.js extra languages (cdnjs %s) */\n" % VERSION
+ "\n".join(extra_parts),
encoding="utf-8",
)
light = fetch(LIGHT_CSS)
dark = fetch(DARK_CSS)
css = (
"/* FlowDeck — highlight.js themes (cdnjs %s): github (light) + "
"github-dark scoped on html[data-theme=\"dark\"] */\n" % VERSION
+ light
+ "\n"
+ scope_css(dark, 'html[data-theme="dark"]')
+ "\n/* FlowDeck tweaks: keep code blocks integrated with app surfaces */\n"
+ ".hljs{background:transparent;}\n"
)
(css_dir / "highlight.css").write_text(css, encoding="utf-8")
print(f"highlight.css: {len(css)} bytes")
if missing:
print("MISSING:", missing)
if __name__ == "__main__":
main()
+653 -27
View File
@@ -540,41 +540,42 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
.sidebar-item.empty-hint:hover { background: none; }
.sidebar-item.empty-hint .page-name { font-size: 12px; }
/* Shared subgroups (Par moi / Avec moi) */
/* Shared subgroups (Par moi / Avec moi) — compact */
.sidebar-subgroup {
padding-top: 2px;
padding-top: 1px;
}
.sidebar-subgroup + .sidebar-subgroup {
margin-top: 6px;
padding-top: 6px;
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid var(--border);
}
.sidebar-subgroup-label {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
font-size: 11px;
padding: 2px 8px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-tertiary);
}
/* Sections */
/* Sections — compact (v7.70 : une seule ouverte à la fois, paddings réduits) */
.sidebar-section {
padding: var(--space-xs) var(--space-md);
padding: 1px 6px;
}
.sidebar-section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 6px;
padding: 3px 4px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition);
user-select: none;
min-height: 24px;
}
.sidebar-section-header:hover {
@@ -589,8 +590,8 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
.sidebar-section-title {
display: flex;
align-items: center;
gap: 3px;
font-size: 12px;
gap: 2px;
font-size: 11px;
font-weight: 600;
color: var(--text-dim);
text-transform: none;
@@ -754,6 +755,10 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
/* Customize Sidebar Panel */
.sidebar-customize-panel {
padding: 8px 12px;
/* v7.69.6 : au-dessus de .customize-overlay (z 998) — sinon chaque clic,
même sur le panneau, touche l'overlay qui referme le mode customize. */
position: relative;
z-index: 999;
}
.scp-header {
@@ -853,14 +858,14 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
display: flex;
align-items: center;
gap: 4px;
padding: 2px 4px;
padding: 1px 4px;
padding-left: 4px;
border-radius: var(--radius-sm);
font-size: 13px;
font-size: 12.5px;
color: var(--text-primary);
cursor: pointer;
transition: background var(--transition);
min-height: 24px;
min-height: 22px;
}
.sidebar-item:hover {
@@ -1037,6 +1042,80 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
color: var(--text-secondary);
}
/* ── Bouton "New" + popover "Create new" (v7.70, style Notion) ──
Popover ouvert vers le haut, bouton pilule en bas de la sidebar. */
.fd-new-wrap {
position: relative;
display: flex;
justify-content: center;
}
.fd-new-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 16px;
border-radius: 20px;
background: var(--bg-hover);
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
border: 1px solid transparent;
cursor: pointer;
transition: background 150ms, border-color 150ms;
}
.fd-new-btn:hover,
.fd-new-btn.open {
background: var(--bg-active);
border-color: var(--border-light);
}
.fd-new-popover {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
right: 0;
background: var(--bg-modal);
border: 1px solid var(--border-light);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0,0,0,.35);
padding: 6px;
z-index: 500;
animation: fd-new-pop-in 150ms ease-out;
transform-origin: bottom center;
}
@keyframes fd-new-pop-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.fd-new-pop-header {
font-size: 12px;
font-weight: 600;
color: var(--text-dim);
padding: 6px 10px 4px;
}
.fd-new-pop-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
border-radius: 6px;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 14px;
text-align: left;
cursor: pointer;
transition: background 120ms, color 120ms;
}
.fd-new-pop-item:hover,
.fd-new-pop-item.active {
background: var(--bg-hover);
color: var(--text-primary);
}
.fd-new-pop-item svg { flex-shrink: 0; }
/* ═══════════════════════════════════════════════════════════
CONTEXT MENU
═══════════════════════════════════════════════════════════ */
@@ -2341,6 +2420,65 @@ button.breadcrumb-current { max-width: 260px; }
cursor: grabbing;
}
/* + « ajouter un bloc dessous » — juste à gauche du handle (gouttière 64px) */
.block-insert-btn {
position: absolute;
left: -64px;
top: 5px;
width: 22px;
height: 22px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: 3px;
color: var(--text-dim);
font-size: 17px;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease;
}
.block-wrapper:hover .block-insert-btn,
.block-wrapper:focus-within .block-insert-btn {
opacity: 1;
}
.block-insert-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* ── v7.66 : bouton « commenter ce bloc » — à droite du bloc (gouttière 64px),
bulle de conversation + , visible au survol comme le handle ── */
.block-comment-btn {
position: absolute;
right: -60px;
top: 4px;
width: 22px;
height: 22px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: 3px;
color: var(--text-dim);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease;
}
.block-wrapper:hover .block-comment-btn,
.block-wrapper:focus-within .block-comment-btn {
opacity: 1;
}
.block-comment-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* ── v5.10.0: selection multiple + menu contexte bloc ── */
.block-wrapper.selected {
background: rgba(35,131,226,0.06);
@@ -2376,6 +2514,30 @@ button.breadcrumb-current { max-width: 260px; }
.block-menu .bm-item.bm-danger .bm-ico { color: #e5484d; }
.block-menu .bm-item.bm-danger:hover { background: rgba(229,72,77,.12); }
.block-menu .bm-sep { height: 1px; background: var(--border, #2a2a2a); margin: 5px 4px; }
/* ── v7.61 : menu contextuel type Notion — recherche, sections, pied, sous-menus ── */
.block-menu .bm-group {
padding: 9px 9px 3px;
font-size: 10px;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: .5px;
}
.block-menu .bm-foot {
border-top: 1px solid var(--border, #2a2a2a);
margin-top: 6px;
padding: 8px 9px 6px;
font-size: 11.5px;
line-height: 1.55;
color: var(--text-tertiary);
}
.block-menu .bm-check { margin-left: auto; color: var(--accent, #2383e2); font-size: 12px; }
.block-menu .bm-item.bm-current { background: var(--bg-secondary); }
.block-menu .bm-dot {
display: inline-block; width: 12px; height: 12px; border-radius: 3px;
border: 1px solid var(--border, #333); vertical-align: -2px;
}
.block-menu .bm-search:focus { border-color: var(--accent, #2383e2); }
.block-menu .bm-colors { padding: 6px 9px; }
.block-menu .bm-colors-label { font-size: 10px; font-weight: 600; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: .4px; margin-bottom: 6px; }
.block-menu .bm-swatches { display: flex; flex-wrap: wrap; gap: 6px; }
@@ -2406,7 +2568,7 @@ button.breadcrumb-current { max-width: 260px; }
color: var(--text-secondary); cursor: pointer; font-size: 12px; border-radius: 6px;
}
.block-menu .bm-cancel:hover { background: var(--bg-hover); }
.block-menu-sub { min-width: 180px; max-height: 320px; overflow-y: auto; }
.block-menu-sub { min-width: 230px; max-height: 60vh; overflow-y: auto; }
.ftable-hdr-toggle { margin-left: 12px; font-size: 12px; color: var(--text-tertiary); display:inline-flex; align-items:center; gap:2px; }
.ftable-hdr-toggle label { display:inline-flex; align-items:center; gap:4px; cursor:pointer; }
@@ -2476,11 +2638,20 @@ button.breadcrumb-current { max-width: 260px; }
font-size: 16px;
}
/* Numbered list */
/* Numbered list — data-num est calculé au rendu (rangée contiguë) */
.block-numbered {
padding-left: 22px;
padding-left: 32px;
position: relative;
}
.block-numbered[data-num]::before {
content: attr(data-num) ". ";
position: absolute;
left: 0;
top: 0;
min-width: 24px;
color: var(--text-primary);
font-size: 16px;
}
/* To-do */
.block-todo {
@@ -2562,6 +2733,35 @@ button.breadcrumb-current { max-width: 260px; }
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border);
}
.code-lang-bar {
display: flex;
align-items: center;
gap: 8px;
}
.code-lang-select {
background: transparent;
border: none;
outline: none;
font-size: 12px;
font-weight: 600;
color: var(--text-dim);
cursor: pointer;
padding: 0;
max-width: 220px;
}
.code-lang-select:hover {
color: var(--text-primary);
}
.code-lang-select option {
background: var(--bg-modal, #1f1f1f);
color: var(--text-primary, #eee);
font-weight: 400;
}
/* highlight.js inside editor code blocks: theme colors the tokens,
the block keeps its app surface (transparent hljs background). */
.block-code code.hljs {
background: transparent;
}
.block-code pre {
margin: 0;
padding: 12px 16px;
@@ -2773,6 +2973,11 @@ button.breadcrumb-current { max-width: 260px; }
border-radius: 50%;
line-height: 1;
}
/* v7.68 : la × de suppression de colonne était en opacity:0 SANS règle de
survol → invisible et pourtant cliquable (même piège que .block-actions) */
.block-column-wrapper:hover .block-actions {
opacity: 1;
}
.block-column {
outline: none;
min-height: 24px;
@@ -2852,35 +3057,410 @@ button.breadcrumb-current { max-width: 260px; }
font-size: 14px;
}
/* FORMAT TOOLBAR (FLOATING) */
/* FORMAT TOOLBAR (FLOATING) — menu contextuel au highlight (v7.62, façon Notion) */
.format-toolbar {
position: fixed;
z-index: 998;
display: flex;
align-items: center;
gap: 2px;
flex-direction: column;
align-items: stretch;
gap: 1px;
min-width: 216px;
max-height: calc(100vh - 16px);
overflow-y: auto;
background: var(--bg-modal);
border: 1px solid var(--border-light);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
padding: 4px 6px;
padding: 6px;
}
.format-toolbar button {
width: 30px;
.format-toolbar .ft-row {
display: flex;
align-items: center;
gap: 2px;
}
.format-toolbar .ft-item {
height: 28px;
min-width: 28px;
padding: 0 7px;
border: none;
background: transparent;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
color: var(--text-primary);
font-size: 14px;
font-size: 13px;
cursor: pointer;
transition: background 0.1s;
}
.format-toolbar button:hover {
.format-toolbar .ft-item:hover {
background: var(--bg-hover);
}
.format-toolbar .ft-item svg {
display: block;
}
/* Ligne 1 : style de texte (libellé du type de bloc + sous-menu) */
.format-toolbar .ft-style {
width: 100%;
justify-content: flex-start;
}
.format-toolbar .ft-style-ico {
font-weight: 600;
color: var(--text-secondary);
}
.format-toolbar .ft-style-name {
flex: 1;
text-align: left;
}
.format-toolbar .ft-caret {
color: var(--text-dim);
font-size: 15px;
line-height: 1;
}
/* « A » : couleur du texte (barre colorée sous la lettre) */
.format-toolbar .ft-a-letter {
position: relative;
font-weight: 600;
}
.format-toolbar .ft-a-letter::after {
content: '';
position: absolute;
left: 1px;
right: 1px;
bottom: -4px;
height: 3px;
border-radius: 2px;
background: var(--accent);
}
.format-toolbar .ft-tx,
.format-toolbar .ft-code-ico {
font-size: 12px;
font-family: var(--font-mono, monospace);
}
.format-toolbar .ft-hl {
border-radius: 2px;
padding: 0 3px;
background: rgba(255,197,61,.28);
}
.format-toolbar .ft-more {
font-size: 16px;
letter-spacing: 1px;
}
/* ── v7.67 : refonte façon capture — ligne Comment | réaction aux 2 extrémités,
entête « Skills » avec ≡ à droite, liste Skills scrollable ── */
.format-toolbar .ft-row-split {
justify-content: space-between;
gap: 4px;
}
.format-toolbar .ft-comment {
justify-content: flex-start;
}
.format-toolbar .ft-skills-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.format-toolbar .ft-skills-head::after {
content: '≡';
font-size: 13px;
color: var(--text-dim);
}
.format-toolbar .ft-skills {
max-height: 132px;
overflow-y: auto;
}
/* Séparateurs, groupe « Skills », pied de page */
.format-toolbar .ft-sep {
height: 1px;
background: var(--border);
margin: 5px 2px;
}
.format-toolbar .ft-group {
font-size: 11px;
color: var(--text-dim);
padding: 3px 7px 2px;
cursor: default;
}
.format-toolbar .ft-skill {
height: 26px;
justify-content: flex-start;
font-size: 13px;
color: var(--text-secondary);
}
.format-toolbar .ft-foot {
height: 26px;
width: 100%;
justify-content: space-between;
font-size: 12px;
color: var(--text-secondary);
}
.format-toolbar .ft-foot kbd {
font-family: inherit;
font-size: 11px;
color: var(--text-dim);
}
/* Info-bulle des éléments du menu de sélection : bulle claire, à droite */
.tooltip.tooltip-light {
background: #ffffff;
border-color: #e3e3e3;
color: #191919;
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
white-space: normal;
max-width: 240px;
line-height: 1.35;
}
.tooltip.tooltip-light em {
color: #6b6b6b;
}
/* ══ v7.63 — Menus mobiles façon Notion : barre horizontale au « / » ══
(défilement latéral) + feuille plein écran Insert block / Turn into / Color ══ */
.fd-mtb {
position: fixed;
z-index: 1300;
display: none;
gap: 2px;
padding: 5px 6px;
max-width: calc(100vw - 16px);
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
background: var(--bg-modal);
border: 1px solid var(--border-light);
border-radius: 10px;
box-shadow: 0 6px 20px rgba(0,0,0,.45);
}
.fd-mtb::-webkit-scrollbar { display: none; }
.fd-mtb .fd-mtb-item {
flex: 0 0 auto;
min-width: 38px;
height: 38px;
padding: 0 8px;
border: none;
background: transparent;
border-radius: 6px;
color: var(--text-primary);
font-size: 16px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
cursor: pointer;
}
.fd-mtb .fd-mtb-item:active { background: var(--bg-hover); }
.fd-mtb .fd-mtb-wide { font-size: 13px; font-weight: 600; padding: 0 10px; }
.fd-mtb .fd-mtb-caret { font-size: 10px; color: var(--text-dim); }
/* Feuille plein écran (titre centré + Cancel bleu à droite, liste qui défile) */
.fd-msheet {
position: fixed;
inset: 0;
z-index: 1500;
display: flex;
flex-direction: column;
background: var(--bg-secondary, #191919);
}
.fd-msheet-head {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
}
.fd-msheet-title { font-size: 15px; font-weight: 600; color: var(--text-primary); }
.fd-msheet-cancel {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--accent, #2383E2);
font-size: 14px;
font-weight: 600;
padding: 6px;
cursor: pointer;
}
/* la liste porte la classe block-menu (hérite des styles .bm-item/.bm-group)
mais doit couvrir tout l'écran, sans la chrome du menu flottant */
.fd-msheet-list.block-menu {
max-width: none;
min-width: 0;
background: none;
border: none;
border-radius: 0;
box-shadow: none;
padding: 6px 8px 40px;
flex: 1 1 auto;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.fd-msheet .bm-item { padding: 12px 10px; font-size: 15px; }
.fd-msheet .bm-group { padding: 14px 10px 4px; }
.fd-msheet .sm-group {
padding: 14px 10px 4px;
font-size: 11px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: .4px;
}
.fd-msheet .slash-item { padding: 12px 10px; font-size: 15px; }
.fd-msheet .slash-item:active { background: var(--bg-hover); }
/* ══ v7.64 — Commentaires & réactions façon Notion ══
Ancres jaunes (texte commenté), pastilles de réaction, tiroir refondu ══ */
.fd-anchor {
background: rgba(255, 213, 0, .32);
border-radius: 2px;
cursor: pointer;
}
.fd-anchor-done { background: rgba(255, 255, 255, .10); }
.fd-anchor-react { background: rgba(255, 213, 0, .28); }
.fd-react-chip {
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: 4px;
padding: 0 5px;
height: 19px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-secondary);
cursor: pointer;
vertical-align: middle;
font-size: 11px;
line-height: 1;
user-select: none;
}
.fd-react-chip:hover { background: var(--bg-hover); }
.fd-react-chip img { width: 12px; height: 12px; object-fit: contain; }
.fd-react-n { font-size: 10px; color: var(--text-dim); }
/* v7.67 : suppression d'une réaction — au survol de la pastille, l'émoji que
J'ai posé devient une croix (un seul clic = retirer). Pas de reflow : c'est
un ::after, les offsets source ne bougent pas. */
.fd-react-emo { position: relative; display: inline-flex; align-items: center; }
.fd-react-emo.is-mine::after {
content: '✕';
position: absolute;
inset: -3px;
display: none;
align-items: center;
justify-content: center;
font-size: 9px;
line-height: 1;
color: var(--text-dim);
background: var(--bg-modal, #1f1f1f);
border: 1px solid var(--border, #333);
border-radius: 50%;
cursor: pointer;
}
.fd-react-chip:hover .fd-react-emo.is-mine::after { display: flex; }
.fd-react-emo.is-mine:hover::after { color: #e5484d; border-color: #e5484d; }
/* Tiroir de commentaires (fil + saisie) */
.comment-thread {
position: relative;
margin-bottom: 12px;
padding: 10px 12px;
background: var(--bg-secondary, #2a2a2a);
border: 1px solid var(--border, #333);
border-radius: 10px;
}
.comment-thread.has-anchor { border-left: 3px solid var(--accent, #2383E2); }
.comment-thread.is-resolved { opacity: .55; }
.thread-flash { outline: 2px solid var(--accent, #2383E2); outline-offset: 2px; }
.ct-head { display: flex; align-items: center; gap: 8px; position: relative; }
.ct-avatar {
width: 24px; height: 24px; border-radius: 50%; color: #fff; flex-shrink: 0;
display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700;
}
.ct-name { font-size: 12px; font-weight: 600; color: var(--text-primary); }
.ct-time { font-size: 11px; color: var(--text-dim); margin-left: auto; }
.ct-actions { display: flex; gap: 2px; margin-left: 2px; }
.ct-act {
width: 24px; height: 24px; border: none; background: transparent; border-radius: 5px;
color: var(--text-dim); font-size: 13px; line-height: 1; cursor: pointer;
}
.ct-act:hover { background: var(--bg-hover); color: var(--text-primary); }
.ct-act.is-on { color: var(--accent, #2383E2); }
.ct-menu {
position: absolute; right: 0; top: 28px; z-index: 6; min-width: 156px; padding: 4px;
background: var(--bg-modal); border: 1px solid var(--border); border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,.4);
}
.ct-menu-item {
display: block; width: 100%; text-align: left; padding: 7px 10px; font-size: 13px;
color: var(--text-primary); background: none; border: none; border-radius: 5px; cursor: pointer;
}
.ct-menu-item:hover { background: var(--bg-hover); }
.ct-menu-item.is-danger { color: #e5484d; }
.ct-body { font-size: 13px; white-space: pre-wrap; margin-top: 6px; color: var(--text-primary); }
.ct-anchor-label { font-size: 10px; color: var(--text-dim); margin-top: 6px; }
/* v7.67 : section Réactions du tiroir (liste des plages réagies + retirer) */
.ct-section-title {
font-size: 11px;
font-weight: 600;
letter-spacing: .5px;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 8px;
}
.ct-reaction {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
margin-bottom: 6px;
background: var(--bg-secondary, #2a2a2a);
border: 1px solid var(--border, #333);
border-radius: 8px;
font-size: 13px;
}
.ct-re-emoji { font-size: 14px; line-height: 1; flex-shrink: 0; }
.ct-re-count { font-size: 11px; color: var(--text-dim); }
.ct-re-text {
flex: 1;
min-width: 0;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.comments-drawer-input { border-top: 1px solid var(--border, #333); padding: 12px 16px; }
.ct-input-row { display: flex; gap: 6px; align-items: flex-end; }
.ct-input-row textarea {
flex: 1; min-width: 0; background: var(--bg-secondary, #2a2a2a);
border: 1px solid var(--border, #333); border-radius: 8px; color: var(--text-primary);
font-size: 13px; font-family: inherit; line-height: 1.4; padding: 8px 10px; resize: none; outline: none;
}
.ct-input-row textarea:focus { border-color: var(--accent, #2383E2); }
.ct-input-actions { display: flex; gap: 4px; align-items: center; padding-bottom: 2px; }
.ct-send {
width: 30px; height: 30px; border-radius: 50%; background: var(--accent, #2383E2); color: #fff;
border: none; font-size: 15px; line-height: 1; cursor: pointer; flex-shrink: 0;
}
.ct-send:disabled { opacity: .4; cursor: default; }
/* Bouton « React to selected text » de la toolbar de sélection */
.ft-react-ico { position: relative; font-size: 15px; line-height: 1; }
.ft-react-plus {
position: absolute; right: -5px; top: -4px; font-size: 9px; font-weight: 700; color: var(--text-dim);
}
/* EDITOR STATUSBAR */
.editor-statusbar {
@@ -2925,7 +3505,9 @@ button.breadcrumb-current { max-width: 260px; }
.page-title-input { font-size: 28px; }
.blocks-container { padding: 8px 16px 96px; }
.block-handle { display: none; }
.block-insert-btn { display: none; }
.block-actions { display: none; }
.block-comment-btn { display: none; }
.editor-statusbar { left: 0; }
}
@@ -3400,6 +3982,7 @@ button.breadcrumb-current { max-width: 260px; }
.page-cover-area { padding: 32px 32px 8px; }
.blocks-container { padding: 8px 32px 80px; }
.block-handle { left: -32px; }
.block-insert-btn { left: -56px; }
.page-title-block::before { left: -32px; }
.page-title-input { font-size: 32px; }
@@ -3424,7 +4007,7 @@ button.breadcrumb-current { max-width: 260px; }
/* ── Sidebar mobile ── */
.sidebar {
display: block;
display: flex; flex-direction: column; /* block cassait flex:1 de .sidebar-scroll → drawer impossible à scroller */
position: fixed !important;
top: 0; left: 0;
bottom: 0; width: 85vw; max-width: 320px;
@@ -3490,7 +4073,9 @@ button.breadcrumb-current { max-width: 260px; }
.page-title-input { font-size: 24px; }
.blocks-container { padding: 8px 16px 96px; }
.block-handle { display: none; }
.block-insert-btn { display: none; }
.block-actions { display: none; }
.block-comment-btn { display: none; }
.page-title-block::before { display: none; }
.page-title-block .page-icon-emoji { margin-left: 0; }
.page-title-block .page-title-input { padding-left: 0; }
@@ -3577,7 +4162,7 @@ button.breadcrumb-current { max-width: 260px; }
.slash-menu { width: 90vw; max-width: 90vw; left: 5vw !important; }
/* ── Format Toolbar ── */
.format-toolbar { left: 8px !important; right: 8px; flex-wrap: wrap; justify-content: center; }
.format-toolbar { left: 8px !important; right: 8px; }
}
/* ═══════════════════════════════════════════════════════════
@@ -4634,6 +5219,7 @@ a.fd-wiki-link:hover { border-bottom-color: var(--accent, #2383e2); }
font-weight: 500;
}
body.page-locked .block-handle,
body.page-locked .block-comment-btn,
body.page-locked .block-add-btn,
body.page-locked .format-toolbar,
body.page-locked .get-started-toolbar { display: none !important; }
@@ -4683,3 +5269,43 @@ body.page-locked .get-started-toolbar { display: none !important; }
.tpl-card-icon { font-size: 20px; line-height: 1.2; }
.tpl-card-name { font-size: 13.5px; font-weight: 600; }
.tpl-card-desc { font-size: 12px; color: var(--text-dim, #999); margin-top: 2px; }
/* ── Unified template picker (registre) + filtre partagé ─────────────── */
.fd-tpl-filter {
border: none;
background: transparent;
color: var(--text-dim, #999);
font-size: 12.5px;
font-weight: 500;
padding: 5px 12px;
border-radius: 7px;
cursor: pointer;
transition: background .12s ease, color .12s ease;
}
.fd-tpl-filter:hover { background: var(--bg-hover); color: var(--text-primary); }
.fd-tpl-filter.active { background: var(--accent); color: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.25); }
.fd-gtpl-overlay { position: fixed; inset: 0; z-index: 2100; background: rgba(0,0,0,.5); display: none; }
.fd-gtpl-overlay.open { display: flex; align-items: center; justify-content: center; padding: 20px; }
.fd-gtpl {
width: 640px; max-width: 96vw; max-height: 80vh; display: flex; flex-direction: column;
background: var(--bg-modal, #fff); border: 1px solid var(--border); border-radius: 14px;
box-shadow: 0 24px 70px rgba(0,0,0,.4); overflow: hidden;
}
.fd-gtpl-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border); }
.fd-gtpl-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }
.fd-gtpl-x { background: none; border: none; color: var(--text-dim); font-size: 20px; line-height: 1; cursor: pointer; padding: 2px 6px; border-radius: 6px; }
.fd-gtpl-x:hover { color: var(--text-primary); background: var(--bg-hover); }
.fd-gtpl-body { padding: 12px 20px; overflow-y: auto; }
.fd-gtpl-search { width: 100%; padding: 8px 12px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 8px; color: var(--text-primary); font-size: 14px; outline: none; box-sizing: border-box; }
.fd-gtpl-search:focus { border-color: var(--accent); }
.fd-gtpl-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 10px; padding: 3px; margin: 10px 0 12px; }
.fd-gtpl-list { display: flex; flex-direction: column; gap: 8px; min-height: 80px; }
.fd-gtpl-card { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; cursor: pointer; background: var(--bg-secondary); transition: border-color .12s ease, background .12s ease; }
.fd-gtpl-card:hover { border-color: var(--accent); background: var(--bg-hover); }
.fd-gtpl-card.sel { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; }
.fd-gtpl-ico { font-size: 20px; line-height: 1; }
.fd-gtpl-name { font-weight: 600; font-size: 14px; color: var(--text-primary); }
.fd-gtpl-meta { font-size: 12px; color: var(--text-dim); margin-top: 2px; }
.fd-gtpl-empty { color: var(--text-dim); font-size: 13px; padding: 20px 0; text-align: center; }
.fd-gtpl-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--border); }
+23
View File
@@ -0,0 +1,23 @@
/* FlowDeck — highlight.js themes (cdnjs 11.9.0): github (light) + github-dark scoped on html[data-theme="dark"] */
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
Theme: GitHub
Description: Light theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-light
Current colors taken from GitHub's CSS
*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}
html[data-theme="dark"] pre code.hljs{display:block;overflow-x:auto;padding:1em}html[data-theme="dark"] code.hljs{padding:3px 5px}html[data-theme="dark"] /*!
Theme: GitHub Dark
Description: Dark theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-dark
Current colors taken from GitHub's CSS
*/.hljs{color:#c9d1d9;background:#0d1117}html[data-theme="dark"] .hljs-doctag, html[data-theme="dark"] .hljs-keyword, html[data-theme="dark"] .hljs-meta .hljs-keyword, html[data-theme="dark"] .hljs-template-tag, html[data-theme="dark"] .hljs-template-variable, html[data-theme="dark"] .hljs-type, html[data-theme="dark"] .hljs-variable.language_{color:#ff7b72}html[data-theme="dark"] .hljs-title, html[data-theme="dark"] .hljs-title.class_, html[data-theme="dark"] .hljs-title.class_.inherited__, html[data-theme="dark"] .hljs-title.function_{color:#d2a8ff}html[data-theme="dark"] .hljs-attr, html[data-theme="dark"] .hljs-attribute, html[data-theme="dark"] .hljs-literal, html[data-theme="dark"] .hljs-meta, html[data-theme="dark"] .hljs-number, html[data-theme="dark"] .hljs-operator, html[data-theme="dark"] .hljs-selector-attr, html[data-theme="dark"] .hljs-selector-class, html[data-theme="dark"] .hljs-selector-id, html[data-theme="dark"] .hljs-variable{color:#79c0ff}html[data-theme="dark"] .hljs-meta .hljs-string, html[data-theme="dark"] .hljs-regexp, html[data-theme="dark"] .hljs-string{color:#a5d6ff}html[data-theme="dark"] .hljs-built_in, html[data-theme="dark"] .hljs-symbol{color:#ffa657}html[data-theme="dark"] .hljs-code, html[data-theme="dark"] .hljs-comment, html[data-theme="dark"] .hljs-formula{color:#8b949e}html[data-theme="dark"] .hljs-name, html[data-theme="dark"] .hljs-quote, html[data-theme="dark"] .hljs-selector-pseudo, html[data-theme="dark"] .hljs-selector-tag{color:#7ee787}html[data-theme="dark"] .hljs-subst{color:#c9d1d9}html[data-theme="dark"] .hljs-section{color:#1f6feb;font-weight:700}html[data-theme="dark"] .hljs-bullet{color:#f2cc60}html[data-theme="dark"] .hljs-emphasis{color:#c9d1d9;font-style:italic}html[data-theme="dark"] .hljs-strong{color:#c9d1d9;font-weight:700}html[data-theme="dark"] .hljs-addition{color:#aff5b4;background-color:#033a16}html[data-theme="dark"] .hljs-deletion{color:#ffdcd7;background-color:#67060c}
/* FlowDeck tweaks: keep code blocks integrated with app surfaces */
.hljs{background:transparent;}
+180
View File
@@ -0,0 +1,180 @@
/* FlowDeck — AI Meeting Notes, panneau façon Notion (v7.1.1).
* Coquille fixe : en-tête / rangée onglets+contrôles / corps / pied (§3B.9).
* Tout le contenu du corps est aligné sur la marge gauche des onglets ;
* tout ce qui est « action globale » est aligné à droite. */
.fm-tabs { display: flex; gap: 2px; align-items: center; }
.fm-tab {
background: none; border: none; color: var(--text-dim); font-size: 12.5px;
cursor: pointer; padding: 6px 12px; border-radius: 99px; font-weight: 500;
display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
}
.fm-tab.active { background: var(--bg-tertiary); color: var(--text-primary); }
.fm-mic {
font-size: 10px; border: 1px solid var(--border); border-radius: 4px;
padding: 0 3px; font-style: normal;
}
.fm-tab.active .fm-mic { border-color: var(--accent); color: var(--accent); }
.fm-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
.fm-controls { display: flex; align-items: center; gap: 6px; margin-left: auto; position: relative; }
.fm-icon {
background: none; border: none; color: var(--text-dim); cursor: pointer;
font-size: 14px; padding: 5px 7px; border-radius: 6px; line-height: 1;
}
.fm-icon:hover { background: var(--bg-hover); color: var(--text-primary); }
/* Bouton de démarrage à menu (split button) */
.fm-split { display: flex; position: relative; }
.fm-start { border-radius: 8px 0 0 8px !important; }
.fm-chevron { border-radius: 0 8px 8px 0 !important; border-left: 1px solid rgba(255,255,255,.35) !important; padding: 7px 9px !important; }
.fm-menu {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 60;
background: var(--bg-modal); border: 1px solid var(--border);
border-radius: 10px; box-shadow: var(--shadow-modal);
padding: 8px; min-width: 280px;
}
.fm-menu-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .4px; padding: 6px 8px 2px; }
.fm-menu-item {
display: block; width: 100%; text-align: left; background: none; border: none;
color: var(--text-primary); font-size: 13px; padding: 7px 8px; border-radius: 6px; cursor: pointer;
}
.fm-menu-item:hover { background: var(--bg-hover); }
.fm-menu-item.sel { color: var(--accent); font-weight: 600; }
.fm-menu-row { display: flex; gap: 6px; }
.fm-menu-row .fm-menu-item { width: auto; }
.fm-menu-check { display: flex; gap: 8px; align-items: center; font-size: 12.5px; color: var(--text-secondary); padding: 8px; cursor: pointer; }
.fm-setup {
display: flex; flex-direction: column; gap: 3px;
font-size: 12.5px; color: var(--text-secondary);
padding: 2px 8px 6px;
}
/* Bande de vérification pré-enregistrement (toujours visible à l'état idle) */
.fm-setupstrip {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
font-size: 12px; color: var(--text-secondary);
border: 1px dashed var(--border); border-radius: 8px; padding: 6px 10px;
}
.fm-dim { color: var(--text-dim); }
/* Enregistrement : onde + chrono + pastilles */
.fm-wave { height: 30px; width: 140px; }
.fm-timer { font-family: monospace; font-size: 12px; color: var(--text-secondary); min-width: 44px; text-align: center; }
.fm-btn-stop { color: #e5484d; }
.fm-paused-badge {
font-size: 11px; padding: 3px 10px; border-radius: 99px;
background: #f5a62333; color: #f5a623; white-space: nowrap;
}
.fm-thinking {
font-size: 11.5px; padding: 4px 12px; border-radius: 99px;
background: var(--bg-tertiary); color: var(--text-primary); white-space: nowrap;
}
/* Panneau réglages */
.fm-settings {
border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px;
display: flex; flex-direction: column; gap: 8px; background: var(--bg-tertiary);
}
.fm-set-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 12.5px; color: var(--text-secondary); }
.fm-set-row select {
background: var(--bg-tertiary); color: var(--text-primary);
border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; font-size: 12.5px;
}
.fm-chip {
background: var(--bg-tertiary); border: 1px solid var(--border);
color: var(--text-secondary); border-radius: 99px; padding: 4px 12px;
font-size: 12px; cursor: pointer;
}
.fm-chip.active { color: var(--text-primary); border-color: var(--accent); }
/* Corps */
.fm-body { display: flex; flex-direction: column; gap: 8px; }
.fm-promise { color: var(--text-dim); font-size: 13px; padding: 2px 0; }
.fm-hint { color: var(--text-dim); font-size: 12px; }
.fm-head { display: flex; align-items: center; gap: 6px; }
.fm-cal { color: var(--text-dim); font-size: 16px; }
.fm-chev { color: var(--text-dim); font-size: 11px; }
.fm-rule { height: 1px; background: var(--border); margin: 0 -2px; }
/* Étapes Thinking */
.fm-steps { display: flex; flex-direction: column; gap: 6px; padding: 4px 0; }
.fm-step { display: flex; gap: 10px; align-items: baseline; font-size: 13px; color: var(--text-dim); }
.fm-step.done { color: var(--text-secondary); }
.fm-step.running { color: var(--text-primary); font-weight: 600; }
.fm-step.failed { color: #e5484d; }
.fm-step-ico { width: 18px; text-align: center; }
/* Barre de diffusion */
.fm-sharebar {
border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px;
display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap;
font-size: 13px; font-weight: 600;
}
.fm-share-btns { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.fm-chipbtn { font-size: 12px; padding: 5px 10px; font-weight: 500; }
/* Résumé structuré */
.fm-summary { display: flex; flex-direction: column; }
.fm-sum-sec h4 { font-size: 13.5px; font-weight: 700; margin: 10px 0 4px; color: var(--text-primary); }
.fm-bullet { display: flex; gap: 8px; padding: 3px 0; font-size: 13px; line-height: 1.5; }
.fm-bullet > span { flex: 1; }
.fm-cite {
background: none; border: none; color: var(--accent); cursor: pointer;
font-size: 11px; vertical-align: super; padding: 0 1px; font-weight: 700;
}
.fm-cite:hover { text-decoration: underline; }
.fm-cite-static { color: var(--accent); font-size: 11px; vertical-align: super; }
.fm-sum-note { color: var(--text-dim); font-size: 12px; font-style: italic; margin-top: 8px; }
.fm-class {
font-size: 12px; color: #f5a623; background: #f5a62314;
border: 1px solid #f5a62344; border-radius: 8px; padding: 6px 10px;
}
/* Transcript groupé par source + rail temporel */
.fm-src { color: var(--text-dim); font-size: 12.5px; margin-top: 8px; }
.fm-seg { display: flex; gap: 8px; padding: 2px 0; font-size: 13px; border-radius: 6px; }
.fm-seg .fm-ts { color: var(--text-dim); font-family: monospace; font-size: 11px; flex-shrink: 0; width: 34px; padding-top: 3px; }
.fm-rail { width: 2px; background: var(--border); border-radius: 2px; flex-shrink: 0; }
.fm-seg-body { flex: 1; min-width: 0; }
.fm-who {
background: none; border: none; color: var(--accent); font-weight: 600;
cursor: pointer; font-size: 12.5px; padding: 0 6px 0 0;
}
.fm-who:hover { text-decoration: underline; }
.fm-flash { background: #2383e233; }
/* Pied de bloc + bandeau */
.fm-foot {
display: flex; align-items: center; gap: 10px; font-size: 12px;
color: var(--text-dim); flex-wrap: wrap;
}
.fm-foot select {
background: transparent; color: var(--text-primary);
border: 1px solid transparent; border-radius: 6px; font-size: 12px; cursor: pointer;
}
.fm-foot select:hover { border-color: var(--border); }
.fm-sep { opacity: .5; }
.fm-consent { flex: 1; min-width: 180px; }
.fm-foot-icons { display: flex; gap: 2px; margin-left: auto; }
.fm-banner {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
font-size: 12px; color: var(--text-secondary);
border-top: 1px solid var(--border); padding-top: 8px;
}
.fm-warn {
font-size: 12.5px; color: #f5a623; background: #f5a62314;
border: 1px solid #f5a62344; border-radius: 8px; padding: 7px 10px;
}
.fm-manual { font-size: 12.5px; color: var(--text-secondary); }
.fm-manual summary { cursor: pointer; color: var(--text-dim); padding: 4px 0; }
.fm-manual-row { display: flex; gap: 8px; align-items: center; margin-top: 6px; flex-wrap: wrap; }
/* Hub Meetings : recherche */
.sb-search-row { padding: 0 8px 6px; }
.sb-search-input {
width: 100%; box-sizing: border-box; background: var(--bg-tertiary);
border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);
font-size: 12px; padding: 5px 9px; outline: none;
}
.sb-search-input:focus { border-color: var(--accent); }
.sb-search-input::-webkit-search-cancel-button { cursor: pointer; }
+34
View File
@@ -0,0 +1,34 @@
/* ═══ Panel skeleton shared by /settings and /help (Notion-style modal) ═══ */
.settings-overlay{position:fixed;inset:0;z-index:500;display:flex;background:rgba(0,0,0,.4);backdrop-filter:blur(2px);}
.settings-panel{position:relative;display:flex;width:1050px;max-width:98vw;height:85vh;margin:auto;background:var(--bg-primary);border:1px solid var(--border-strong);border-radius:14px;box-shadow:var(--shadow-modal);overflow:hidden;}
.settings-nav{width:200px;min-width:200px;background:var(--bg-sidebar);border-right:1px solid var(--border);padding:8px 0;overflow-y:auto;}
.settings-nav-header{padding:12px 16px;font-size:13px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;}
.settings-nav-item{display:flex;align-items:center;gap:8px;padding:6px 16px;font-size:13px;color:var(--text-secondary);cursor:pointer;transition:background 100ms;border-radius:0;}
.settings-nav-item:hover{background:var(--bg-hover);}
.settings-nav-item.active{background:rgba(35,131,226,.12);color:var(--text);font-weight:500;}
.settings-content{flex:1;overflow-y:auto;padding:24px 32px;}
.settings-content h2{font-size:18px;font-weight:600;margin-bottom:4px;}
.settings-content .section-desc{font-size:12px;color:var(--text-dim);margin-bottom:24px;}
.settings-close{position:absolute;top:12px;right:12px;width:32px;height:32px;background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;border-radius:6px;display:flex;align-items:center;justify-content:center;text-decoration:none;}
.settings-close:hover{background:var(--bg-hover);color:var(--text);}
/* Mobile hamburger for the panel nav — hidden on desktop */
.settings-menu-btn{display:none;position:absolute;top:8px;left:8px;z-index:20;width:40px;height:40px;border:none;border-radius:8px;background:var(--bg-tertiary);color:var(--text);align-items:center;justify-content:center;cursor:pointer;}
.settings-nav-backdrop{display:none;}
/* ── Mobile: nav becomes a left side-navigation drawer (hamburger opens it) ── */
@media (max-width:768px){
.settings-overlay{background:var(--bg-primary);backdrop-filter:none;}
.settings-panel{flex-direction:column;width:100vw;max-width:100vw;height:100dvh;max-height:100dvh;margin:0;border:none;border-radius:0;box-shadow:none;}
.settings-menu-btn{display:flex;}
.settings-nav{position:fixed;top:0;left:0;bottom:0;width:260px;max-width:80vw;min-width:0;z-index:600;transform:translateX(-100%);transition:transform .2s cubic-bezier(0.4,0,0.2,1);border-right:1px solid var(--border);box-shadow:4px 0 24px rgba(0,0,0,.4);padding-top:8px;}
.settings-nav.nav-open{transform:translateX(0);}
.settings-nav-item{padding:10px 16px;}
.settings-nav-backdrop{display:block;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:599;}
.settings-content{padding:56px 16px 16px;}
.settings-close{top:8px;right:8px;width:44px;height:44px;background:var(--bg-tertiary);}
.setting-row{flex-wrap:wrap;}
.setting-control{margin-left:0;width:100%;}
.prov-grid{grid-template-columns:1fr;}
.avatar-section{gap:10px;}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#2383E2"/>
<text x="16" y="22" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#fff">FD</text>
</svg>

Before

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 999 B

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Some files were not shown because too many files have changed in this diff Show More