Compare commits

...
55 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 f8acb906e5 feat: connecteurs Discord/Telegram/MCP + outils MCP dynamiques au registre (v7.57.0)
FlowDeck CI / lint (push) Successful in 2m14s
FlowDeck CI / test (push) Failing after 17m51s
FlowDeck CI / docker (push) Skipped
- presets Discord / Telegram : colonnes kind + auth (bearer/bot/none) sur
  agent_connectors (migration 34) ; Discord envoie le jeton préfixé dans
  l'en-tête d'autorisation, Telegram le place dans l'URL via le substitut
  {secret} remplacé à l'appel — jamais stocké en clair
- Teams : scope ChannelMessage.Read.All ajouté aux Graph scopes M365
- app/services/mcp_client.py : handshake initialize → notifications/initialized
  → tools/list → tools/call (JSON-RPC 2.0, réponse JSON ou premier data: d'un
  text/event-stream), garde SSRF, outils cachés en base (tools_json) —
  le bouton « Tester » rejoue le handshake
- ToolRegistry._all() merge le cache MCP à chaque run : outils exposés au LLM
  sous mcp_<serveur>_<outil> avec leur inputSchema, dispatch tools/call ;
  serveur désactivé → outil absent du schéma, échec réseau → ToolResult(error)
- menu + : champ Type dans le formulaire connecteur (preset URL + auth),
  badge « · N outil(s) » sur la fiche d'un serveur MCP
- tests : tests/test_v757_mcp_discord.py (12 tests, 0 appel réseau réel) ;
  suite complète 1331 verts, ruff 0, eslint 0
- livraison : VERSION + app/main = 7.57.0, OpenAPI 523 chemins, CHANGELOG,
  ROADMAP phase 7 cochée, avenant phase 7 dans docs/V74_Agent_Plus_Menu.md
2026-10-07 09:09:19 -04:00
bruno 1d7750bda0 feat: connecteurs Google + Microsoft 365 (OAuth2 PKCE) — phase 6/8 (v7.56.0)
FlowDeck CI / lint (push) Successful in 2m14s
FlowDeck CI / test (push) Failing after 16m4s
FlowDeck CI / docker (push) Skipped
- app/services/oauth_connectors.py : flow OAuth2 complet PKCE (S256) pour
  2 fournisseurs décrits par 1 dict — Google (Drive/Gmail/Calendar en lecture
  seule) et Microsoft 365 (Graph Files.Read / Mail.Read / Calendars.Read) ;
  begin() = URL d'autorisation + state + code_verifier, complete() = échange du
  code, access_token() = refresh automatique (60 s de marge, refresh_token
  conservé si absent de la réponse), api_get() = path absolu refusé + validation
  SSRF + borne 20 000 car.
- Tokens chiffrés Fernet en réutilisant calendar_sync._encrypt_tokens (zéro
  dépendance) dans la table connector_tokens (migration 33, PK (kind, user_id)).
- 4 routes /api/agent/connectors/oauth/{kind}/… : status, authorize (cookies
  d'état HttpOnly 10 min, retour same-origin validé), callback (GET safe, state
  comparé en temps constant, tokens stockés puis cookies purgés, redirection
  ?oauth=connected / ?oauth_error=), disconnect. OpenAPI 523 chemins.
- Config + .env.example : GOOGLE_CLIENT_ID/SECRET, MS_CLIENT_ID/SECRET (vidés =
  « non configuré »), redirect URI dérivé d'APP_BASE_URL.
- Catalogue : google/ms365 en natifs avec badge connecté/non connecté ;
  connector_fetch et Tester passent par l'API du fournisseur avec le token de
  l'utilisateur (user_id transmis par l'outil LLM).
- Menu + : « Se connecter » / « Déconnecter » sur la fiche, toast au retour du
  flux (URL nettoyée par history.replaceState). État dans la fiche du menu
  plutôt qu'une page dédiée.
- Tests : tests/test_v756_oauth_connectors.py (13), 0 appel réseau réel
  (_post_form / _api_get monkeypatchés) — state forgé refusé sans échange,
  tokens chiffrés en base, refresh, URL absolue refusée, 401/404, câblage menu ;
  test_v755 adapté (5 natifs). Suite complète 1319 verts (-n auto), ruff 0,
  eslint 0 erreur (19 warnings préexistants hors fichiers touchés).
2026-10-07 08:29:22 -04:00
bruno 18c72d77fe feat: menu + — Connecteurs (socle) — phase 5/8 (v7.55.0)
FlowDeck CI / lint (push) Successful in 2m11s
FlowDeck CI / test (push) Failing after 15m16s
FlowDeck CI / docker (push) Skipped
- Table agent_connectors (migration 32) : colonnes plates name, url,
  secret_encrypted, enabled, status, detail — config_json remis (YAGNI, les
  scopes OAuth des phases 6-7 ajouteront le leur).
- app/services/connectors.py : 1 fichier au lieu du package connectors/ —
  3 natifs (gitea, github, web) servis à la volée avec statut sans réseau,
  CRUD des personnels (URL validée par _validate_url = garde SSRF, clé chiffrée
  Fernet et jamais renvoyée — seul has_secret), probe() qui persiste
  status/detail, connector_fetch() borné à 20 000 car.
- API /api/agent/connectors : GET, POST (400 URL privée), PATCH, DELETE,
  POST /connectors/probe — 401 sans session, CSRF global. OpenAPI 519 chemins.
- Outil LLM connector_fetch (26e outil) : un seul outil qui dispatche vers
  Gitea/GitHub/web/personnalisé (id ou nom ou kind + path + query) au lieu d'un
  outil par connecteur ; désactivé/inconnu = erreur outil, jamais de run cassé.
- Menu + : section « Connecteurs » (catalogue avec badge ✓/✗/⚠/?), fiche par
  connecteur (Tester, Activer/Désactiver, Supprimer — masqués pour les natifs),
  formulaire « Ajouter un connecteur personnalisé » (clé en type=password).
- Tests : tests/test_v755_connectors.py (13) — 3 natifs, roundtrip Fernet de la
  clé, 5 URLs refusées (localhost, 127.0.0.1, metadata cloud, ftp:, file:),
  toggle persisté, 401, probe OK/erreur, outil complet, câblage menu ;
  test_v751/v753/v754 adaptés (1 seule section « bientôt » = plugins).
  Suite complète 1306 verts (-n auto), ruff 0, eslint 0 problème.
2026-10-06 23:26:19 -04:00
bruno 64c85caffc feat: menu + — Mémoire de l'agent — phase 4/8 (v7.54.0)
FlowDeck CI / lint (push) Successful in 2m13s
FlowDeck CI / test (push) Failing after 3h10m19s
FlowDeck CI / docker (push) Skipped
- Le moteur n'envoie jamais l'historique (system + objectif courant seulement)
  : chaque run repartait de zéro. Nouveau service app/services/agent_memory.py —
  une ligne résumé par conversation (table agent_memory, migration 31),
  écrite en fin de run et ré-injectée au contexte du run suivant.
- context_for() : bloc « ## Mémoire de la conversation », budget 4000 car.
  (troncature gauche + marqueur). remember() : 1 note/échange (180 → 700 car.),
  20 notes max, upsert, erreurs journalisées — un run ne peut pas échouer à
  cause de la mémoire. Toggle OFF = ni lecture ni écriture.
- Toggle « Mémoire » dans le menu + : entrée racine à l'état réel (🧠/💭 +
  libellé), PATCH /api/agent/conversations/{id} avec memory_enabled ajouté au
  whitelist, état lu à l'ouverture et à la création, défaut AGENT_MEMORY_DEFAULT
  (settings.agent_memory_default) + memory_enabled dans la réponse de création.
- Décision : mémoire d'espace (lignes workspace_id NULL) remise — YAGNI, rien
  ne l'écrivait ; noté ponytail: dans le service avec le chemin de montée.
- Tests : tests/test_v754_memory.py (8) dont 2 runs réels du moteur (FakeLLM
  capture le prompt : mémoire présente ON / absente OFF) ; test_v751 et
  test_v753 adaptés (2 sections « bientôt » restantes). Suite complète 1293
  verts (-n auto, 2e run ; 1 échec intermittent sur test_v56_import URL au
  1er run, vert isolé + vert au run suivant = flake préexistant), ruff 0,
  eslint 0 problème. OpenAPI 516 chemins / 7.54.0.
2026-10-06 22:53:15 -04:00
bruno a68e8848ea feat: menu + — Design System – Canevas — phase 3/8 (v7.53.0)
FlowDeck CI / lint (push) Successful in 2m12s
FlowDeck CI / test (push) Failing after 14m56s
FlowDeck CI / docker (push) Skipped
- Section « Design System – Canevas » : liste des canevas (intégrés +
  personnels, GET /board/api/page-templates) puis page de détail avec
  « Créer une page à partir du canevas », « Insérer dans le document ouvert »
  (désactivé sans document ouvert) et « Enregistrer le document ouvert comme
  canevas ».
- GET /board/api/page-templates/{id}/blocks (nouvelle route) : blocs d'un
  canevas — intégré via template_id=0&key=, sinon id ; 404 clé/id inconnu.
  Insertion côté front via ensureBlockIds() global + concat dans E.blocks
  + autoSave/render.
- Canevas « Design System » ajouté aux builtins (callout tokens, TOC, toggles
  composants, grille, checklist revue UI, citation).
- Fix : le front du panneau appelait /api/page-templates (404 silencieux) —
  le router est sous prefix=/board → /board/api/page-templates.
- Piège : pour /use la clé builtin se passe DANS LE BODY (body.key), pas en
  query string — test garde-fou ajouté.
- Tests : tests/test_v753_canvases.py (5) + test_v751 adapté. Suite complète
  1285 verts (-n auto), ruff 0, eslint 0 problème. OpenAPI 516 chemins.
2026-10-06 21:59:34 -04:00
bruno 18a2d45d46 feat: menu + — Compétences-skills « Gérer » + « Parcourir » — phase 2/8 (v7.52.0)
FlowDeck CI / lint (push) Successful in 2m11s
FlowDeck CI / test (push) Failing after 13m54s
FlowDeck CI / docker (push) Skipped
- Sous-menu « Compétences-skills » : 11 skills builtin + skills enregistrés,
  libellés affichés « Deep research » (/research) et « Skill-creator »
  (/create-new-skill) via FD_SKILL_ALIAS (slugs intacts) ; le clic épingle le
  skill au contexte, même chemin que la palette /.
- « Gérer les compétences » : section du menu + formulaire intégré (nom,
  description, prompt) — création, édition, suppression (chip épinglé nettoyé),
  export JSON téléchargé, import fichier avec overwrite (pas de doublon).
- « Parcourir les compétences » : section galerie filtrable (nom/description/
  slug), installation en 1 clic (installGallerySkill réutilisé).
- PATCH /api/agent/skills/{skill_id} créé : édition partielle, whitelist de
  colonnes, 400/401/404/409. OpenAPI régénéré : 515 chemins, version 7.52.0.
- En-tête ← + titre de section dans le menu (navigation à 3 niveaux).
- Fix : épingler un skill enregistré n'efface plus le message déjà tapé
  (_pinDbSkill ne vide le composer que s'il est vide).
- Tests : tests/test_v752_skills_menu.py (7) + test_v751 adapté. Suite
  complète 1280 verts (-n auto), ruff 0, eslint 0 problème.
2026-10-06 21:39:24 -04:00
bruno f271ac9b7a feat: menu + de l'assistant en hub de contexte — phase 1/8 (v7.51.0)
FlowDeck CI / lint (push) Successful in 2m11s
FlowDeck CI / test (push) Failing after 3h10m48s
FlowDeck CI / docker (push) Skipped
- Bouton + : menu à sections (fichiers/répertoires, compétences-skills,
  connecteurs, Design System – Canevas, Add plugins, Mémoire on/off).
  Sections pas encore livrées affichées « bientôt (phase N) » mais désactivées,
  navigation clavier ↑/↓/Entrée/Échap, focus visible, la frappe referme le menu.
- Parcours « Parcourir… » : un niveau par appel via GET /api/nav/menu
  (contrat : dossier = icon 'folder'), fil d'Ariane cliquable, épingle de dossier
  via la ligne « 📌 Épingler le dossier ».
- Jeton folder:<id> résolu par ContextBuilder._single_folder() : titre du dossier
  + documents directs, budget ~12k caractères (marqueur « tronqué »), enfants
  directs seulement.
- « Rechercher… » conserve l'ancien sélecteur de mentions (@ inline + recherche
  par nom) : régression zéro sur le chemin existant.
- Tests : tests/test_v751_plus_menu.py (7 tests). Suite complète 1273 verts
  (-n auto), ruff check app tests propre, eslint static/js 0 erreur.
- Docs : CHANGELOG, ROADMAP (phase 1 cochée), docs/V74_Agent_Plus_Menu.md statut.
2026-10-06 20:01:47 -04:00
bruno f006880394 docs: plan par phases du menu + de l'assistant + design V74
FlowDeck CI / lint (push) Successful in 2m12s
FlowDeck CI / test (push) Failing after 13m39s
FlowDeck CI / docker (push) Skipped
- ROADMAP : section « v7.51.0 → v7.58.0 — Menu + de l'assistant »
  (8 phases : menu hub/fichiers-répertoires, compétences, canevas,
  mémoire, connecteurs socle, Google+M365, Discord/Telegram/MCP, plugins),
  état des lieux lu dans le code, aucun case cochée.
- docs/V74_Agent_Plus_Menu.md : maquette, découpage front/back, modèle de
  données, sécurité (SSRF, secrets Fernet, CSRF), décisions D1-D7,
  pièges repo, critères d'acceptation par phase.
2026-10-06 19:35:22 -04:00
bruno 81746d9440 fix: peek de Library nu comme local-workspace - iframe pleine (v7.50.2)
FlowDeck CI / test (push) Failing after 13m34s
FlowDeck CI / lint (push) Successful in 2m32s
FlowDeck CI / docker (push) Skipped
.peek-body gardait padding:16px 20px + overflow-y:auto (heritage de l'ancien
peek a rendu HTML) : l'iframe etait amochee (519x816 mesures contre 559x848
en local-workspace) avec bande grise autour du document + double scrollbar.
Corps nu (padding:0, overflow:hidden) : l'editeur remplit le panneau,
exactement comme le peek de /local-workspace.

Mesure Playwright (cookie de session forge, les 14 boutons Open de Library) :
embed OK partout, bases hydratees, aucun jeton « Loading database... ».
Test: test_library_peek_body_has_no_inset_padding. Suite 1266/0 (-n auto)
+ ruff 0. Bump VERSION/main.py 7.50.2, CHANGELOG, WORKLOAD, OpenAPI.
2026-10-06 15:53:04 -04:00
bruno f53205bc45 fix: peek des bases hydrate FlowDeckDB + Open de ligne, tableau pleine largeur (v7.50.1)
FlowDeck CI / test (push) Failing after 14m37s
FlowDeck CI / lint (push) Successful in 2m11s
FlowDeck CI / docker (push) Skipped
- page_editor_embed.html incluait seulement _page_editor_scripts.html :
  window.FlowDeckDB etait undefined dans l'iframe -> une base ouverte en side
  peek restait a « Loading database... ». Include _database_table_scripts.html
  ajoute (meme traitement que page_editor.html).
- Le panneau #db-side-peek n'existait que dans _database_table.html : hors page
  dediee, openPageInSidePeek etait un no-op. Desormais cree a la demande par
  ensureDbPeek (database_table.js) ; markup unique retire du partial.
- .database-table-container : plafond 1100px retire (regle heritee de Library)
  + max-width 100% pour .db-first et database-page-title : le tableau prend la
  largeur du document, plus de barre de scroll parasite sous le tableau.
- Tests: test_embed_editor_can_render_inline_databases. Suite 1265/0 (-n auto)
  + ruff 0. Bump VERSION/main.py 7.50.1, CHANGELOG, WORKLOAD, OpenAPI.
2026-10-06 15:15:01 -04:00
bruno 3706689eca fix: side peek = document seul en edition + bouton Open calendrier/Kanban (v7.50.0)
FlowDeck CI / lint (push) Successful in 2m15s
FlowDeck CI / test (push) Failing after 13m47s
FlowDeck CI / docker (push) Skipped
- Les 4 peeks (library.js, local_workspace.js, my_tasks.js, database_table.js)
  chargent /pages/{id}?embed=1 : le panneau ne montre plus que le document en
  mode edition, sans sidebar ni barre (regression v7.49.0). Le cadre du
  panneau (close/full/resize partages fdWirePeekResize) est inchange.
- Calendrier My Tasks : le handler testait e.target.dataset.openRow mais les
  boutons .mt-cal-open ne contiennent que leur <svg> -> clic sur l'icone =
  e.target = <svg>, rien ne partait. cause racine corrigee par
  t.closest('[data-open-row]') : un seul chemin tableau/calendrier/Kanban.
- Kanban My Tasks : meme bouton Open ajoute sur les tuiles (openButton(t),
  overlay absolu revele au survol de la carte .mt-card).
- Menu contextuel global « Open in side peek » : etait un toast d'erreur ;
  route maintenant vers le peek de la page courante (_libData.openPeekById /
  _wsData.openSidePeek), repli nouvel onglet ailleurs.
- /pages/{id}?embed=1 sur une page de base (content_format=collection)
  rendait un editeur de blocs vide : le template collection est garde en
  embed (body.embed-mode retire sidebar+barre, le tableau reste). Bouton
  pleine page des peeks de bases : navigue via dataset.page (URL propre)
  au lieu du src ?embed=1.
- Tests : garde de coherence inversee (?embed=1 exige sur les 4 fichiers),
  +3 nouveaux (closest, tuiles Kanban, collection embed). Fix d'un test
  pre-existant casse (assertion panel.style.display, reecriture vanilla JS
  v7.49.0). Suite 1264 passed / 0 failed (-n auto) + ruff 0.
- Bump VERSION/main.py 7.50.0, CHANGELOG, WORKLOAD, OpenAPI regenere.
2026-10-06 12:48:48 -04:00
bruno 7dbaefb381 fix: page de base - titre au-dessus du tableau, tableau au ras du sidebar (v7.49.2)
FlowDeck CI / lint (push) Successful in 2m11s
FlowDeck CI / test (push) Failing after 13m46s
FlowDeck CI / docker (push) Skipped
- .db-first (page_editor_scripts._applyLayout) quand le premier bloc est une
  base embarquee : .blocks-container/.page-title-block passent a margin:0 +
  padding-left:var(--space-lg) au lieu de max-width:900px centre -> le tableau
  demarre a x=256 (marge topbar) au lieu de x=694 sur 1920px (mesure Playwright).
- .block-embed-collection { max-width:100%; overflow:hidden } : l'embed ne
  deborde plus de sa colonne (les popovers fixes restent hors du clipping).
- page_editor_collection.html : h1.database-page-title au-dessus du conteneur
  (les pages collection dediees n'avaient pas de titre).
- Bump VERSION/main.py 7.49.2, CHANGELOG, WORKLOAD, OpenAPI regenere.
- Instance locale flowdeck-final rebuildee flowdeck:v7.49.2 :8081, health OK.
2026-10-06 09:49:16 -04:00
bruno 15b6b163f4 fix: tableau des bases aligne sur le titre (v7.49.1)
FlowDeck CI / lint (push) Successful in 2m10s
FlowDeck CI / test (push) Failing after 15m54s
FlowDeck CI / docker (push) Skipped
.database-table-container: padding 0 24px -> 0 var(--space-lg), identique a
celui de la topbar (16px) : le bord gauche du tableau s'aligne au ras du
fil d'Ariane/titre, au plus pres de la marge gauche. Bump VERSION/main.py,
CHANGELOG, WORKLOAD, OpenAPI. Instance locale flowdeck-final rebuild ee
v7.49.1, health OK, CSS verifie dans le conteneur.
2026-10-06 08:18:02 -04:00
bruno 2e5efcfe07 chore: retire commit_msg.txt embarque par erreur
FlowDeck CI / lint (push) Successful in 2m13s
FlowDeck CI / test (push) Failing after 15m3s
FlowDeck CI / docker (push) Skipped
2026-10-05 22:48:00 -04:00
bruno 1d1cdbd618 fix: side peek des bases repasse en vanilla JS + largeur 1100px standard (v7.49.0)
FlowDeck CI / test (push) Failing after 3h13m58s
FlowDeck CI / lint (push) Successful in 2m12s
FlowDeck CI / docker (push) Skipped
- Panneau peek: les bindings Alpine (x-data absent du conteneur) rendaient
  loovverture et le redimensionnement inoperants -> cblage direct sur le document.
- Helper unique window.fdWirePeekResize (app.js): pointer capture, 300px-90vw,
  clic=fermer, largeur persiste fd_peek_width partagee entre les 4 peeks.
- database-table-container margin:0 (tableau colle a gauche, marge Library).
- .lib-container remonte dans app.css (trash etait pleine largeur), .db-index 1100px.
- ObsiGate verifie sans code: creation .xlsx OK (openpyxl, #186).
2026-10-05 22:47:36 -04:00
bruno 894004a1f5 feat: confirmations destructives de /trash en fenêtre thématisée (v7.45.6)
FlowDeck CI / docker (push) Successful in 1m52s
FlowDeck CI / lint (push) Successful in 1m59s
FlowDeck CI / test (push) Successful in 15m20s
Les 3 confirmations destructives de la poubelle (suppression définitive
unitaire, suppression groupée, Empty Trash) remplacent le confirm() du
navigateur par une fenêtre FlowDeck :

- réutilise les classes flowdeck-modal-overlay / .flowdeck-modal déjà
  présentes dans base.html (variables --bg-secondary / --border / --accent,
  rayon 12 px, boutons Cancel + confirmation coloré) → aucun nouveau CSS ;
- overlay cliquable pour annuler, Échap pour annuler, × pour annuler ;
- bouton de confirmation porteur du libellé exact (Delete / Delete all /
  Empty Trash) et message reprenant le nom de la page ou le nombre de pages ;
- role="alertdialog" aria-modal + aria-labelledby ;
- composant dans trashData() : askConfirm / cancelConfirm / acceptConfirm,
  mutations de propriétés individuelles (pitfall réactivité Alpine) ;
- id #fd-trash-confirm (la classe flowdeck-modal-overlay sert déjà à
  #fd-global-folder-modal → locator unique côté test) ;
- aucun confirm() natif ne subsiste dans trash.html.

Gates : e2e/trash_ui.spec.js étendu — ouverture + titre + message de la
fenêtre, ANNULATION vérifiée (page toujours présente), confirmation
(suppression réelle : plus dans la poubelle + contenu effacé), fenêtre Empty
Trash ouverte/annulée quel que soit le contenu, purge réelle seulement si la
poubelle ne contient que des pages E2E (instance partagée) ; le handler
dialog du test échoue si un dialog natif revient (NATIVE_DIALOG). Suite e2e
11 passed · pytest 1099 passed · ruff OK · OpenAPI 510 chemins / 7.45.6.
2026-10-03 10:16:45 -04:00
bruno 6582df3bcb feat: poubelle flexible (sélection groupée, purge, tri/filtres, dates) + 4 bugs (v7.45.5)
FlowDeck CI / docker (push) Successful in 1m52s
FlowDeck CI / lint (push) Successful in 2m1s
FlowDeck CI / test (push) Successful in 15m21s
Page /trash alignée sur ce que fait ce type de section :
- sélection multiple (Select all porté au filtre courant) + barre d'actions
  groupées Restore / Delete / Clear ;
- Empty Trash via POST /board/api/trash/empty (purge en masse, 1 requête) ;
- tri Recently deleted / Oldest first / Name et filtre emplacement RÉELS —
  remplacent les deux boutons décoratifs « Last edited by ▾ » et « In ▾ » qui
  ne faisaient rien ;
- date de suppression + jours restants par élément (rétention 30 j alignée sur
  app/services/trash.py, horodatages UTC/ISO gérés) ;
- compteur de résultats, états vides distincts (vide vs filtre sans résultat +
  Clear filters), toasts sur chaque action.

Bugs trouvés en route et corrigés :
1. Restore/Delete de la page ne marchaient JAMAIS : getCsrf() renvoie la chaîne
   du jeton mais le code faisait csrf?.[1] → 2e caractère → 403 CSRF silencieux
   avalé par if (r.ok).
2. « Move to Trash » de l'éditeur = 404 permanent : route appelée
   /board/api/pages/{id}/trash (inexistante) alors que la route réelle est
   /api/pages/{id}/trash, et le .then() naviguait quand même → la page partait
   à l'accueil SANS être mise à la poubelle. URL corrigée + r.ok vérifié.
3. Page restaurée invisible en Library/Recents/Private : la suppression éditeur
   réécrivait parent_section='Trash' et la restauration ne le remettait pas,
   alors que tous les listings filtrent parent_section != 'Trash'. Écriture
   retirée (deleted_at = source de vérité unique) + réparation idempotente au
   boot dans db.init_db + requête sidebar /trash alignée sur deleted_at.
4. Routes trash sans aucune authentification (le CSRF ne protège pas : cookie
   lisible + en-tête forgé) : 401 ajouté sur list/restore/delete/empty et sur
   POST /api/pages/{id}/trash — vérifié anonyme → 401.

Gates : tests/test_trash_api.py (5) · e2e/trash_ui.spec.js (1, avec garde « on
ne vide jamais la poubelle d'autrui ») · pytest 1099 passed · ruff OK ·
e2e probe_nav_perf + partial_nav + editor_mount + logout_dnd + smoke = 10 passed ·
OpenAPI 510 chemins / 7.45.5.
2026-10-03 01:35:26 -04:00
bruno aa88cf6665 fix: anti-FOUC au chargement complet (x-cloak sur .app-layout) + nettoyage (v7.45.4)
FlowDeck CI / docker (push) Successful in 1m52s
FlowDeck CI / lint (push) Successful in 2m15s
FlowDeck CI / test (push) Successful in 15m22s
- Symptôme rapporté : clic sur Home → rafale de menus/fenêtres + bande rouge
  « You are offline. Changes will sync when connection is restored. ».
- Constat : le masquage de v7.45.3 ne couvrait QUE les swaps partiels fdLoad.
  Un chargement COMPLET de document (F5, première visite, navigation servie par
  le service worker) peignait toute l'app tant qu'Alpine n'avait pas initialisé
  .app-layout : sidebar brute (sections ouvertes, menu utilisateur) + zone brute
  dont la bande hors ligne (aucun x-cloak dessus). Le symptôme Home n'est par
  ailleurs PAS rejouable en navigateur frais (nav, clic Home en ligne/hors
  ligne, plein chargement → 0 frame de contenu brut, 0 erreur console).
- Fix : x-cloak sur le nœud racine Alpine .app-layout (base.html) — sidebar +
  zone + bandeau masqués jusqu'au montage d'appState(), retrait par Alpine à
  l'init. Le probe asserte que l'attribut est bien retiré et que le node est
  visible en fin de parcours (garde-fou contre la page blanche).
- Suppression de la classe fd-navigating (posée à chaque fdLoad, aucune règle
  CSS dans le dépôt → code mort).
- e2e/probe_nav_perf.spec.js : 4 scénarios avec assertions (nav 5 pages, clic
  Home réel, Home hors ligne avec SW, plein chargement 3 pages) + capture
  screenshot auto si contenu brut + collecte console.

Gates : probe_nav_perf 4/4 · regression_partial_nav + regression_editor_mount
+ regression_logout_dnd + smoke = 6 passed (errs=0 sur 7 pages × 3 passages) ·
pytest 1094 passed · ruff OK · OpenAPI 7.45.4.
2026-10-03 00:54:28 -04:00
bruno b2ea8ec537 docs: suivis v7.45.3 listés au ROADMAP (CLS library, fenêtre settings, topbar swappée, fd-navigating morte)
FlowDeck CI / lint (push) Successful in 1m57s
FlowDeck CI / test (push) Failing after 3h12m37s
FlowDeck CI / docker (push) Skipped
2026-10-03 00:16:52 -04:00
bruno 1051a72b52 fix: flashs de navigation — la zone swapée n'est plus peint non montée (v7.45.3)
FlowDeck CI / docker (push) Successful in 1m52s
FlowDeck CI / lint (push) Successful in 1m58s
FlowDeck CI / test (push) Successful in 15m25s
- Symptôme : à chaque changement de page/section, une rafale de fenêtres /
  menus / états s'affiche une fraction de seconde avant la page voulue.
- Cause : pendant la fenêtre x-ignore (swap fdLoad → Alpine.initTree, mise en
  place en v7.45.2), .main-wrapper était peint NON montée : tous ses [x-show]
  visibles à leur valeur brute, puis masqués d'un coup au montage. Mesure
  (e2e/probe_nav_perf.spec.js sur l'instance locale) : 19 éléments bruts
  visibles 87 ms sur /library (CLS 0.149, sources lib-loading/lib-empty/
  lib-table), 80 éléments pendant 732 ms sur /settings, 7 sur /workspaces.
- Fix (app.js, handler htmx:afterSwap) : opacity:0 + pointer-events:none posés
  dans la même task que le swap (aucun paint intermédiaire possible), retirés
  APRÈS Alpine.initTree sur les 3 chemins de démontage (scripts chargés, zéro
  script, filet 4 s). reveal() est appelé avant les early-returns de done() →
  impossible de rester bloqué invisible.
- Après : 0 frame de contenu brut peint sur les 4 navigations du probe,
  1–2 frames masquées, zone révélée (opacity:1, pointer-events:auto) et
  montée (0 [x-show] non montés, x-ignore absent) à chaque fois.
- Gate : e2e/probe_nav_perf.spec.js — marques htmx, fenêtre x-ignore,
  échantillon par frame des [x-show] bruts peints, layout-shift + assertion
  (0 brut / zone révélée et montée), exécutable en CI.
- Reste (non traité, voir ROADMAP) : CLS résiduel 0.057 sur /library (états
  loading → table), fenêtre x-ignore de ~250 ms sur /settings (coût des
  scripts de page), topbar incluse dans .main-wrapper re-swappée à chaque nav.

Gates : probe_nav_perf + regression_partial_nav + regression_editor_mount +
regression_logout_dnd + smoke · pytest 1094 passed · ruff OK ·
OpenAPI 7.45.3.
2026-10-03 00:15:41 -04:00
bruno afbc236cc2 fix: navigation partielle Alpine sans course + scripts de page idempotents (v7.45.2)
FlowDeck CI / docker (push) Successful in 1m51s
FlowDeck CI / lint (push) Successful in 1m57s
FlowDeck CI / test (push) Successful in 15m16s
- app.js : x-ignore sur .main-wrapper au swap fdLoad (nœud remplacé
  uniquement), démontage unique quand tous les <script src> ont exécuté
  (load/error + filet 4s) via Alpine.initTree idempotent (_x_marker) —
  fini les cascades « Undefined variable » (éditeur) et « reading 'has' »
  ($store.fdCtx) : la zone s'initialisait avant composants et stores.
- 5 scripts de page gardés contre la ré-exécution (SyntaxError
  « Identifier 'LW' has already been declared » à la 2e visite
  partielle) : local_workspace, board, settings, database_table,
  page_editor_realtime.
- if (window.Alpine) → Alpine.data immédiat sinon listener alpine:init
  (déjà passé sur swap) : 8 scripts + 7 templates inline.
- app.css : purge des 7 blocs @font-face Inter orphelins (fichiers
  inexistants → 302 HTML → « Failed to decode downloaded font »).
- Gates : e2e/regression_editor_mount + e2e/regression_partial_nav
  (7 pages x complet/1er/2e passage) + regression_logout_dnd + smoke
  = 6/6 verts sur l'image rebuildée · pytest 1094 passed · ruff OK ·
  OpenAPI 7.45.2.
2026-10-02 21:29:46 -04:00
bruno 0f86294abc merge: main → develop (sync) 2026-07-20 11:02:42 -04:00
214 changed files with 47426 additions and 3044 deletions
+26
View File
@@ -1,6 +1,10 @@
# ── Gitea ──
GITEA_URL=https://git.dracodev.net
GITEA_TOKEN=change-me
# Laissez VIDES pour activer le login local seul : depuis v7.46.0 les valeurs de
# substitution (`test-id`, `test-secret`, `change-me`) comptent comme « provider
# non configuré » — avant, /auth/login redirigeait vers Gitea avec un client_id
# invalide (authentification OAuth impossible).
GITEA_OAUTH_CLIENT_ID=
GITEA_OAUTH_CLIENT_SECRET=
GITEA_WEBHOOK_SECRET=
@@ -16,6 +20,16 @@ GITHUB_OAUTH_CLIENT_SECRET=
# Exemple : OAUTH_REDIRECT_URI=https://flowdeck.dracodev.net/auth/callback
OAUTH_REDIRECT_URI=
# ── Connecteurs OAuth (v7.56.0) — Google / Microsoft 365 ──
# Vidés = connecteur « non configuré » (le menu + affiche la raison).
# Le redirect URI à enregistrer est dérivé d'APP_BASE_URL :
# {APP_BASE_URL}/api/agent/connectors/oauth/google/callback
# {APP_BASE_URL}/api/agent/connectors/oauth/ms365/callback
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
MS_CLIENT_ID=
MS_CLIENT_SECRET=
# ── App ──
APP_SECRET_KEY=change-me-to-random
APP_HOST=0.0.0.0
@@ -82,3 +96,15 @@ APP_BASE_URL=http://localhost:8080
# SSO_ATTRIBUTE_MAPPING={"email":"email","full_name":"name","groups":"groups"}
# SSO_GROUPS_MAPPING=[{"sso_group":"FlowDeck Admins","workspace_role":"admin","workspace_id":1}]
# SSO_DEFAULT_WORKSPACE_ID=0
# ── Web tools de l'agent (v7.46.0) ──
# Skills « recherche-marche », « veille-techno », « debug-web ».
# web_search → EXA (recherche sémantique + extraits, ~10 $/mois offerts)
# fetch_url → aucune config : lecture d'une page, garde-fou SSRF actif
# search_code → GitHub public (10 req/min sans jeton, 30 avec)
# Sans EXA_API_KEY, web_search bascule sur DuckDuckGo (repli dégradé, sans
# compte mais parsé au HTML) : utilisable, moins fiable.
WEB_SEARCH_PROVIDER=exa
EXA_API_KEY=
GITHUB_TOKEN=
WEB_FETCH_MAX_CHARS=20000
+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
+2250
View File
File diff suppressed because it is too large Load Diff
+578
View File
@@ -175,6 +175,360 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
---
## v7.46.0 — Sidebar : onglets compressibles Home / Chat / Meeting / Inbox ✅ (2026-10-04)
> **Objectif** : remplacer la rangée d'icônes par une navigation par onglets
> (Home, Chat, Meeting, Inbox dans cet ordre) où l'onglet actif est une pilule
> étiquetée, les autres sont réduits à leur icône, et chaque sélection remplace
> le contenu de la barre latérale. **COMPLETED**.
- [x] **Onglets compressibles** — `sidebar-nav-row` : 4 onglets dans l'ordre
**Home → Chat → Meeting → Inbox**, libellé visible uniquement sur l'onglet
actif (pilule), `role="tab"` + `aria-selected`, onglet mémorisé
(`fd_sidebar_tab`, valeur validée)
- [x] **Panneaux contextuels** — 4 panneaux Alpine (`sidebar-tab-panel`) :
Home (arbre + sections + libraries), Chat, Meeting, Inbox ; un seul visible
à la fois, footer commun conservé
- [x] **Onglet Chat branché sur l'historique réel** — raccourcis circulaires
d'agents + conversations groupées *Past 30 days* / *Older* ; clic →
`window.fdAgent.openConversation(id)` ouvre le panneau agent sur la
conversation
- [x] **Onglet Meeting branché sur le calendrier réel** — nouveau
`GET /api/v2/meetings/upcoming` (collections calendaires liées, propriété
date, horizon paramétrable et clampé), filtres *Upcoming* / *Today*,
actions *Transcribe a podcast* et *New AI meeting note*
- [x] **Onglet Inbox branché sur les notifications réelles** — All / Unread,
marquage lu au clic, *Mark all read*, état *Inbox Zero*, badge non lus- [x] **Verrouillé** — `tests/test_sidebar_tabs.py` (15 tests) +
`e2e/sidebar_tabs.spec.js` (3 scénarios, mesure de largeur de la pilule,
clic Home n'affiche que le libellé Home, CSS périmée en cache) ;
suite complète 1182 tests verts
## v7.46.0 — Audit de fonctionnement : 10 anomalies corrigées ✅ (2026-10-04)
> **Objectif** : passer l'instance au crible (routes, sécurité, navigation,
> schémas) et supprimer les anomalies constatées. **COMPLETED**.
- [x] **Audited** : 677 routes, 280 endpoints GET sondés anonymement, 64
templates/JS croisés avec les routes enregistrées, crawl Playwright
authentifié (12 pages), intégrité SQLite. **10 anomalies**, toutes corrigées.
- [x] **Écritures anonymes closes** — `POST/DELETE /api/workspaces`,
`PUT /api/workspaces/{id}`, `select` et `local-workspace/items` répondaient
200 **sans session** (repli `uid = … else 1`) : un anonyme pouvait renommer
ou supprimer un espace. → `_require_user_id()` + `_require_ws_owner()`
- [x] **Router `/workspace` authentifié** — `export/csv`, historique,
commentaires, sprints, dashboards et templates servaient des données réelles
en anonyme (500 inclus). → `Depends(_require_session)` sur le router, la page
de partage public bascule dans un `public_router` sans dépendance
- [x] **`password_hash` retiré** de `/auth/user` **et du cookie de session**
(signé mais non chiffré) → `app.auth.session.public_user()`
- [x] **OAuth Gitea** — placeholders `test-id`/`test-secret` traités comme
« provider non configuré » (avant : redirection Gitea avec un client_id
invalide) ; `GITEA_OAUTH_CLIENT_ID/SECRET` documentés dans `.env.example`
- [x] **Bouton Home** — pointait sur `local_workspaces[0]` (1er espace **trié
par nom**) au lieu de l'espace actif : le clic pouvait changer d'espace sans
annonce. → `local_ws_id` exposé par les 2 sidebars (`gitea_mirror_ws_id`
séparé) et prioritaire dans `base.html`
- [x] **`/api/v2/agents/conversations`** — 422 car masqué par
`/agents/{agent_id}` → `{agent_id:int}`
- [x] **Sélecteur de blocs synchronisés** — `/api/synced-blocks` (404) →
`/board/api/synced-blocks`, message d'aide corrigé
- [x] **`/workspace/favorites`** — 500 permanent (`no such column:
f.collection_id`, dérive de la migration v2.2.0) → schéma actuel + propriété
du favori
- [x] **Synced blocks** — 500 anonyme (`NoneType.get`) + absence de contrôle de
propriété sur PUT/DELETE → 401 / 403 (auteur ou admin)
- [x] **Jinja non silencieux** — `LoggingUndefined` : rendu identique, mais un
`WARNING` journalise toute variable absente du contexte (c'est ce silence
qui avait masqué le bug du bouton Home)
- [x] **Tests** — `tests/test_anomalies_v746.py` : **34 tests** (fuites,
écriture anonyme, propriété, ordre de routes, schéma, navigation, logs).
**Suite complète : 1167 tests verts**, `ruff check app/ tests/` propre
---
## v7.46.0 — Web tools de l'agent & 11 nouveaux skills ✅ (2026-10-04)
> **Objectif** : doter l'agent d'un accès au web (recherche en ligne, lecture de
> page, recherche GitHub) et élargir la galerie de skills. **COMPLETED**.
- [x] **3 nouveaux tools agent** — `app/services/tool_registry.py` (25 tools, +3) :
- `web_search` — recherche web sourcée (titre + URL + extrait), provider piloté
par `WEB_SEARCH_PROVIDER`
- `fetch_url` — télécharge une page et la rend en Markdown (titres, listes,
code, tableaux) pour que l'IA puisse la citer
- `search_code` — GitHub `repositories | code | issues`, sans PAT (10 req/min)
ou avec `GITHUB_TOKEN` (30 req/min)
- [x] **Service réseau** — `app/services/web_search.py` (nouveau) : Exa (recherche
sémantique + extraits) avec repli DuckDuckGo sans compte, client partagé A42,
**aucune dépendance ajoutée**, couche `transport` injectable pour les tests
- [x] **Garde-fou SSRF réutilisé** — `fetch_url` s'appuie sur
`importers.url_fetch._validate_url` / `_is_public_host` : schémas http(s)
uniquement, résolution DNS puis refus des IP loopback / privées / lien-local
/ réservées, **re-validation à chaque saut de redirection** (une URL publique
ne peut pas rediriger vers `169.254.169.254`)
- [x] **Config** — `WEB_SEARCH_PROVIDER`, `EXA_API_KEY`, `GITHUB_TOKEN`,
`WEB_FETCH_MAX_CHARS` (`app/config.py` + `.env.example`)
- [x] **Galerie : 6 → 17 presets** — `app/services/skill_gallery.py` :
- **vague 1 (locaux)** : veille-documentaire, chasse-decisions, onboarding-nouveau,
audit-base, sprint-review
- **vague 2 (web)** : recherche-marche, veille-techno, debug-web — chaque
affirmation doit porter sa source `[titre — URL]`, sinon « non vérifié »
- **vague 3 (pouvoir)** : triage-incident, nettoyage-base (aperçu avant toute
écriture), briefing-quotidien
- [x] **UI** — libellés d'action du journal agent pour les 3 tools
(`Recherche web effectuée`, `Page web lue`, `Recherche GitHub effectuée`)
- [x] **Tests** — `tests/test_agent_web_tools.py` : **29 tests**, réseau **et DNS**
jamais touchés (`httpx.MockTransport` + résolution neutralisée) : registre +
schémas + périmètre, cohérence galerie/registre, export→import des presets
web, provider + repli + filtrage des URLs anti-bot, markdown + troncature +
**6 vecteurs SSRF refusés** (localhost, 127.0.0.1, metadata cloud, IPv6
loopback, `file:`, `ftp:`) + redirection vers metadata cloud + redirection
publique suivie, GitHub repos/issues/quota. **Suite complète : 1128 tests verts.**
---
## 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é :
>
> ```
> + ├─ Ajouter des fichiers ou répertoires … → picker actuel + parcours de l'arborescence
> ├─ Compétences-skills … … … … … … … … → Deep research, Skill-creator, … + Gérer + Parcourir
> ├─ Connecteurs … … … … … … … … … … … → Parcourir + Ajouter un connecteur personnalisé
> ├─ Design System – Canevas … … … … … → galerie de canevas (templates de page)
> ├─ Add plugins … … … … … … … … … … … → catalogue de modules on/off
> └─ Mémoire (on/off) … … … … … … … … → mémoire persistante de l'agent
> ```
>
> **Doc de design** : [`docs/V74_Agent_Plus_Menu.md`](docs/V74_Agent_Plus_Menu.md)
> — maquette, découpage front/back, modèle de données, endpoints, décisions, pièges.
**État des lieux lu dans le code le 2026-10-06** (constat **avant** la phase 1) :
- Le **+** = `toggleAddPicker()` (`static/js/agent_panel_2.js:1074`) → menu **plat** de
mentions (`GET /api/agent/mentions`, `app/routers/agent.py:596`) : documents,
collections, pages. **Pas de dossiers, pas de sous-menu, pas de section.**
- **Compétences** : 11 skills builtin (`FD_SKILLS`, `static/js/agent_panel_2.js:11`) +
table `agent_skills` (CRUD `/api/agent/skills`) + galerie de 17 presets — mais
**aucune UI « Gérer » ni « Parcourir »** : tout passe par la palette `/`.
- **Canevas** : `GET /api/page-templates` (built-ins `app/services/block_templates.py` +
personnels) et `POST /api/page-templates/{id}/use` existent déjà, **jamais appelés
depuis le panneau agent**.
- **Connecteurs / Plugins / Mémoire** : **inexistants** — 0 hit `connector|plugin|memory`
dans `app/` hors « in-memory ».
**Ordre** : phases 1 → 8, chacune indépendante et livrable (version + tests verts +
CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j.
### Phase 1 — v7.51.0 — Menu hub + fichiers/répertoires · effort S–M ✅ (livrée 2026-10-06)
- [x] **Structure du menu** — sections + sous-menus, navigation clavier (↑/↓/Entrée/Échap),
styles réutilisés depuis `.fd-ap-mention-menu` ; les sections pas encore livrées
s'affichent « bientôt » et désactivées (le menu ne promet rien que le code ne fait)
→ `FD_PLUS_MENU` / `FD_PLUS_FILES` (`static/js/agent_panel_2.js`), markup
`.fd-ap-plus-menu` (`agent_panel.html`), Échap remonte d'un niveau, la frappe
referme le menu
- [x] **Fichiers & répertoires** — picker actuel (recherche) **+ mode « Parcourir… »** :
arborescence **un niveau par appel via `GET /api/nav/menu?parent_id=`**
(contrat : dossier = `icon === 'folder'` — plus simple que le tree récursif,
déjà testé), fil d'Ariane cliquable ; un répertoire s'épingle comme un fichier
(ligne « 📌 Épingler le dossier »)
- [x] **Jeton `folder:<id>`** résolu par `ContextBuilder._mentions_context` →
`_single_folder()` : titre du dossier + documents directs, budget ~12k caractères
(marqueur « tronqué ») ; enfants directs seulement (récursion si besoin réel)
- [x] **Tests** — `tests/test_v751_plus_menu.py` : **7 tests** (résolution, budget,
id inconnu, contrat `/api/nav/menu`, rendu du menu, 6 sections / 5 désactivées
à la livraison de la phase 1, jeton `folder:` front+back) ; **suite complète
1273 verts** à la livraison, `ruff` 0, `eslint` 0 erreur
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.51.0**, CHANGELOG, ce ROADMAP
### Phase 2 — v7.52.0 — Compétences : « Gérer » + « Parcourir » · effort M ✅ (livrée 2026-10-06)
- [x] **Sous-menu Compétences** — 11 builtin + skills enregistrés listés, le clic
épingle le skill au contexte (même chemin que la palette `/`, comportement
conservé)
- [x] **Libellés affichés** — alias « Deep research » (`/research`) et
« Skill-creator » (`/create-new-skill`) via `FD_SKILL_ALIAS`, slugs intacts
- [x] **« Gérer les compétences »** — **section du menu + formulaire intégré**
(nom, description, prompt) plutôt que modale : CRUD `POST`/`PATCH`/`DELETE`
sur `/api/agent/skills` — **`PATCH /api/agent/skills/{id}` créé** (champs
présents seuls, whitelist de colonnes, 400/401/404/409) + **export JSON**
(`/skills/{id}/export` → téléchargement) et **import fichier**
(`/skills/import`, `overwrite:true` → pas de doublon) ; suppression = nettoyage
du chip épinglé
- [x] **« Parcourir les compétences »** — section galerie sur
`GET /api/agent/skills/gallery`, **filtre** (nom/description/slug),
installation en 1 clic (réutilise `installGallerySkill`)
- [x] **En-tête ← + titre** dans le menu (navigation à 3 niveaux)
- [x] **Fix** — épingler un skill enregistré n'efface plus le message tapé
- [x] **Tests** — `tests/test_v752_skills_menu.py` : **7 tests** (PATCH complet +
partiel, 400/404, 401 sans session, import sans doublon, export→import,
câblage sections/alias, markup) ; `test_v751` adapté (4 sections « bientôt ») ;
**suite complète 1280 verts**, `ruff` 0, `eslint` 0 problème
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.52.0**, OpenAPI régénéré
(515 chemins), CHANGELOG, ce ROADMAP
### Phase 3 — v7.53.0 — Design System – Canevas · effort M ✅ (livrée 2026-10-06)
- [x] **Sous-menu Canevas** — `GET /board/api/page-templates` (built-ins **+
personnels**) ; **piège confirmé** : le router est sous `prefix="/board"` →
l'URL réelle est `/board/api/page-templates`, pas `/api/page-templates`
(404 silencieux corrigé dans le front)
- [x] **Deux actions (3 en réalité)** — page de détail par canevas :
« Créer une page à partir du canevas » (`POST …/0/use` avec `{key}` **dans le
body** — la clé en query est ignorée par la route), « Insérer dans le document
ouvert » (`GET …/blocks` **nouvelle route** → `ensureBlockIds()` global →
concat dans `E.blocks` + `autoSave/render`, désactivé sans document ouvert)
et « Enregistrer le document ouvert comme canevas » (`POST` + `page_id`)
- [x] **Canevas « design system »** — preset `design_system` dans
`block_templates.py` (callout tokens, TOC, toggles composants, grille,
checklist revue UI) ; `design-tokens.css` référencé dans le callout
- [x] **Sauver le doc ouvert comme canevas** — `POST /board/api/page-templates`
(existante) branchée depuis le menu, nom via `prompt()` natif
- [x] **Tests** — `tests/test_v753_canvases.py` : **5 tests** (preset listé,
route blocks intégré/personnel + 404, création de page vérifiée en base avec
ids, câblage 3 « bientôt » + garde-fou `key`-dans-le-body) ; `test_v751`
adapté ; **suite complète 1285 verts**, `ruff` 0, `eslint` 0 problème
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.53.0**, OpenAPI régénéré
(516 chemins), CHANGELOG, ce ROADMAP
### Phase 4 — v7.54.0 — Mémoire de l'agent · effort M ✅ (livrée 2026-10-06)
- [x] **Table `agent_memory`** — **une ligne résumé par conversation**
(`conversation_id` PK, upsert) + colonne `agent_conversations.memory_enabled`
(migration **31**, défaut `1`) ; la variante « mémoire d'espace » prévue au
design est **remise** (YAGNI : rien ne l'écrit — `ponytail:` noté dans le service)
- [x] **Service `app/services/agent_memory.py`** — **extraction déterministe**
(pas d'LLM) : 1 note par échange (objectif 180 car. → réponse 700 car.),
20 notes max, écrite en fin de run via `agent_memory.remember()` ; toute
erreur est journalisée, **un run ne peut pas échouer à cause de la mémoire**
- [x] **Injection** — `AgentEngine.run()` appelle `agent_memory.context_for()` après
`ContextBuilder.build()` : bloc « ## Mémoire de la conversation », **budget
4 000 caractères** (troncature par la gauche + marqueur) ; OFF = ni lecture ni
écriture → contexte strict du run courant
- [x] **Toggle « Mémoire »** dans le menu + — entrée racine à l'état réel
(🧠/💭 + libellé), `PATCH /api/agent/conversations/{id}` (`memory_enabled`
ajouté au whitelist), état lu à l'ouverture et à la création, défaut
`AGENT_MEMORY_DEFAULT` (config + réponse de création)
- [x] **Tests** — `tests/test_v754_memory.py` : **8 tests** dont **2 runs réels du
moteur** (FakeLLM capture le prompt : mémoire présente ON / absente OFF),
budget et nombre de notes bornés, toggle relu en base ; `test_v751` +
`test_v753` adaptés (2 sections « bientôt ») ; **suite complète 1293 verts**,
`ruff` 0, `eslint` 0 problème
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.54.0**, OpenAPI régénéré
(516 chemins), CHANGELOG, ce ROADMAP
### Phase 5 — v7.55.0 — Connecteurs : socle · effort L ✅ (livrée 2026-10-06)
- [x] **Table `agent_connectors`** (migration **32**) — colonnes **plates**
(`name`, `url`, `secret_encrypted`, `enabled`, `status`, `detail`) ; le
`config_json` prévu est **remis** (YAGNI : rien ne le remplissait — les scopes
OAuth des phases 6-7 ajouteront le leur)
- [x] **`app/services/connectors.py`** — **1 fichier au lieu du package
`connectors/`** promis (3 probes + 1 fetch ne justifient pas une arborescence) ;
3 natifs `gitea`/`github`/`web` servis à la volée, statut **sans réseau**
(`native_state()`) ; `probe()` et `connector_fetch()` passent par `_get()`
(gardé SSRF + re-vérification après redirection, timeout 10 s, borne 20 000 car.)
- [x] **Menu « Connecteurs »** — « Parcourir » = catalogue avec badge
(✓ ok / ✗ error / ⚠ missing / ? inconnu) + fiche (Tester, Activer/Désactiver,
Supprimer — masqués pour les natifs) ; « Ajouter un connecteur personnalisé » =
formulaire nom + URL + clé (`type="password"`)
- [x] **Tool agent** — **un seul `connector_fetch`** (id/nom/kind + `path` +
`query`) au lieu d'un outil par connecteur : même capacité, moins de bruit de
schéma — l'outil dispatche vers Gitea/GitHub/web/personnalisé. Connecteur
désactivé ou inconnu = erreur outil, jamais une exception de run
- [x] **Sécurité** — `_validate_url` (SSRF) à la création **et** à chaque appel,
clé **chiffrée Fernet** et jamais renvoyée (`has_secret` seulement), 401 sans
session sur les 5 routes, CSRF global
- [x] **Tests** — `tests/test_v755_connectors.py` : **13 tests** (3 natifs, clé
chiffrée roundtrip, **5 URLs refusées** : localhost / 127.0.0.1 / metadata
cloud / ftp: / file:, toggle persisté, 401, probe OK + probe erreur persistée,
outil complet, câblage menu) ; 3 autres fichiers de phase adaptés (1 section
« bientôt » restante) ; **suite complète 1306 verts**, `ruff` 0, `eslint` 0
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.55.0**, OpenAPI régénéré
(519 chemins), CHANGELOG, ce ROADMAP
### Phase 6 — v7.56.0 — Connecteurs : Google + Microsoft 365 · effort L ✅ (livrée 2026-10-06)
- [x] **OAuth2 PKCE Google** — Drive / Gmail / Calendar **en lecture seule** ;
connexion (`begin()` → URL + state + `code_verifier` S256), échange du code,
**refresh automatique** (60 s de marge, `refresh_token` conservé si absent de
la réponse), déconnexion — table `connector_tokens` (migration **33**,
PK `(kind, user_id)`), tokens chiffrés Fernet **réutilisant**
`calendar_sync._encrypt_tokens` (zéro dépendance ajoutée)
- [x] **OAuth2 Microsoft (Graph)** — `Files.Read` / `Mail.Read` / `Calendars.Read`
+ `offline_access`, même code (2 providers décrits par **1 dict**)
- [x] **Écran connecteur** — **pas de page dédiée** : l'état vit dans la fiche du
menu + (badge « connecté · N scope(s) » / « non connecté »), boutons
**Se connecter / Déconnecter**, toast au retour du flux (`?oauth=connected` /
`?oauth_error=` nettoyés par `history.replaceState`)
- [x] **Tests** — `tests/test_v756_oauth_connectors.py` : **13 tests**, **0 appel
réseau réel** (`_post_form` / `_api_get` monkeypatchés) — URL d'autorisation
(PKCE, scopes, redirect URI, cookies), 400 non configuré, callback → tokens
chiffrés + redirection, **state forgé refusé sans échange**, callback sans
session, refresh, `api_get` (Bearer, URL absolue refusée), déconnexion,
catalogue, outil LLM, 401/404, câblage menu ; `test_v755` adapté (5 natifs) ;
**suite complète 1319 verts**, `ruff` 0, `eslint` 0
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.56.0**, `.env.example`
(GOOGLE_/MS_ CLIENT_ID/SECRET + redirect URI), OpenAPI régénéré (523 chemins),
CHANGELOG, ce ROADMAP
### Phase 7 — v7.57.0 — Connecteurs : Discord, Telegram, Teams, MCP · effort L ✅
- [x] **Presets Discord / Telegram** — champ **Type** dans le formulaire
« Ajouter un connecteur » : `kind` + `auth` (`bearer`/`bot`/`none`) ajoutés à
`agent_connectors` (migration **34**) ; Discord = en-tête d'autorisation avec
le jeton préfixé, Telegram = jeton **dans l'URL** via le substitut `{secret}`
(remplacé à l'appel, **jamais stocké en clair** — colonne url = `…/bot{secret}`)
- [x] **Teams** — scope `ChannelMessage.Read.All` ajouté aux Graph scopes M365
(consentement admin)
- [x] **MCP complet** — `app/services/mcp_client.py` : `initialize` →
`notifications/initialized` → `tools/list` → `tools/call` (JSON-RPC 2.0,
réponse JSON **ou** 1er `data:` d'un `text/event-stream`), garde SSRF,
outils **cachés** en base (`tools_json`, maj par le bouton « Tester »)
- [x] **Outils dynamiques au registre** — `ToolRegistry._all()` merge le cache
MCP à chaque run : nom `mcp_<serveur>_<outil>` + `inputSchema` exposés au
LLM, `execute()` dispatche sur `tools/call` ; serveur désactivé → outil
**absent du schéma** ; erreur RPC → `ToolResult(error)` (jamais de run perdu)
- [x] **Tests** — `tests/test_v757_mcp_discord.py` : **12 tests**, 0 appel
réseau (`_rpc` / `_get` monkeypatchés) — presets, `{secret}` absent de la
base, kind/auth invalides → 400, handshake + séquence d'appels, cache
`tools_json`, dispatch LLM, serveur désactivé, erreur handshake → probe
error, parse SSE/erreurs JSON-RPC, slug, scopes Teams, câblage menu
- [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 ✅
- [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 à 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.*
---
## 🔜 Prochaines versions
### v4.0.1 — Onboarding & Polish ✅ (2026-07-18)
@@ -1003,6 +1357,12 @@ Détails livrés :
- [x] **Version** — 7.0.0 (`VERSION` + `app/main.py`) · `ruff check` OK
- [x] **Éditeur visuel** — **pipeline steps** dans Settings → Automations (v7.45.0) : cartes ordonnées (trigger/condition/delay/action) avec **config typée par type** (8 actions : webhook/set_property/create_page/notify/slack/email/forge_issue/agent_trigger + ops condition + datalist événements), ajout/édition/suppression/**↑↓** via l'API `/steps` + bouton **✨ Convertir le JSON en pipeline** (legacy → steps ordonnés). **Bonus trouvé par le gate** : **51 expressions multi-instructions** (`a=1; b()` — `;` = SEULE expression par directive interdit sous le parseur CSP, non couvert par le scan `tokens`) → converties en méthodes dans **11 fichiers** (nav settings ×8, menu section base ×7, partages/éditeur ×13, breadcrumb ×5, lib/local ×6, board ×3, ctx/agent/workspaces/card/gitea ×6). Nouveau scanner `scan_semi` ajouté au lot.
- [x] **Fix SW + CSRF uploads** (v7.45.1) — logout ne sert plus la page « hors ligne » (le fetch event de navigation est en `redirect:'manual'` → 302 lue `opaqueredirect` → rejet ; rejoué en `redirect:'follow'` + **302 synthétique vers l'URL finale**, Chromium refusant les responses `redirected` servies à une navigation) et drag & drop de fichiers/dossiers réparé (`_doUpload` + `toggleFavorite` sans `X-CSRF-Token` = oublis A19, derniers appels mutants du front). `timeoutFetch` annule enfin ses requêtes. Gate `e2e/regression_logout_dnd.spec.js` (2 tests verts).
- [x] **Navigation partielle Alpine** (v7.45.2) — les swaps fdLoad initialisaient `.main-wrapper` avant l'exécution des scripts de page (courses : « Undefined variable » sur l'éditeur, `reading 'has'` sur `$store.fdCtx`, `SyntaxError: 'LW' already declared` à la 2ᵉ visite). Fix maison dans `app.js` : `x-ignore` sur la zone swapée (nœud remplacé uniquement) → démontage unique à tous `<script src>` chargés via `Alpine.initTree` idempotent ; garde de fichier sur 5 scripts à `const` de haut niveau ; `if (window.Alpine)` sur 8 scripts + 7 templates inline ; polices Inter orphelines purgées d'`app.css`. Gates : `regression_editor_mount` + `regression_partial_nav` (7 pages × complet/partiel/2ᵉ = 0 erreur).
- [x] **Flashs de navigation** (v7.45.3) — pendant la fenêtre `x-ignore` (swap fdLoad → `Alpine.initTree`), la zone `.main-wrapper` était peinte **non montée** : tous ses `[x-show]` visibles à leur valeur brute (menus/panneaux/états loading+empty+table superposés) puis masqués d'un coup au montage = la rafale de fenêtres qui flashent à chaque changement de page/section. Fix 6 lignes dans `app.js` : `opacity:0` + `pointer-events:none` posés dans `htmx:afterSwap` (même task que le swap → aucun paint intermédiaire), retirés **après** `initTree` sur les 3 chemins de démontage. Mesure avant : 19 éléments bruts/87 ms sur /library (CLS 0.149), 80 éléments/732 ms sur /settings — après : 0 frame de contenu brut. Gate maison `e2e/probe_nav_perf.spec.js` (marques htmx + fenêtre x-ignore + échantillon par frame + layout-shift).
- Suivis restants (mesurés, non traités) : CLS résiduel **0.058** sur /library (états `lib-loading`/`lib-empty` → `lib-table` : le remplacement du spinner par les lignes décale le contenu — nécessiterait un squelette de lignes, pas un réglage CSS) · fenêtre `x-ignore` de **~150–300 ms** sur /settings (coût des scripts de page avant `initTree`, pas de gain sans changer l'ordre de montage) · la **topbar est dans `.main-wrapper`** (base.html) → re-swappée et re-montée à chaque navigation (avec le masquage elle disparaît/revient au lieu de flasher — la sortir demanderait de la swapper séparément).
- Suite v7.45.4 : `x-cloak` posé sur `.app-layout` (racine Alpine) → les **chargements complets** (F5, SW, hors ligne) ne peignent plus la sidebar brute ni la bande rouge hors ligne — le masquage fdLoad ne couvrait que les swaps partiels. `fd-navigating` (classe sans règle CSS) supprimée. Probe étendu à 4 scénarios (nav / clic Home réel / Home hors ligne / plein chargement) avec assertions. **Symptôme Home non rejouable** en navigateur frais (0 frame brut, 0 erreur console) → à confirmer sur le navigateur signalé.
- [x] **Poubelle** (v7.45.5) — page /trash alignée sur ce que fait ce type de section : **sélection multiple** (select all porté au filtre courant + barre Restore/Delete/Clear), **Empty Trash** (`POST /board/api/trash/empty`, purge en masse en 1 requête), **tri** (récents/anciens/nom) et **filtre emplacement** réels (les deux boutons décoratifs « Last edited by ▾ » / « In ▾ » qui ne faisaient rien sont remplacés), **date de suppression + jours restants** par élément (rétention 30 j), compteur de résultats, états vides distincts, toasts sur chaque action. 4 bugs trouvés en route : (1) **Restore/Delete ne marchaient jamais** (`getCsrf()` = chaîne mais code lu `csrf?.[1]` → 403 CSRF silencieux), (2) **« Move to Trash » de l'éditeur = 404** (route `/board/api/pages/{id}/trash` inexistante, `.then()` naviguait quand même → la page n'allait jamais à la poubelle), (3) **page restaurée invisible** en Library/Recents/Private (`parent_section='Trash'` réécrit au delete, jamais remis → `deleted_at` = source de vérité unique + réparation au boot), (4) **routes trash sans auth** (401 ajouté, vérifié anonyme). Gates : `tests/test_trash_api.py` (5) + `e2e/trash_ui.spec.js` (1) + suite 1099 passed.
- [x] **Fenêtre de confirmation thématisée** (v7.45.6) — les 3 confirmations destructives de /trash (suppression unitaire, groupée, **Empty Trash**) ne passent plus par le `confirm()` du navigateur : fenêtre FlowDeck réutilisant les classes `flowdeck-modal*` déjà présentes dans `base.html` (variables du thème, overlay cliquable + Échap pour annuler, Cancel + bouton de confirmation coloré porteur du libellé exact, titre/message avec le nom de la page ou le nombre de pages, `role="alertdialog"`). Composant dans `trashData()` : `askConfirm` / `cancelConfirm` / `acceptConfirm` (mutations de propriétés individuelles, pitfall réactivité Alpine). Gate e2e : ouverture + titre + message + **annulation (page toujours là)** + confirmation (contenu réellement effacé) + purge, et le test échoue si un dialog natif revient (`NATIVE_DIALOG`).
### v7.1.0 — Calendar sync + Meeting Notes ✅ (2026-09-28)
> **Objectif** : calendrier bidirectionnel + transcription → agents (cf. Notion 07/2026 : Meeting Notes trigger Custom Agents).
@@ -1048,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.45.1
7.71.5
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.45.1 (fix logout « hors ligne » SW = navigation en `redirect:'manual'` → 302 rejouée + 302 synthétique, **fix drag & drop upload/favoris = `X-CSRF-Token` manquant** (oublis A19)) | **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
+13 -2
View File
@@ -11,6 +11,17 @@ from app.services.http_client import shared_client
logger = logging.getLogger(__name__)
# Valeurs de substitution livrées dans app/config.py : elles sont NON VIDES,
# donc un simple `bool(client_id and client_secret)` les considérait comme
# configurées et /auth/login redirigeait vers Gitea avec client_id=test-id
# (échec d'authentification garanti). Un placeholder == provider non configuré.
_PLACEHOLDER_CREDS = {"test-id", "test-secret", "change-me", "changeme", "your-client-id"}
def _real(value: str | None) -> bool:
"""True when ``value`` is a real credential (not empty, not a placeholder)."""
return bool(value) and value.strip().lower() not in _PLACEHOLDER_CREDS
class OAuthProvider(ABC):
"""Abstract OAuth2 provider interface."""
@@ -52,7 +63,7 @@ class GiteaProvider(OAuthProvider):
self.redirect_uri = redirect_uri
def is_enabled(self) -> bool:
return bool(self.client_id and self.client_secret)
return _real(self.client_id) and _real(self.client_secret)
def get_authorize_url(self, state: str, redirect_uri: str | None = None, force_login: bool = False) -> str:
params = {
@@ -144,7 +155,7 @@ class GitHubProvider(OAuthProvider):
self.api_url = "https://api.github.com"
def is_enabled(self) -> bool:
return bool(self.client_id and self.client_secret)
return _real(self.client_id) and _real(self.client_secret)
def get_authorize_url(self, state: str, redirect_uri: str | None = None, force_login: bool = False) -> str:
return (
+15 -2
View File
@@ -13,6 +13,19 @@ logger = logging.getLogger(__name__)
_serializer = URLSafeTimedSerializer(settings.app_secret_key)
# Colonnes de la table ``users`` qui ne doivent JAMAIS quitter le serveur :
# le cookie de session est signé (HMAC) mais pas chiffré — son payload est
# lisible en base64 par quiconque détient le cookie, et ``/auth/user`` le
# renvoyait tel quel au client.
_SECRET_USER_FIELDS = ("password_hash",)
def public_user(user_data: dict | None) -> dict | None:
"""Return a copy of ``user_data`` stripped of credential material."""
if user_data is None:
return None
return {k: v for k, v in user_data.items() if k not in _SECRET_USER_FIELDS}
class SessionManager:
"""Manages user sessions via signed cookies (v5.2.0: revocable).
@@ -30,7 +43,7 @@ class SessionManager:
``user_sessions`` table (ip + user agent) and becomes revocable.
"""
payload = {
"user": user_data,
"user": public_user(user_data),
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
}
user_id = user_data.get("id")
@@ -93,7 +106,7 @@ class SessionManager:
"""Re-sign a cookie keeping its session id (used after profile edits)."""
sid = SessionManager.session_id(cookie) if cookie else None
payload = {
"user": user_data,
"user": public_user(user_data),
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
}
user_id = user_data.get("id")
+29
View File
@@ -126,6 +126,35 @@ class Settings(BaseSettings):
agent_max_tokens_budget: int = 500000
agent_run_timeout_seconds: int = 300
# ── Connecteurs OAuth (v7.56.0) — Google / Microsoft 365 ──
# Client OAuth2 à créer dans les consoles : Google Cloud (OAuth client web)
# et Entra ID (application web). Le redirect URI est dérivé d'APP_BASE_URL.
google_client_id: str = ""
google_client_secret: str = ""
ms_client_id: str = ""
ms_client_secret: str = ""
# ── Mémoire de l'agent (v7.54.0) ──
# État initial du toggle « Mémoire » à la création d'une conversation ;
# le basculement se fait ensuite par PATCH /api/agent/conversations/{id}.
agent_memory_default: bool = True
# ── Web tools de l'agent (web_search / fetch_url / search_code) ──
# `web_search_provider` : « exa » (recherche sémantique + snippets, clé
# requise) ou « duckduckgo » (repli sans compte, parsing HTML — dégradé).
web_search_provider: str = "exa"
exa_api_key: str = ""
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:":
+21
View File
@@ -348,6 +348,16 @@ def init_db():
conn.execute("ALTER TABLE pages ADD COLUMN deleted_at TEXT")
except sqlite3.OperationalError:
pass
# v7.45.5 : réparation des pages restaurées marquées 'Trash' — l'ancien
# soft-delete (éditeur) réécrivait parent_section='Trash' et la
# restauration ne le remettait pas → page invisible en Library/Recents/
# Private. Idempotent (WHERE restrictif), rejoué à chaque boot.
# ponytail: un dossier restauré repasse en 'Private' (l'état d'origine
# n'est pas stocké) → icône/dossier à remettre à 'Workspace' si besoin.
conn.execute(
"UPDATE pages SET parent_section='Private' "
"WHERE parent_section='Trash' AND deleted_at IS NULL"
)
try:
conn.execute("ALTER TABLE pages ADD COLUMN share_mode TEXT DEFAULT 'private'")
except sqlite3.OperationalError:
@@ -823,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
+21 -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.45.1",
version="7.71.5",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
@@ -224,6 +230,8 @@ app.include_router(webhooks.router)
app.include_router(collections.router)
app.include_router(my_tasks.router)
app.include_router(workspace.router)
# Partage public :-exempt de la dépendance d'authentification du router.
app.include_router(workspace.public_router)
app.include_router(library.router)
app.include_router(admin.router)
app.include_router(gitea_router)
@@ -233,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)
@@ -245,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)
@@ -260,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
+472
View File
@@ -1553,3 +1553,475 @@ def _migration_sso_enterprise_auth(conn: sqlite3.Connection) -> None:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_sso_requests_created ON sso_requests(created_at)"
)
@register(30, "v7.47.0: My Tasks — mapping des propriétés de base de tâches")
def _migration_my_tasks_mapping(conn: sqlite3.Connection) -> None:
"""My Tasks façon Notion : une base ne diffuse ses tâches qu'après
conversion explicite, et les trois propriétés requises sont **nommées**
(pas devinées d'après leur type).
``collections.is_task`` existait déjà mais ne mémorisait rien : My Tasks
devait deviner « la colonne personne = Assigné à » et n'avait aucun moyen
de distinguer deux bases connectées. Trois colonnes nullable REFERENCES
fixent le mapping ; suppression de la propriété → `SET NULL`, et la base
redevient « non configurée » au lieu de pointer sur du vide.
"""
cols = columns(conn, "collections")
for name, target in (
("task_assignee_prop", "collection_properties"),
("task_status_prop", "collection_properties"),
("task_due_prop", "collection_properties"),
):
if name in cols:
continue
conn.execute(
f"ALTER TABLE collections ADD COLUMN {name} INTEGER "
f"REFERENCES {target}(id) ON DELETE SET NULL"
)
# Index de lecture : « les bases de tâches de cet utilisateur ».
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_collections_task "
"ON collections(is_task, workspace_id)"
)
@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
serveurs MCP (kind='mcp', cache d'outils) réutilisent la même table."""
cols = columns(conn, "agent_connectors")
if "kind" not in cols:
conn.execute("ALTER TABLE agent_connectors ADD COLUMN kind TEXT NOT NULL DEFAULT 'custom'")
if "auth" not in cols:
conn.execute("ALTER TABLE agent_connectors ADD COLUMN auth TEXT NOT NULL DEFAULT 'bearer'")
if "tools_json" not in cols:
conn.execute("ALTER TABLE agent_connectors ADD COLUMN tools_json TEXT NOT NULL DEFAULT '[]'")
@register(33, "v7.56.0: tokens OAuth des connecteurs (Google / M365)")
def _migration_connector_tokens(conn: sqlite3.Connection) -> None:
"""Tokens OAuth par (kind, utilisateur), chiffrés Fernet côté service."""
conn.execute(
"""CREATE TABLE IF NOT EXISTS connector_tokens (
kind TEXT NOT NULL,
user_id INTEGER NOT NULL,
tokens_enc TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (kind, user_id)
)"""
)
@register(32, "v7.55.0: connecteurs de l'agent (socle)")
def _migration_agent_connectors(conn: sqlite3.Connection) -> None:
"""Catalogue de connecteurs : 3 natifs servis à la volée + personnels ici.
Colonne ``url`` plate (pas de ``config_json``) : les scopes/OAuth des
phases 6-7 ajouteront leur propre colonne le moment venu.
"""
conn.execute(
"""CREATE TABLE IF NOT EXISTS agent_connectors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL,
secret_encrypted TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'unknown',
detail TEXT NOT NULL DEFAULT '',
created_by INTEGER
)"""
)
@register(31, "v7.54.0: agent memory (mémoire de l'agent)")
def _migration_agent_memory(conn: sqlite3.Connection) -> None:
"""Toggle par conversation + table des résumés mémorisés.
Une ligne résumé par conversation (upsert) — voir `app/services/agent_memory.py`.
"""
if "memory_enabled" not in columns(conn, "agent_conversations"):
conn.execute(
"ALTER TABLE agent_conversations "
"ADD COLUMN memory_enabled INTEGER NOT NULL DEFAULT 1"
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS agent_memory (
conversation_id INTEGER PRIMARY KEY
REFERENCES agent_conversations(id) ON DELETE CASCADE,
kind TEXT NOT NULL DEFAULT 'summary',
content TEXT NOT NULL DEFAULT '',
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)"
)
+7
View File
@@ -104,6 +104,10 @@ def update_user(user_id: int, request: Request, _admin=Depends(admin_required),
def delete_user(user_id: int, _admin=Depends(admin_required)):
"""Delete a user and cascade their data."""
from app.db import get_conn
from app.services.collection_lifecycle import (
delete_collections,
workspace_collection_ids,
)
with get_conn() as conn:
user = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
if not user:
@@ -121,6 +125,9 @@ def delete_user(user_id: int, _admin=Depends(admin_required)):
# Delete workspaces owned by this user
ws_rows = conn.execute("SELECT id FROM workspaces WHERE owner_id=?", (user_id,)).fetchall()
for ws in ws_rows:
# Les bases du workspace tombent avec lui (sinon : collections
# orphelines listées par /db et My Tasks).
delete_collections(conn, workspace_collection_ids(conn, ws["id"]))
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws["id"],))
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws["id"],))
conn.execute("DELETE FROM workspaces WHERE owner_id=?", (user_id,))
+237 -6
View File
@@ -7,15 +7,17 @@ from __future__ import annotations
import asyncio
import json
import logging
import secrets
from datetime import UTC
from urllib.parse import urlparse
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse
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 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 (
@@ -199,15 +201,17 @@ def create_conversation(request: Request, body: dict = Body(default={})):
ws = _workspace_id(request)
with get_conn() as conn:
agent = _default_agent(conn, user_id)
mem_default = 1 if settings.agent_memory_default else 0
cur = conn.execute(
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json, provider, model)
VALUES (?,?,?,?,?,?)""",
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json, provider, model, memory_enabled)
VALUES (?,?,?,?,?,?,?)""",
(agent["id"], user_id, body.get("title", "New conversation"),
json.dumps({"workspace_id": ws}),
body.get("provider", ""), body.get("model", "")),
body.get("provider", ""), body.get("model", ""), mem_default),
)
conn.commit()
return {"id": cur.lastrowid, "title": body.get("title", "New conversation"), "status": "created"}
return {"id": cur.lastrowid, "title": body.get("title", "New conversation"),
"status": "created", "memory_enabled": mem_default}
@router.get("/conversations/{conversation_id}")
@@ -249,6 +253,10 @@ def patch_conversation(request: Request, conversation_id: int, body: dict = Body
if body.get(col) is not None:
sets.append(f"{col}=?")
params.append(str(body[col]))
# v7.54.0 — toggle « Mémoire » de la conversation (0/1).
if body.get("memory_enabled") is not None:
sets.append("memory_enabled=?")
params.append(1 if body["memory_enabled"] else 0)
params.append(conversation_id)
conn.execute(f"UPDATE agent_conversations SET {', '.join(sets)} WHERE id=?", params)
conn.commit()
@@ -498,6 +506,43 @@ def create_skill(request: Request, body: dict = Body(default={})):
return {"id": cur.lastrowid, "name": name, "status": "created"}
@router.patch("/skills/{skill_id}")
def update_skill(request: Request, skill_id: int, body: dict = Body(default={})):
"""Édition d'un skill enregistré (v7.52.0 — « Gérer les compétences »).
Seuls les champs présents dans ``body`` sont mis à jour ; la liste des
colonnes est figée ici (jamais interpolée depuis la requête).
"""
_current_user_id(request)
fields: dict = {}
if "name" in body:
name = str(body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name est requis")
fields["name"] = name
if "description" in body:
fields["description"] = str(body.get("description") or "")
if "prompt_template" in body:
fields["prompt_template"] = str(body.get("prompt_template") or "")
if "allowed_tools" in body:
fields["allowed_tools_json"] = json.dumps(body.get("allowed_tools") or [])
if not fields:
raise HTTPException(status_code=400, detail="Aucun champ à mettre à jour")
cols = ", ".join(f"{k}=?" for k in fields)
with get_conn() as conn:
if not conn.execute("SELECT id FROM agent_skills WHERE id=?", (skill_id,)).fetchone():
raise HTTPException(status_code=404, detail="Skill introuvable")
try:
conn.execute(
f"UPDATE agent_skills SET {cols} WHERE id=?",
(*fields.values(), skill_id),
)
conn.commit()
except Exception as exc: # noqa: BLE001 — contrainte UNIQUE(name)
raise HTTPException(status_code=409, detail=f"Skill existe déjà: {exc}") from exc
return {"id": skill_id, "status": "updated", "fields": sorted(fields)}
@router.post("/skills/{skill_id}/apply")
def apply_skill(request: Request, skill_id: int):
"""Create a conversation pre-loaded with a skill, ready to run."""
@@ -590,6 +635,192 @@ def delete_skill(request: Request, skill_id: int):
return {"id": skill_id, "status": "deleted"}
# ── Connecteurs (v7.55.0) ──
# ponytail : catalogue partagé au même titre que les skills (`list_skills`
# n'a pas non plus de filtre par créateur) — la clé n'est jamais renvoyée.
@router.get("/connectors")
def list_connectors_route(request: Request):
"""Catalogue : natifs (statut dérivé de la config) + personnels."""
user_id = _current_user_id(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)."""
_current_user_id(request)
try:
return connectors.create_connector(
body.get("name") or "", body.get("url") or "", str(body.get("secret") or ""),
kind=str(body.get("kind") or "custom"),
auth=str(body.get("auth") or "bearer"),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.patch("/connectors/{connector_id}")
def update_connectors_route(request: Request, connector_id: int, body: dict = Body(default={})):
_current_user_id(request)
try:
row = connectors.update_connector(
connector_id,
name=body.get("name"),
enabled=body.get("enabled"),
secret=body.get("secret"),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if row is None:
raise HTTPException(status_code=404, detail="Connecteur introuvable")
return row
@router.delete("/connectors/{connector_id}")
def delete_connectors_route(request: Request, connector_id: int):
_current_user_id(request)
if not connectors.delete_connector(connector_id):
raise HTTPException(status_code=404, detail="Connecteur introuvable")
return {"id": connector_id, "status": "deleted"}
@router.post("/connectors/probe")
async def probe_connectors_route(request: Request, body: dict = Body(default={})):
"""Teste un connecteur (id, nom ou kind natif) et persiste le résultat."""
target = str(body.get("connector") or "").strip()
if not target:
raise HTTPException(status_code=400, detail="connector est requis")
user_id = _current_user_id(request)
try:
return await connectors.probe(target, user_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
# ── Connecteurs OAuth : Google / Microsoft 365 (v7.56.0) ──
# Flow : POST authorize (cookies state/verifier/next) → fournisseur →
# GET callback (échange du code, tokens chiffrés) → redirection vers la page
# d'origine. Le GET est en SAFE_METHODS : pas de CSRF (comme /auth/callback).
def _oauth_kind(kind: str) -> str:
if kind not in oauth_connectors.OAUTH_KINDS:
raise HTTPException(status_code=404, detail="Connecteur OAuth inconnu")
return kind
def _oauth_next(request: Request, kind: str, key: str, default: str = "/") -> str:
"""Chemin de retour same-origin (cookie `key` pour le callback)."""
raw = request.cookies.get(key, "") or default
path = urlparse(raw).path if raw.startswith("http") else raw
if not path.startswith("/") or path.startswith("//"):
return default
return path
@router.get("/connectors/oauth/{kind}/status")
def connector_oauth_status(request: Request, kind: str):
"""État du connecteur OAuth pour l'utilisateur courant."""
_oauth_kind(kind)
user_id = _current_user_id(request)
try:
oauth_connectors._client(kind)
configured, detail = True, ""
except ValueError as exc:
configured, detail = False, str(exc)
tok = oauth_connectors.tokens(kind, user_id)
return {
"kind": kind, "configured": configured, "configured_detail": detail,
"connected": bool(tok.get("access_token")), "scope": tok.get("scope", ""),
"expires_at": tok.get("expires_at", 0),
}
@router.post("/connectors/oauth/{kind}/authorize")
def connector_oauth_authorize(request: Request, kind: str):
"""URL d'autorisation (PKCE) + cookies d'état éphémères (10 min)."""
_oauth_kind(kind)
_current_user_id(request)
try:
flow = oauth_connectors.begin(kind)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
referer = request.headers.get("referer") or ""
next_path = urlparse(referer).path if referer else "/"
if not next_path.startswith("/") or next_path.startswith("//"):
next_path = "/"
secure = request.url.scheme == "https"
resp = JSONResponse({"url": flow["url"], "kind": kind})
for key, value in (
(f"fd_oauth_state_{kind}", flow["state"]),
(f"fd_oauth_verifier_{kind}", flow["verifier"]),
(f"fd_oauth_next_{kind}", next_path),
):
resp.set_cookie(key, value, max_age=600, httponly=True, samesite="lax", secure=secure)
return resp
@router.get("/connectors/oauth/{kind}/callback")
async def connector_oauth_callback(
request: Request, kind: str, code: str = "", state: str = "", error: str = ""
):
"""Reçoit le code du fournisseur, échange, stocke, renvoie sur la page."""
_oauth_kind(kind)
next_path = _oauth_next(request, kind, f"fd_oauth_next_{kind}")
expected = request.cookies.get(f"fd_oauth_state_{kind}", "")
verifier = request.cookies.get(f"fd_oauth_verifier_{kind}", "")
def _back(flag: str) -> RedirectResponse:
sep = "&" if "?" in next_path else "?"
resp = RedirectResponse(f"{next_path}{sep}{flag}", status_code=302)
for key in (f"fd_oauth_state_{kind}", f"fd_oauth_verifier_{kind}",
f"fd_oauth_next_{kind}"):
resp.delete_cookie(key, samesite="lax")
return resp
if error:
return _back("oauth_error=" + error[:80])
if not code or not expected or not secrets.compare_digest(expected, state):
return _back("oauth_error=state")
user = get_current_user(request)
if not user or not user.get("id"):
return _back("oauth_error=session")
try:
tokens = await oauth_connectors.complete(kind, code, verifier)
except ValueError as exc:
return _back("oauth_error=" + str(exc)[:80].replace(" ", "+"))
oauth_connectors.save(kind, int(user["id"]), tokens)
logger.info("Connector OAuth connected: %s (user #%s)", kind, user["id"])
return _back("oauth=connected")
@router.post("/connectors/oauth/{kind}/disconnect")
def connector_oauth_disconnect(request: Request, kind: str):
_oauth_kind(kind)
user_id = _current_user_id(request)
removed = oauth_connectors.clear(kind, user_id)
return {"kind": kind, "status": "disconnected" if removed else "not-connected"}
# ── Mentions (commande @ / +) & feedback (boutons 👍 / 👎) ──
+2 -1
View File
@@ -27,6 +27,7 @@ from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est ut
)
from app.services.automations import fire_event as _fire_event
from app.services.automations import run_event_sync
from app.services.collection_lifecycle import delete_collections
from ._common import _v2_rate_check
@@ -152,7 +153,7 @@ def delete_collection_v2(collection_id: int, request: Request, authorization: st
row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
if not row:
raise HTTPException(404, "Collection not found")
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
delete_collections(conn, [collection_id])
conn.commit()
audit_log(user, "collection.delete", "collection", collection_id, "", request)
return {"id": collection_id, "status": "deleted"}
+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"}
+6 -3
View File
@@ -156,7 +156,10 @@ async def create_agent_v2(request: Request, authorization: str | None = Header(d
return JSONResponse(content=data, status_code=201)
@router.get("/agents/{agent_id}")
# v7.46.0 : `{agent_id:int}` — sans le contrainte de type, la routeparametrée
# enregistree AVANT `/agents/conversations` capturait le segment « conversations »
# et renvoyait 422 (int_parsing) au lieu de la liste des conversations.
@router.get("/agents/{agent_id:int}")
def get_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
_guard(request, authorization)
with get_conn() as conn:
@@ -166,7 +169,7 @@ def get_agent_v2(agent_id: int, request: Request, authorization: str | None = He
return row_to_dict(row)
@router.put("/agents/{agent_id}")
@router.put("/agents/{agent_id:int}")
async def update_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
user = _guard(request, authorization, write=True)
body = await _json_body(request)
@@ -194,7 +197,7 @@ async def update_agent_v2(agent_id: int, request: Request, authorization: str |
return {"id": agent_id, "status": "updated"}
@router.delete("/agents/{agent_id}")
@router.delete("/agents/{agent_id:int}")
def delete_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
user = _guard(request, authorization, write=True)
with get_conn() as conn:
+2 -1
View File
@@ -475,10 +475,11 @@ def logout(request: Request):
def current_user(request: Request):
"""Return current user info as JSON."""
from app.auth.session import get_current_user as gcu
from app.auth.session import public_user
user = gcu(request)
if not user:
return {"authenticated": False}
return {"authenticated": True, "user": user}
return {"authenticated": True, "user": public_user(user)}
# ── v7.2.0 — TOTP 2FA ─────────────────────────────────────────────────────
+30 -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"]
@@ -485,6 +493,10 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
active_ws_name = row["name"]
workspace_pages = _load_workspace_pages(ws_cookie)
has_active_workspace = True
# v7.46.0 : comme pour le sidebar dashboard, exposer l'ID de
# l'espace ACTIF (le bouton Home de base.html pointait sinon
# sur ``local_workspaces[0]``, premier espace trie par nom).
local_ws_id = wsi
except (ValueError, Exception):
pass
recent = []
@@ -517,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=[],
+34
View File
@@ -226,4 +226,38 @@ def use_page_template(request: Request, template_id: int, body: dict = Body(defa
return {"status": "ok", "id": page_id, "title": title or name}
@router.get("/api/page-templates/{template_id}/blocks")
def page_template_blocks(request: Request, template_id: int, key: str = ""):
"""v7.53.0 : blocs d'un canevas, pour l'insérer dans le document ouvert.
Builtin : ``template_id=0`` + ``?key=`` (même convention que ``/use``).
Canevas personnel : son ``id``. Les blocs builtin sont sans ``id`` — le
front appelle ``ensureBlockIds()`` (déjà global côté éditeur).
"""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if template_id == 0:
from app.services.block_templates import blocks_json_for
raw = blocks_json_for(key or "empty")
if raw is None:
raise HTTPException(404, "Unknown built-in template")
else:
uid = (user or {}).get("id")
with get_conn() as conn:
row = conn.execute(
"SELECT blocks_json FROM page_global_templates "
"WHERE id=? AND (created_by IS NULL OR created_by=?)",
(template_id, uid),
).fetchone()
if not row:
raise HTTPException(404, "Template not found")
raw = row["blocks_json"]
try:
blocks = json.loads(raw)
except (json.JSONDecodeError, TypeError) as exc:
raise HTTPException(500, "Template content corrupted") from exc
if not isinstance(blocks, list):
raise HTTPException(500, "Template content corrupted")
return {"blocks": blocks}
# ── Core helpers ──
+81 -7
View File
@@ -12,6 +12,11 @@ from fastapi.responses import HTMLResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import fire_event, run_event_sync
from app.services.collection_lifecycle import (
collections_hosted_by_page,
collections_hosted_by_pages,
delete_collections,
)
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
from ._common import _sidebar_data
@@ -90,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}
@@ -125,21 +143,41 @@ def unpublish_page(request: Request, page_id: int):
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
def _trash_session(request: Request):
"""Trash : session obligatoire (les 3 routes mutaient/ lisaient toutes les
pages sans aucune vérification — le CSRF seul ne protège pas, le cookie est
lisible par JS et un attaquant fixe cookie ET en-tête)."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
raise HTTPException(401, "Authentication required")
return user
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
@router.get("/api/trash")
def list_trash(request: Request):
_trash_session(request)
with get_conn() as conn:
rows = conn.execute("SELECT id, title, workspace, parent_id, deleted_at FROM pages WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall()
return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows]
rows = conn.execute(
"SELECT id, title, workspace, parent_section, deleted_at FROM pages "
"WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC"
).fetchall()
return [
{
"id": r["id"],
"name": r["title"] or "Untitled",
"icon": "📁" if r["parent_section"] == "Workspace" else "📄",
"path": r["workspace"] or "Private",
"deleted_at": r["deleted_at"],
}
for r in rows
]
@router.post("/api/trash/{page_id}/restore")
def restore_page(request: Request, page_id: int):
_trash_session(request)
with get_conn() as conn:
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
conn.commit()
@@ -154,11 +192,47 @@ def restore_page(request: Request, page_id: int):
@router.delete("/api/trash/{page_id}")
def permanent_delete(request: Request, page_id: int):
_trash_session(request)
with get_conn() as conn:
# La page hôte d'une base disparaît définitivement → sa base et ses
# lignes aussi, sinon My Tasks (et /db) continuaient de lister des
# tâches sans document.
collections = collections_hosted_by_page(conn, page_id)
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
delete_collections(conn, collections)
conn.commit()
return {"status": "ok", "deleted": page_id}
return {"status": "ok", "deleted": page_id, "collections": len(collections)}
@router.post("/api/trash/empty")
def empty_trash(request: Request):
"""Empty Trash : purge en masse (les enfants des pages supprimées passent
en racine, comme permanent_delete — comportement historique conservé)."""
_trash_session(request)
with get_conn() as conn:
n = conn.execute(
"SELECT COUNT(*) FROM pages WHERE deleted_at IS NOT NULL"
).fetchone()[0]
# Bases portées par les pages purgées (les pages contenu de ligne
# n'hébergent aucune base et ne remontent rien).
trashed = [
r["id"]
for r in conn.execute(
"SELECT id FROM pages WHERE deleted_at IS NOT NULL"
).fetchall()
]
collections = collections_hosted_by_pages(conn, trashed)
conn.execute(
"UPDATE pages SET parent_id=NULL WHERE parent_id IN "
"(SELECT id FROM pages WHERE deleted_at IS NOT NULL)"
)
conn.execute("DELETE FROM pages WHERE deleted_at IS NOT NULL")
delete_collections(conn, collections)
conn.commit()
return {"status": "ok", "deleted": n, "collections": len(collections)}
+27 -6
View File
@@ -16,23 +16,41 @@ router = APIRouter(tags=["board"], prefix="/board")
# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════
# These routes MUST be registered before the catch-all /{owner}/{repo} below.
# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════# These routes MUST be registered before the catch-all /{owner}/{repo} below.
def _session_user(request: Request) -> dict:
"""Session obligatoire — sans garde, un appel anonyme levait
``AttributeError: 'NoneType' object has no attribute 'get'`` (HTTP 500)
au lieu d'un 401."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
return user
def _assert_can_edit_block(request: Request, sb: dict) -> None:
"""Seul l'auteur du bloc (ou un admin global) peut le modifier/supprimer."""
user = _session_user(request)
if sb.get("created_by") in (None, user.get("id")):
return
if user.get("is_admin"):
return
raise HTTPException(403, "Not the author of this synced block")
@router.get("/api/synced-blocks")
def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
"""List synced blocks for a workspace."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
user = _session_user(request)
from app.services.synced_blocks import list_synced_blocks
return {"synced_blocks": list_synced_blocks(workspace or user.get("login", ""))}
@router.post("/api/synced-blocks")
def create_synced_block_api(request: Request, body: dict = Body(...)):
"""Create a new synced block."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
user = _session_user(request)
from app.services.synced_blocks import create_synced_block
sid = create_synced_block(
workspace=body.get("workspace", ""),
@@ -61,6 +79,7 @@ async def update_synced_block_api(request: Request, sid: int):
sb = get_synced_block(sid)
if not sb:
raise HTTPException(404, "Synced block not found")
_assert_can_edit_block(request, sb)
update_synced_block(sid, body.get("title", sb["title"]), body.get("content", []))
# v6.5.0: rewrite every referencing page's stored content first (DB row
# content pages included), THEN push the realtime update so open rooms
@@ -86,6 +105,7 @@ async def delete_synced_block_api(request: Request, sid: int):
sb = get_synced_block(sid)
if not sb:
raise HTTPException(404, "Synced block not found")
_assert_can_edit_block(request, sb)
# v6.5.0: collect referencing pages BEFORE the FK cascade wipes the
# refs, rewrite their stored content (deleted state), then broadcast.
pids = page_ids_for_synced(sid)
@@ -99,8 +119,9 @@ async def delete_synced_block_api(request: Request, sid: int):
@router.get("/api/synced-blocks/{sid}")
def get_synced_block_api(sid: int):
def get_synced_block_api(request: Request, sid: int):
"""Get a synced block by id."""
_session_user(request)
from app.services.synced_blocks import get_synced_block
sb = get_synced_block(sid)
if not sb:
+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}
+9
View File
@@ -17,11 +17,13 @@ from . import ( # ordre = ordre d'enregistrement d'origine
crud,
dashboard_views,
data_api,
index_page,
linked,
meta,
pages,
properties,
structure,
task_db,
views,
)
from ._common import _validate_page_properties # noqa: F401
@@ -34,7 +36,14 @@ from ._renderers import ( # noqa: F401 — ré-exports tests
logger = logging.getLogger(__name__)
router = APIRouter()
# `task_db` est enregistré **en premier** : sa route statique
# `GET /db/task-dbs/api` serait sinon capturée par `data_api`'s
# `GET /db/{collection_id}/api` (`collection_id="task-dbs"` → 422).
# `index_page` (la page HTML `/db`) vient ensuite, avant tout module
# déclarant `/db/{collection_id}` — sinon la racine serait capturée en 422.
for _mod in (
task_db,
index_page,
crud,
pages,
boards,
+34 -21
View File
@@ -8,10 +8,11 @@ import json
import logging
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import HTMLResponse
from app.db import get_conn
from app.routers.dashboard._common import _get_active_workspace
from app.services.automations import fire_event, run_event_sync
from app.services.collection_lifecycle import delete_collections
from app.services.db_templates import materialize_properties
from app.services.permission_manager import PermissionManager
@@ -24,22 +25,10 @@ router = APIRouter(tags=["collections"], prefix="/db")
# ── API: List & Create (no path params) ──
@router.get("", response_class=HTMLResponse)
def list_collections(request: Request):
"""Page listing all collections in the workspace."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM collections ORDER BY name"
).fetchall()
collections = [dict(r) for r in rows]
return HTMLResponse(
f"<div class='collections-list'>"
f"<h2>Collections ({len(collections)})</h2>"
f"<pre>{json.dumps(collections, indent=2, default=str)}</pre>"
f"</div>"
)
#
# `GET /db` (la page HTML) vit dans `index_page.py` : elle était auparavant
# un dump JSON de debug, sans layout — alors qu'elle sert de destination au
# bouton « Ouvrir mes bases » de My Tasks.
@@ -71,6 +60,24 @@ def create_collection_api(request: Request, body: dict = Body(default={})):
schema = body.get("schema", [])
is_locked = body.get("is_locked", False)
# Workspace d'appartenance : celui du body si fourni et autorisé, sinon le
# workspace actif de l'utilisateur. Sans cela la collection reste orpheline
# et n'apparaît dans aucune vue scopée (ex. /my-tasks).
user = _session_user(request)
workspace_id = body.get("workspace_id")
if workspace_id is not None:
try:
workspace_id = int(workspace_id)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="workspace_id must be an integer") from None
with get_conn() as conn:
exists = conn.execute("SELECT 1 FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
if not exists:
raise HTTPException(status_code=400, detail="workspace_id does not exist")
else:
active = _get_active_workspace(request, (user or {}).get("id"))
workspace_id = active["id"] if active else None
with get_conn() as conn:
# Apply a template if requested (provides schema + icon).
tpl = _apply_template(conn, body.get("template"))
@@ -88,9 +95,11 @@ def create_collection_api(request: Request, body: dict = Body(default={})):
cur = conn.execute(
"""INSERT INTO collections
(name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(name, description, icon, schema_json, gitea_owner, gitea_repo, int(is_locked)),
(name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked,
workspace_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(name, description, icon, schema_json, gitea_owner, gitea_repo, int(is_locked),
workspace_id),
)
collection_id = cur.lastrowid
@@ -171,7 +180,11 @@ def delete_collection_api(request: Request, collection_id: int):
if not existing:
raise HTTPException(status_code=404, detail="Collection not found")
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
# `delete_collections` détache la page hôte et les vues liées avant la
# suppression : `pages.collection_id` et
# `collection_data_sources.source_collection_id` sont en NO ACTION, la
# suppression brute levait un IntegrityError.
delete_collections(conn, [collection_id])
conn.commit()
run_event_sync(fire_event("collection.deleted", {"collection_id": collection_id,
+259
View File
@@ -0,0 +1,259 @@
"""FlowDeck — Collections : la page « /db » (liste des bases).
`GET /db` était un **dump JSON de debug** (le HTML renvoyé était un
`<pre>` dans le vide, sans layout) : c'est pourtant la destination du bouton
« Ouvrir mes bases » de My Tasks, et le point d'entrée pour convertir une base
en base de tâches. On le remplace par une vraie page :
* bases **du workspace courant** d'abord (ce que voit l'utilisateur dans la
sidebar), puis celles de ses **autres workspaces** dans une section séparée :
sans cela, un utilisateur dont le workspace actif ne contient aucune base
tombe sur une page vide alors qu'il en possède ailleurs ;
* indicateur « Base de tâches » / « À configurer » par base ;
* accès direct à la page de la base, où se trouve le bouton de conversion.
**Rattachement d'une base à un workspace.** `collections.workspace_id` n'est
renseigné que pour les bases créées via `/db/api`. Une base née d'un document
(page convertie en base, base inline) a `workspace_id` **NULL** et n'appartient
qu'à travers sa page hôte : la résout par `COALESCE(workspace_id, page hôte)`
(`app/services.collection_lifecycle.effective_workspace_sql`). Filtrer sur
`workspace_id`, ou faire un `JOIN workspaces`, faisait disparaître ces bases de
la page alors qu'elles sont listées dans la sidebar du workspace — l'utilisateur
les voyait sans pouvoir les ouvrir.
Le filtre de vivacité (`live_collection_ids`) est appliqué : une base dont la
page hôte est à la corbeille n'est pas proposée, puisqu'elle est inaccessible.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from app.db import get_conn
from app.routers.dashboard._common import _get_active_workspace
from app.services.collection_lifecycle import (
effective_workspace_sql,
live_collection_ids,
user_workspace_ids,
)
from app.templating import ENV
from ._common import _session_user
logger = logging.getLogger(__name__)
router = APIRouter(tags=["collections"], prefix="/db")
#: Colonnes communes aux deux listes (carte de base). `c.` qualification
#: obligatoire : le nom de la collection d'icônes homonyme rendrait `rows` et
#: `id` ambigus.
_CARD_COLUMNS = """
c.id, c.name, c.icon, c.parent_page_id, c.workspace_id, c.is_task,
c.task_assignee_prop, c.task_status_prop, c.task_due_prop,
(SELECT COUNT(*) FROM collection_pages cp
WHERE cp.collection_id = c.id AND cp.parent_id IS NULL) AS rows
"""
def _esc(value) -> str:
return (
str(value if value is not None else "")
.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace('"', "&quot;").replace("'", "&#39;")
)
def _card(r: dict) -> str:
"""Une carte de base : état My Tasks + ce qu'il reste à faire."""
target = (f"/pages/{r['parent_page_id']}" if r["parent_page_id"]
else f"/db/{r['id']}")
labels = {"assignee": "Assigné à", "status": "Statut", "due": "Échéance"}
missing = [role for role in ("assignee", "status", "due")
if not r.get(f"task_{role}_prop")]
if r["is_task"] and not missing:
badge = '<span class="db-card-badge ok">Base de tâches</span>'
hint = "Alimente My Tasks."
elif r["is_task"]:
badge = '<span class="db-card-badge warn">À configurer</span>'
hint = ("Il manque : " + ", ".join(labels[m] for m in missing)
+ ". Ouvrez la base puis « Configurer » dans sa barre.")
else:
badge = ""
hint = "Ouvrez-la puis cliquez sur « Convertir en base de tâches »."
# `workspace_id` est NULL pour une base portée par une page : on affiche le
# nom du workspace effectif plutôt que celui de la colonne brute.
ws_label = r.get("ws_label") or ""
meta = f'{r["rows"]} ligne(s)'
if ws_label:
meta += f" · {ws_label}"
return f"""<a class="db-card" href="{_esc(target)}">
<div class="db-card-head">
<span class="db-card-icon">{_esc(r["icon"] or "📋")}</span>
<span class="db-card-name">{_esc(r["name"])}</span>
{badge}
</div>
<div class="db-card-meta">{_esc(meta)}</div>
<div class="db-card-hint">{_esc(hint)}</div>
</a>"""
@router.get("", response_class=HTMLResponse)
def list_collections(request: Request):
"""Liste des bases du workspace, dans le layout de l'application."""
user = _session_user(request)
if not user:
from fastapi.responses import RedirectResponse
return RedirectResponse("/auth/login?provider=local", status_code=302)
with get_conn() as conn:
workspace = _get_active_workspace(request, user.get("id")) or {}
ws_id = workspace.get("id")
ws_name = workspace.get("name") or "Workspace"
rows = _collections_of(conn, ws_id) if ws_id else []
# Bases des autres workspaces : la page ne doit jamais laisser
# l'utilisateur sans piste s'il en possède ailleurs.
current_ids = {r["id"] for r in rows}
others = [r for r in _all_collections(conn, user.get("id"), ws_id)
if r["id"] not in current_ids]
connected = sum(1 for r in rows if r["is_task"])
return HTMLResponse(_render(request, ws_name, rows, connected, bool(ws_id),
others))
def _collections_of(conn, ws_id) -> list[dict]:
"""Bases d'un workspace, vivantes seulement.
`live_collection_ids` écarte les bases dont la page hôte a disparu (corbeille
ou suppression) : elles sont inaccessibles, les proposer serait trompeur.
Le rattachement passe par le workspace **effectif** : une base créée depuis
un document a `workspace_id` NULL et n'est repérée que via sa page hôte.
"""
live = set(live_collection_ids(conn, ws_id))
rows = conn.execute(
f"""SELECT {_CARD_COLUMNS}
FROM collections c
WHERE {effective_workspace_sql("c")} = ?
ORDER BY c.name""",
(ws_id,),
).fetchall()
return [dict(r) for r in rows if r["id"] in live]
def _all_collections(conn, user_id, current_ws_id=None) -> list[dict]:
"""Toutes les bases accessibles à l'utilisateur, tous workspaces confondus.
Sert à la section « Autres workspaces » : l'utilisateur doit pouvoir
retrouver une base même si elle n'est pas dans le workspace actif (My Tasks
étant transverse, ces bases l'alimentent aussi).
Le périmètre est celui de `user_workspace_ids` (propriétaire **ou** membre) :
sans ce filtre, la page exposait les bases de tous les utilisateurs de
l'instance. Les bases sans workspace rattachable sont exclues — elles
n'appartiennent à personne.
"""
ws_ids = user_workspace_ids(conn, user_id)
if not ws_ids:
return []
qs = ",".join("?" * len(ws_ids))
names = {
r["id"]: r["name"]
for r in conn.execute(
f"SELECT id, name FROM workspaces WHERE id IN ({qs})", ws_ids
).fetchall()
}
rows = conn.execute(
f"""SELECT {_CARD_COLUMNS},
{effective_workspace_sql("c")} AS ws_effective
FROM collections c
WHERE {effective_workspace_sql("c")} IN ({qs})
ORDER BY c.name""",
ws_ids,
).fetchall()
live: dict[int, set[int]] = {}
out = []
for r in rows:
d = dict(r)
ws = d["ws_effective"]
if ws not in live:
live[ws] = set(live_collection_ids(conn, ws))
if d["id"] not in live[ws]:
continue
# Le workspace courant est déjà indiqué dans l'en-tête de la page.
d["ws_label"] = "" if ws == current_ws_id else names.get(ws, "")
out.append(d)
return out
def _render(request: Request, ws_name: str, rows: list[dict], connected: int,
has_ws: bool, others: list[dict] | None = None) -> str:
"""Rendu de la page, dans le layout commun (sidebar + base.html)."""
from app.routers.dashboard import _sidebar_data
others = others or []
blocks = [_card(r) for r in rows]
other_blocks = [_card(r) for r in others]
if rows or others:
total = len(rows) + len(others)
summary = (f"{connected} base(s) de tâches connectée(s) sur {total}"
if connected else
f"Aucune base de tâches connectée sur {total}")
sections = f"""<div class="db-grid">{''.join(blocks)}</div>"""
if other_blocks:
sections += ('<h2 class="db-section-title">Autres workspaces</h2>'
'<p class="db-section-sub">Ces bases appartiennent à '
"d'autres workspaces — elles alimentent tout de même "
f'My Tasks.</p><div class="db-grid">'
f"{''.join(other_blocks)}</div>")
body = f"""<div class="db-index">
<div class="db-index-head">
<h1>📋 Bases de données</h1>
<span class="db-index-ws">{_esc(ws_name)}</span>
</div>
<p class="db-index-sub">{summary} — les bases de tâches alimentent
<a href="/my-tasks">My Tasks</a>.</p>
{sections}
</div>"""
elif has_ws:
body = """<div class="db-empty">
<div class="db-empty-icon">📋</div>
<h1>Aucune base dans ce workspace</h1>
<p>Créez une page, puis convertissez-la en base de données — ou partez
d'un modèle depuis le menu d'ajout.</p>
<p class="db-empty-hint">Une base reliée à My Tasks apparaît ici avec son
état de configuration.</p>
</div>"""
else:
body = """<div class="db-empty">
<div class="db-empty-icon">📋</div>
<h1>Aucun workspace sélectionné</h1>
<p>Créez ou ouvrez d'abord un workspace : vos bases y seront rattachées.</p>
</div>"""
# `my_tasks.css` porte les styles `.db-*` de cette page (cartes, sections,
# états vides) **et** ceux de My Tasks. Elle est liée par `base.html`
# (le shell, jamais swappé) : la lier ici exposait la page à un <link>
# retiré par htmx lors d'une navigation partielle.
block_tpl = ENV.from_string(
'{% extends "base.html" %}'
"{% block content %}{{ content_html|safe }}{% endblock %}"
)
return block_tpl.render(
**_sidebar_data(request, []),
request=request,
content_html=body,
page_title="Bases de données",
title_prefix="Bases de données",
page_icon="grid",
)
+15 -4
View File
@@ -211,14 +211,25 @@ def create_inline_database(request: Request, body: dict = Body(default={})):
@router.put("/{collection_id}/toggle-task/api")
def toggle_task(request: Request, collection_id: int):
"""API: toggle is_task flag on a collection (Turn into Tasks)."""
"""API: toggle is_task flag on a collection (Turn into Tasks).
Rétrocompatible : l'activation passe désormais par la même conversion que
``POST /db/{id}/task-db/api``, donc les trois colonnes requises sont liées
(créées si besoin). Sans cela, cette route laissait une base « connectée »
mais muette — elle n'émettait aucune tâche faute de mapping.
"""
from .task_db import disable_task_db_in_conn, enable_task_db_in_conn
with get_conn() as conn:
coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
new_val = 0 if coll["is_task"] else 1
conn.execute("UPDATE collections SET is_task=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (new_val, collection_id))
conn.commit()
if coll["is_task"]:
disable_task_db_in_conn(conn, collection_id)
new_val = 0
else:
enable_task_db_in_conn(conn, collection_id)
new_val = 1
return {"collection_id": collection_id, "is_task": bool(new_val), "mode": "tasks" if new_val else "standard"}
+270
View File
@@ -0,0 +1,270 @@
"""FlowDeck — Collections : « base de tâches » (My Tasks façon Notion).
Unlike Notion — where any base can be added to the dashboard widget from
scratch — a base doit **explicitement** alimenter My Tasks :
``POST /db/{id}/task-db`` bascule ``collections.is_task`` et enregistre le
mapping des trois propriétés requises (Assigné à / Statut / Échéance).
Tant que le mapping est incomplet, la base est connectée mais **n'émet
aucune tâche** (`task_databases.collect_tasks` la saute) — l'UI le signale
plutôt que de deviner une colonne.
Limite : 10 bases connectées au tableau de bord (`MAX_TASK_SOURCES`).
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Body, HTTPException, Request
from app.db import get_conn
from app.services.task_databases import (
MAX_TASK_SOURCES,
ROLE_LABELS,
ROLE_TYPES,
TASK_ROLES,
list_task_sources,
source_state,
)
from ._common import _require_edit, _session_user
logger = logging.getLogger(__name__)
router = APIRouter(tags=["collections"], prefix="/db")
#: Trio de statuts amorcé quand une colonne Statut est créée à la conversion.
DEFAULT_STATUS_OPTIONS = [
{"name": "À faire", "color": "gray"},
{"name": "En cours", "color": "blue"},
{"name": "Terminée", "color": "green"},
]
def _user_id(request: Request) -> int:
"""ID de l'appelant — session obligatoire.
`_current_user` retombe sur l'admin (id 1) en cas d'absence de session :
wrong pour une route qui liste *les bases de l'utilisateur* (un anonyme
verrait le tableau de bord de l'admin). On passe donc par `_session_user`.
"""
user = _session_user(request)
if not user or not user.get("id"):
raise HTTPException(status_code=401, detail="Authentication required")
return user["id"]
@router.get("/task-dbs/api")
def list_connected_task_databases(request: Request):
"""Bases de tâches connectées au tableau de bord My Tasks."""
uid = _user_id(request)
with get_conn() as conn:
sources = list_task_sources(conn, uid)
return {
"sources": [
{k: s[k] for k in (
"id", "name", "icon", "workspace_id", "workspace_name",
"configured", "missing_roles", "status_options",
)}
for s in sources
],
"total": len(sources),
"max_sources": MAX_TASK_SOURCES,
"limit_reached": len(sources) >= MAX_TASK_SOURCES,
}
@router.get("/{collection_id}/task-db/api")
def get_task_db_state(request: Request, collection_id: int):
"""État de conversion + colonnes candidates pour la modale."""
_require_edit(collection_id, _session_user(request))
with get_conn() as conn:
state = source_state(conn, collection_id)
if not state["exists"]:
raise HTTPException(status_code=404, detail="Collection not found")
return state
@router.post("/{collection_id}/task-db/api")
def enable_task_db(request: Request, collection_id: int, body: dict = Body(default={})):
"""Convertit la base en base de tâches (mapping explicite des 3 rôles).
``mapping`` : ``{"assignee": <prop_id>, "status": …, "due": …}``.
``create_missing`` : ``{"status": {"name": "Statut", "options": [...]}}``
pour qu'une colonne absente soit créée dans la transaction.
"""
_require_edit(collection_id, _session_user(request))
with get_conn() as conn:
state = enable_task_db_in_conn(
conn,
collection_id,
mapping=body.get("mapping") or {},
create_missing=body.get("create_missing") or {},
)
return {"status": "enabled", "collection_id": collection_id,
"mapping": state["mapping"], "configured": state["configured"]}
def enable_task_db_in_conn(conn, collection_id: int, *, mapping: dict | None = None,
create_missing: dict | None = None) -> dict:
"""Active ``is_task`` et enregistre le mapping — dans la transaction
appelante.
Point d'entrée unique de la conversion : la route `/task-db/api` comme
l'ancien `PUT /db/{id}/toggle-task/api` (qui aurait sinon laissé une base
« connectée » sans aucune colonne liée, donc muette).
"""
state = source_state(conn, collection_id)
if not state["exists"]:
raise HTTPException(status_code=404, detail="Collection not found")
# ── Limite de sources (on recompte : la base elle-même peut déjà être
# connectée, auquel cas on ne la compte pas deux fois).
if not state["is_task"] and state["sources_count"] >= MAX_TASK_SOURCES:
raise HTTPException(
status_code=409,
detail=(
f"Limite de {MAX_TASK_SOURCES} bases de tâches atteinte. "
"Déconnectez une base avant d'en ajouter une autre."
),
)
# ── Création des colonnes manquantes ──
create_missing = create_missing or {}
mapping = dict(mapping or {})
by_name = {p["name"]: p for p in state["all_properties"]}
for role in TASK_ROLES:
if mapping.get(role):
continue
spec = create_missing.get(role) or {}
name = (spec.get("name") or ROLE_LABELS[role]).strip()
prop_type = spec.get("prop_type") or ROLE_TYPES[role][0]
if prop_type not in ROLE_TYPES[role]:
raise HTTPException(
status_code=400,
detail=f"Type '{prop_type}' incompatible avec le rôle '{role}'",
)
if name in by_name:
# La colonne existe déjà (nom saisi) → on la lie.
mapping[role] = by_name[name]["id"]
continue
created = _create_property(conn, collection_id, name, prop_type,
spec.get("options"))
mapping[role] = created["id"]
# ── Validation : les 3 rôles pointent une colonne de la bonne base ──
props = {
p["id"]: p
for p in conn.execute(
"SELECT id, name, prop_type FROM collection_properties WHERE collection_id=?",
(collection_id,),
).fetchall()
}
seen: dict[int, str] = {}
for role in TASK_ROLES:
prop_id = mapping.get(role)
if not prop_id:
raise HTTPException(
status_code=400,
detail=f"Colonne « {ROLE_LABELS[role]} » manquante",
)
try:
prop_id = int(prop_id)
except (TypeError, ValueError):
raise HTTPException(status_code=400,
detail="Identifiant de propriété invalide") from None
prop = props.get(prop_id)
if prop is None:
raise HTTPException(
status_code=400,
detail=f"« {ROLE_LABELS[role]} » n'appartient pas à cette base",
)
if prop["prop_type"] not in ROLE_TYPES[role]:
raise HTTPException(
status_code=400,
detail=(
f"« {prop['name']} » est de type {prop['prop_type']}, "
f"incompatible avec « {ROLE_LABELS[role]} »"
),
)
if prop_id in seen:
raise HTTPException(
status_code=400,
detail=f"« {prop['name']} » est déjà utilisé pour « {seen[prop_id]} »",
)
seen[prop_id] = ROLE_LABELS[role]
mapping[role] = prop_id
conn.execute(
"""UPDATE collections
SET is_task = 1,
task_assignee_prop = ?, task_status_prop = ?, task_due_prop = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?""",
(mapping["assignee"], mapping["status"], mapping["due"], collection_id),
)
conn.commit()
return source_state(conn, collection_id)
@router.delete("/{collection_id}/task-db/api")
def disable_task_db(request: Request, collection_id: int):
"""Déconnecte la base du tableau de bord (les tâches sont conservées)."""
_require_edit(collection_id, _session_user(request))
with get_conn() as conn:
disable_task_db_in_conn(conn, collection_id)
return {"status": "disabled", "collection_id": collection_id}
def disable_task_db_in_conn(conn, collection_id: int) -> None:
"""Déconnexion dans la transaction appelante : efface aussi le mapping,
pour ne pas laisser des identifiants de colonnes mortes."""
row = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Collection not found")
conn.execute(
"""UPDATE collections
SET is_task = 0, task_assignee_prop = NULL, task_status_prop = NULL,
task_due_prop = NULL, updated_at = CURRENT_TIMESTAMP
WHERE id = ?""",
(collection_id,),
)
conn.commit()
def _create_property(conn, collection_id: int, name: str, prop_type: str,
options=None) -> dict:
"""Crée une propriété (à la position suivante) et la renvoie.
Une colonne Statut amorcée sans options reçoit le trio classique
« À faire / En cours / Terminée » : sans options, le Kanban n'aurait
aucune colonne à afficher.
"""
import json
row = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties "
"WHERE collection_id = ?",
(collection_id,),
).fetchone()
position = row[0] if row else 0
opts = []
for opt in options or []:
if isinstance(opt, str):
opts.append({"name": opt, "color": "gray"})
elif isinstance(opt, dict) and opt.get("name"):
opts.append({"name": opt["name"], "color": opt.get("color") or "gray"})
if not opts and prop_type in ("status", "select"):
opts = list(DEFAULT_STATUS_OPTIONS)
cur = conn.execute(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, options_json, position, required,
visible_in_views)
VALUES (?, ?, ?, ?, ?, 0, 1)""",
(collection_id, name, prop_type, json.dumps(opts), position),
)
prop_id = cur.lastrowid
conn.commit()
return {"id": prop_id, "name": name, "prop_type": prop_type}
+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 ──
+207 -39
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)
@@ -174,6 +227,11 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
active_ws_name = row["name"]
workspace_pages = _load_workspace_pages(ws_cookie)
has_active_workspace = True
# v7.46.0 : exposer l'ID de l'espace ACTIF. Avant, le sidebar
# ne fournissait que son nom et le bouton Home de la sidebar
# retombait sur ``local_workspaces[0]`` (premier espace TRIE
# PAR NOM) → clic sur Home = changement d'espace surprise.
local_ws_id = wsi
# else: stale cookie from another user — ignore
except (ValueError, Exception):
pass
@@ -199,28 +257,30 @@ 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
local_ws_id = 0
# 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:
local_ws_id = row["id"]
gitea_mirror_ws_id = row["id"]
except Exception:
logger.exception("_sidebar_data")
# Private pages for mirror workspace (when Gitea remote active)
private_pages = []
if gitea_workspace and local_ws_id:
if gitea_workspace and gitea_mirror_ws_id:
try:
with get_conn() as conn:
pp_rows = conn.execute(
"SELECT id, title FROM pages WHERE parent_section='Private' AND workspace_id=? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 20",
(local_ws_id,)
(gitea_mirror_ws_id,)
).fetchall()
private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows]
except Exception:
@@ -353,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()
@@ -435,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
@@ -558,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:
@@ -665,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)
+81 -4
View File
@@ -15,8 +15,10 @@ from app.config import settings
from app.db import get_conn
from ._common import (
WORKSPACE_COOKIE,
_build_breadcrumb,
_build_tree_children,
_cleanup_empty_mirrors,
_file_page_disk_path,
_get_active_workspace,
_get_user_id,
@@ -34,10 +36,16 @@ router = APIRouter(tags=["dashboard"])
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
@router.get("/local-workspace", response_class=HTMLResponse)
def local_workspace_page(request: Request, folder: int = None):
def local_workspace_page(request: Request, folder: int = None, ws: int = None):
"""Local workspace page with file/folder tree.
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
If ?ws=ID is provided, shows that workspace — c'est ce que fait le bouton
« Home » de la sidebar (``/local-workspace?ws=<id>``). Le paramètre était
produit par le sidebar mais **ignoré** par la route : le contenu affiché
restait celui du workspace *actif* (cookie), donc Home pouvait montrer un
autre workspace que celui annoncé dans l'URL. Un ``ws`` qui n'appartient
pas à l'utilisateur est ignoré (et le workspace actif conservé).
"""
from app.templating import ENV
env = ENV
@@ -46,13 +54,52 @@ def local_workspace_page(request: Request, folder: int = None):
if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302)
ws = _get_active_workspace(request, user_id=user["id"])
ws_id = ws["id"] if ws else None
ws_row = _get_active_workspace(request, user_id=user["id"])
if ws is not None:
# Workspace demandé par l'URL : on ne l'accepte que s'il appartient à
# l'utilisateur (même règle de propriété que _get_active_workspace).
with get_conn() as conn:
requested = conn.execute(
"SELECT id, name FROM workspaces WHERE id=? AND owner_id=?",
(ws, user["id"]),
).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é.
response = _render_local_workspace(env, sidebar, user, ws_row, folder)
response.set_cookie(WORKSPACE_COOKIE, str(ws_row["id"]),
max_age=86400 * 30, httponly=True, path="/")
return response
ws_id = ws_row["id"] if ws_row else None
# If no workspace exists for this user, redirect to workspaces page
if not ws_id:
return RedirectResponse("/workspaces", status_code=302)
return _render_local_workspace(env, sidebar, user, ws_row, folder)
def _render_local_workspace(env, sidebar: dict, user: dict, ws_row: dict,
folder: int | None) -> HTMLResponse:
"""Render the local-workspace page for ``ws_row`` (shared by both paths).
``sidebar`` a été calculé **avant** le éventuel changement de workspace
(``?ws=``) : on force donc le nom affiché et la sidebar sur ``ws_row``,
sinon la page afficherait le contenu d'un workspace sous le titre d'un
autre.
"""
ws_id = ws_row["id"]
ws_name = (ws_row.get("name") or sidebar.get("active_ws_name") or "My Workspace")
# Build breadcrumb if navigating into a folder
breadcrumb = []
current_folder_id = folder
@@ -60,15 +107,40 @@ def local_workspace_page(request: Request, folder: int = None):
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,
"workspace_name": sidebar.get("active_ws_name", "My Workspace"),
"workspace_name": ws_name,
"active_ws_name": ws_name,
"active_workspace_id": ws_id,
"current_folder_id": current_folder_id or 0,
"workspace_id": ws_id or 0,
"nav_workspace_id": ws_id or 0,
"breadcrumb": breadcrumb,
"breadcrumbs": breadcrumb,
"breadcrumb_items": nav_crumbs,
}
template = env.get_template("local_workspace.html")
return HTMLResponse(
@@ -255,6 +327,7 @@ def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
@router.post("/api/local-workspace/items")
def create_local_workspace_item(request: Request, body: dict = Body(default={})):
"""Create a new file in the active workspace."""
_require_user_id(request) # A3/A4 : pas de creation de page anonyme
name = (body.get("name") or "").strip() or "Untitled"
item_type = body.get("type", "page")
parent_id = body.get("parent_id")
@@ -279,6 +352,7 @@ def create_local_workspace_item(request: Request, body: dict = Body(default={}))
@router.put("/api/local-workspace/items/{item_id:int}")
def rename_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
"""Rename a file."""
_require_user_id(request)
name = body.get("name", "Untitled").strip()
with get_conn() as conn:
conn.execute("UPDATE pages SET title=? WHERE id=?", (name, item_id))
@@ -291,6 +365,7 @@ def rename_local_workspace_item(request: Request, item_id: int, body: dict = Bod
@router.delete("/api/local-workspace/items/{item_id:int}")
def delete_local_workspace_item(request: Request, item_id: int):
"""Soft-delete a file/folder (sets deleted_at)."""
_require_user_id(request)
from datetime import datetime
with get_conn() as conn:
conn.execute(
@@ -306,6 +381,7 @@ def delete_local_workspace_item(request: Request, item_id: int):
@router.post("/api/local-workspace/items/{item_id:int}/restore")
def restore_local_workspace_item(request: Request, item_id: int):
"""Restore a soft-deleted file/folder."""
_require_user_id(request)
with get_conn() as conn:
conn.execute(
"UPDATE pages SET deleted_at=NULL WHERE id=?",
@@ -342,6 +418,7 @@ def serve_uploaded_file(ws_id: int, filename: str):
@router.put("/api/local-workspace/items/{item_id:int}/move")
def move_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
"""Move an item to a new parent (drag & drop)."""
_require_user_id(request)
new_parent_id = body.get("parent_id") # None = move to root
with get_conn() as conn:
conn.execute(
+13 -4
View File
@@ -6,7 +6,7 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, Body, Request
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
@@ -62,11 +62,20 @@ def api_rename_page(page_id: int, request: Request, body: dict = Body(default={}
@router.post("/api/pages/{page_id:int}/trash")
def api_trash_page(page_id: int):
"""Soft-delete a page (move to trash)."""
def api_trash_page(page_id: int, request: Request):
"""Soft-delete a page (move to trash).
v7.45.5 : ``parent_section`` n'est plus réécrit en 'Trash' — ``deleted_at``
est la seule source de vérité. L'ancienne écriture marquait définitivement
la page : à la restauration elle restait 'Trash' et disparaissait des
listings Library / Recents / Private (``parent_section != 'Trash'``).
"""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
raise HTTPException(401, "Authentication required")
with get_conn() as conn:
conn.execute(
"UPDATE pages SET parent_section='Trash', deleted_at=CURRENT_TIMESTAMP WHERE id=?",
"UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id=?",
(page_id,),
)
conn.commit()
+29 -207
View File
@@ -29,15 +29,16 @@ def trash_page(request: Request, owner: str = Query(default=""), repo: str = Que
sidebar = board_sidebar(request, owner, repo)
with get_conn() as conn:
ws_key = f"{owner}/{repo}" if owner and repo else ""
# Pages are soft-deleted via parent_section='Trash'
# Pages are soft-deleted via deleted_at (parent_section n'est plus
# touché par le trash → source de vérité unique, v7.45.5)
if ws_key:
rows = conn.execute(
"SELECT id, title, workspace FROM pages WHERE parent_section='Trash' AND workspace=? ORDER BY updated_at DESC",
"SELECT id, title, workspace FROM pages WHERE deleted_at IS NOT NULL AND workspace=? ORDER BY updated_at DESC",
(ws_key,),
).fetchall()
else:
rows = conn.execute(
"SELECT id, title, workspace FROM pages WHERE parent_section='Trash' ORDER BY updated_at DESC",
"SELECT id, title, workspace FROM pages WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC",
).fetchall()
sidebar["trash_items"] = [{"id": r["id"], "name": r["title"] or "Untitled", "workspace": r["workspace"]} for r in rows]
# Pass active workspace for breadcrumb nav menu
@@ -93,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"))
@@ -118,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,
@@ -216,8 +235,10 @@ def view_page_root(request: Request, page_id: int):
"{% from '_icons.html' import fd_icon %}{{ fd_icon('paperclip', 14) }}"
).render(),
)
# Select template: collection pages use database table view
if page.get("content_format") == "collection" and not embed:
# Select template: collection pages use database table view (aussi en
# mode embed : `page_editor_collection.html` + body.embed-mode = le
# tableau SANS sidebar ni barre — le peek d'une base reste le tableau).
if page.get("content_format") == "collection":
template = env.get_template("page_editor_collection.html")
else:
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
@@ -248,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")
+49 -7
View File
@@ -6,18 +6,48 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, Body, Request
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.collection_lifecycle import (
delete_collections,
workspace_collection_ids,
)
from ._common import WORKSPACE_COOKIE, _get_active_workspace, _get_user_id, _sidebar_data
from ._common import (
WORKSPACE_COOKIE,
_cleanup_empty_mirrors,
_get_active_workspace,
_get_user_id,
_require_user_id,
_sidebar_data,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["dashboard"])
def _require_ws_owner(request: Request, ws_id: int) -> int:
"""A3/A4 — session obligatoire + l'appelant doit posseder l'espace
(ou être admin global). Sans ce garde, un POST/DELETE anonyme créait ou
supprimait des espaces au nom de l'utilisateur 1."""
uid = _require_user_id(request)
with get_conn() as conn:
row = conn.execute(
"SELECT owner_id FROM workspaces WHERE id=?", (ws_id,)
).fetchone()
if not row:
raise HTTPException(404, "Workspace not found")
is_admin = conn.execute(
"SELECT is_admin FROM users WHERE id=?", (uid,)
).fetchone()
if row["owner_id"] != uid and not (is_admin and is_admin["is_admin"]):
raise HTTPException(403, "Not your workspace")
return uid
@router.get("/workspaces", response_class=HTMLResponse)
@@ -42,8 +72,7 @@ def workspaces_page(request: Request):
@router.get("/api/workspaces")
def list_workspaces(request: Request):
"""List all workspaces for the current user."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = user["id"] if user and user.get("id") else 1
uid = _require_user_id(request)
with get_conn() as conn:
rows = conn.execute(
"SELECT w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id AND collection_row_id IS NULL) as page_count "
@@ -67,7 +96,7 @@ def create_workspace(request: Request, body: dict = Body(default={})):
if not name:
return {"error": "Name required"}
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = user["id"] if user and user.get("id") else 1
uid = _require_user_id(request)
with get_conn() as conn:
# Ensure user exists (FK constraint)
uid_ok = conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone()
@@ -99,6 +128,7 @@ def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})
name = body.get("name", "").strip()
if not name:
return {"error": "Name required"}
_require_ws_owner(request, ws_id)
with get_conn() as conn:
conn.execute("UPDATE workspaces SET name=? WHERE id=?", (name, ws_id))
conn.commit()
@@ -109,13 +139,19 @@ def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})
@router.delete("/api/workspaces/{ws_id:int}")
def delete_workspace(request: Request, ws_id: int):
"""Delete a workspace and all its pages."""
"""Delete a workspace, its pages and its databases."""
_require_ws_owner(request, ws_id)
with get_conn() as conn:
# Les bases du workspace partaient avec ses pages : les laisser créait
# des collections orphelines (workspace_id pointant sur rien) que
# /db et My Tasks continuaient de lister.
collections = workspace_collection_ids(conn, ws_id)
delete_collections(conn, collections)
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,))
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws_id,))
conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,))
conn.commit()
return {"status": "ok"}
return {"status": "ok", "collections": len(collections)}
@@ -123,6 +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."""
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:
+397 -2
View File
@@ -9,7 +9,9 @@ See ``docs/V71_Calendar_Meetings.md``.
"""
from __future__ import annotations
import json
import secrets
from datetime import UTC, date, datetime, timedelta
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import JSONResponse
@@ -115,8 +117,109 @@ def freebusy(collection_id: int, request: Request):
return out
# ── upcoming (sidebar "Meeting" tab) ────────────────────────────────────────
@router.get("/api/v2/meetings/upcoming")
def upcoming_meetings(request: Request, days: int = 30):
"""Upcoming calendar events for the sidebar Meeting tab.
Reads every calendar collection linked to the current user, extracts the
date property of each linked row and returns the ones falling in the next
``days`` days (today included), sorted chronologically. Rows imported from
Google/CalDAV carry an ``external_event_id`` and are flagged ``synced``.
"""
user = _auth_user(request)
horizon = max(1, min(days, 365))
today = datetime.now(UTC).date()
end = today + timedelta(days=horizon)
events: list[dict] = []
with get_conn() as conn:
links = conn.execute(
"SELECT id, collection_id, date_property, calendar_id, provider "
"FROM calendar_links WHERE user_id=? ORDER BY id",
(user["id"],),
).fetchall()
for link in links:
collection_id = link["collection_id"]
if not collection_id:
continue
date_prop = cal.date_prop_id(conn, collection_id,
link["date_property"] or "")
if not date_prop:
continue
prop_id, prop_name = date_prop
rows = conn.execute(
"SELECT id, title, property_values_json, external_event_id "
"FROM collection_pages WHERE collection_id=?",
(collection_id,),
).fetchall()
for row in rows:
try:
values = json.loads(row["property_values_json"] or "{}")
except (TypeError, ValueError):
continue
raw = cal.row_date(values, prop_id, prop_name)[:10]
if len(raw) != 10:
continue
try:
day = date.fromisoformat(raw)
except ValueError:
continue
if not (today <= day <= end):
continue
events.append({
"id": row["id"],
"title": row["title"] or "Untitled",
"date": raw,
"today": day == today,
"collection_id": collection_id,
"calendar_id": link["calendar_id"] or "primary",
"provider": link["provider"],
"synced": bool(row["external_event_id"]),
"url": f"/db/{collection_id}",
})
events.sort(key=lambda e: (e["date"], e["title"].lower()))
return {"today": today.isoformat(), "days": horizon,
"events": events[:200], "count": len(events)}
# ── 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)
@@ -186,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}
+374 -98
View File
@@ -1,147 +1,423 @@
"""FlowDeck — My Tasks router (v1.9.0)."""
"""FlowDeck — My Tasks (tableau de bord de tâches façon Notion).
Contrairement à Notion, où *My Tasks* vit dans l'onglet **Home** sans être
rattaché à une page, cette page est un **tableau de bord transverse** : elle
réunit toutes les tâches qui vous sont assignées, **tous workspaces
confondus**. Le cookie `flowdeck_workspace` ne cadre donc plus cette vue (il
reste utilisé par la sidebar).
Les sources sont opt-in : une base n'alimente My Tasks qu'après conversion
explicite (`collections.is_task` + mapping des trois propriétés requises —
`app/services/task_databases.py`).
Trois vues (tableau / kanban / calendrier), filtres globaux (échéance
aujourd'hui, masquer les terminées), tri (échéance, statut, source), édition
en direct du statut et de l'échéance, et création rapide dans n'importe
quelle base connectée.
"""
from __future__ import annotations
import html
import json
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.property_types import apply_auto_properties
from app.services.task_databases import (
MAX_TASK_SOURCES,
collect_tasks,
filter_and_sort,
list_task_sources,
user_today,
)
from app.templating import ENV
logger = logging.getLogger(__name__)
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
VIEWS = ("table", "board", "calendar")
DUE_FILTERS = ("all", "today", "overdue", "week")
SORTS = ("due", "status", "source", "created")
# ── Session ──────────────────────────────────────────────────────────────
def _get_current_user(request: Request) -> dict | None:
session = request.cookies.get("flowdeck_session", "")
return SessionManager.decode_session(session)
@router.get("", response_class=HTMLResponse)
def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
"""My Tasks — aggregates all pages assigned to the current user across all collections."""
def _require_user(request: Request) -> dict:
"""Session obligatoire, sans repli « admin » (id 1) : ce tableau de bord
est strictement celui de l'appelant."""
user = _get_current_user(request)
user_login = user.get("login", "admin") if user else "admin"
if not user or not user.get("id"):
raise HTTPException(status_code=401, detail="Authentication required")
return user
with get_conn() as conn:
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
grouped: dict[str, list[dict]] = {}
for col in collections:
col_id = col["id"]
pages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
(col_id,),
).fetchall()
# ── Données ──────────────────────────────────────────────────────────────
collection_tasks = []
for p in pages:
props = {}
try:
props = json.loads(p["property_values_json"] or "{}")
except (json.JSONDecodeError, TypeError):
pass
due_date = props.get("due_date", "")
status = props.get("status", "—")
assignee = props.get("assignee", "")
if view != "all" and assignee and assignee != user_login:
continue
collection_tasks.append({
"id": p["id"],
"title": p["title"] or "Untitled",
"icon": p["icon"] or "📋",
"status": status,
"due_date": due_date,
})
if collection_tasks:
grouped[col["name"]] = collection_tasks
def _workspace_names(conn, user_id: int) -> dict[int, str]:
rows = conn.execute(
"SELECT id, name FROM workspaces WHERE owner_id = ?", (user_id,)
).fetchall()
return {r["id"]: r["name"] for r in rows}
total = sum(len(t) for t in grouped.values())
# Build content HTML
sections = ""
for col_name, tasks in grouped.items():
items = ""
for t in tasks:
due_badge = f"<span class='mt-due'>{t['due_date']}</span>" if t.get("due_date") else ""
status_cls = t['status'].lower().replace(' ', '-') if t.get('status') else 'none'
status_badge = f"<span class='mt-status mt-{status_cls}'>{t.get('status', '—')}</span>"
items += f"<div class='mt-item'><span class='mt-icon'>{t['icon']}</span><span class='mt-title'>{t['title']}</span>{status_badge}{due_badge}</div>"
def _validated(view: str, due: str, sort: str) -> tuple[str, str, str]:
"""Paramètres d'URL bornés aux valeurs réellement implémentées."""
return (
view if view in VIEWS else "table",
due if due in DUE_FILTERS else "all",
sort if sort in SORTS else "due",
)
sections += f"<div class='mt-section'><div class='mt-section-header'>{col_name} <span class='mt-count'>{len(tasks)}</span></div>{items}</div>"
content_html = f"""<div style="max-width:800px;margin:0 auto;padding:40px 24px;">
<h1 style="font-size:24px;margin:0 0 8px;">📋 My Tasks</h1>
<p style="color:var(--text-dim);font-size:14px;margin-bottom:24px;">{total} tasks across {len(grouped)} collections</p>
<div class="view-tabs" style="display:flex;gap:4px;margin-bottom:24px;border-bottom:1px solid var(--border);padding-bottom:12px;">
<a class="tab{' active' if view=='all' else ''}" href="/my-tasks?view=all" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">All</a>
<a class="tab{' active' if view=='today' else ''}" href="/my-tasks?view=today" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Today</a>
<a class="tab{' active' if view=='overdue' else ''}" href="/my-tasks?view=overdue" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Overdue</a>
<a class="tab{' active' if view=='upcoming' else ''}" href="/my-tasks?view=upcoming&days=7" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Next 7 days</a>
</div>
<style>
.tab.active{{background:var(--accent);color:#fff!important;}}
.tab:hover{{background:var(--bg-hover);color:var(--text);}}
.mt-section{{margin-bottom:24px;}}
.mt-section-header{{font-size:13px;font-weight:600;color:var(--text-dim);margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px;}}
.mt-count{{color:var(--text-dim);font-weight:400;opacity:.5;}}
.mt-item{{display:flex;align-items:center;gap:10px;padding:10px 12px;background:var(--bg-card);border-radius:8px;margin-bottom:3px;border:1px solid var(--border);}}
.mt-item:hover{{border-color:var(--accent);}}
.mt-icon{{font-size:16px;}}
.mt-title{{flex:1;font-size:14px;}}
.mt-status{{font-size:11px;padding:2px 8px;border-radius:4px;}}
.mt-due{{font-size:11px;color:var(--text-dim);}}
.mt-todo,.mt-none{{background:rgba(255,255,255,.05);color:var(--text-dim);}}
.mt-in-progress{{background:rgba(35,131,226,.15);color:#2C8CEB;}}
.mt-done,.mt-completed,.mt-complete{{background:rgba(0,200,100,.15);color:#00CC66;}}
</style>
{sections}
</div>"""
def _public_source(s: dict) -> dict:
return {
"id": s["id"],
"name": s["name"],
"icon": s["icon"],
"workspace_id": s["workspace_id"],
"workspace_name": s.get("workspace_name") or "",
"configured": s["configured"],
"missing_roles": s["missing_roles"],
"status_options": s["status_options"],
}
def _payload(conn, request: Request, user_id: int, *, due: str, sort: str,
hide_done: bool) -> dict:
"""Charge et normalise tout ce dont les trois vues ont besoin."""
user = _get_current_user(request) or {}
tasks = collect_tasks(conn, user_id, mine_only=True,
login=user.get("login", ""))
# Même jour de référence que `due_state` (fuseau de l'utilisateur).
tasks = filter_and_sort(tasks, due=due, hide_done=hide_done, sort=sort,
today=user_today(conn, user_id))
sources = list_task_sources(conn, user_id)
return {
"tasks": tasks,
"total": len(tasks),
"sources": [_public_source(s) for s in sources],
"sources_total": len(sources),
"sources_configured": sum(1 for s in sources if s["configured"]),
"max_sources": MAX_TASK_SOURCES,
"filters": {"due": due, "sort": sort, "hide_done": hide_done},
}
# ── Rendu HTML ───────────────────────────────────────────────────────────
def _render_shell(request: Request, content_html: str, *, app_js: bool = True):
"""Enveloppe le contenu dans le layout commun (sidebar + base.html).
`static/css/my_tasks.css` est lié par `base.html` (le shell, jamais swappé)
: un `<link>` placé dans la zone swappée pouvait être retiré par htmx lors
d'une navigation partielle, et la page revenait sans aucun style. Seul
`static/js/my_tasks.js` reste conditionné à ``app_js`` : sans base
connectée, il n'y a rien à monter côté client.
"""
from app.routers.dashboard import _sidebar_data
env = ENV
sidebar = _sidebar_data(request, [])
block_tpl = env.from_string(
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
# `assets` est rendu par un mini-template : `asset_version` (cache-busting)
# n'est pas substitutionné si on le concatène à la main.
assets = ENV.from_string(
"{% if app_js %}"
'<script src="/static/js/my_tasks.js?v={{ asset_version }}" defer></script>'
"{% endif %}"
).render(app_js=app_js)
block_tpl = ENV.from_string(
'{% extends "base.html" %}'
"{% block content %}{{ assets|safe }}{{ content_html|safe }}{% endblock %}"
)
return block_tpl.render(
**sidebar,
request=request,
content_html=content_html,
assets=assets,
page_title="My Tasks",
title_prefix="My Tasks",
page_icon="📋",
)
@router.get("/api")
def my_tasks_api(request: Request, view: str = "all", days: int = 7):
"""API: return my tasks as JSON."""
def _json_attr(obj) -> str:
"""JSON échappé pour un attribut HTML."""
return html.escape(json.dumps(obj, ensure_ascii=False), quote=True)
def _render_app(data: dict, view: str) -> str:
"""Coquille du tableau de bord : barre d'outils, filtres et zone de vue
sont rendus côté client à partir de la configuration ci-dessous."""
config = {
"view": view,
"filters": data["filters"],
"sources": data["sources"],
"maxSources": data["max_sources"],
"views": list(VIEWS),
}
return ('<div id="my-tasks-app" class="my-tasks-app" '
f'data-config="{_json_attr(config)}"></div>')
def _render_empty(data: dict) -> str:
"""État vide « aucune source » : explique la conversion en base de tâches.
Gabarit centré (`my_tasks-empty*` dans `static/css/my_tasks.css`), sans
style inline : la mise en page suit le thème clair/sombre et reste
modifiable en un seul endroit. Les autres états vides (filtres actifs,
rien à faire) sont rendus par le client, qui connaît l'état des filtres.
"""
return f"""<div class="my-tasks-empty">
<div class="my-tasks-empty-icon">📋</div>
<h2>My Tasks</h2>
<p>Aucune base n'alimente encore ce tableau de bord. Pour en ajouter une :</p>
<ol class="my-tasks-empty-steps">
<li><span class="n">1</span><span>Ouvrez une base de données depuis
<a href="/db">la liste de vos bases</a>.</span></li>
<li><span class="n">2</span><span>Dans la barre de la base, cliquez sur
<b>« Convertir en base de tâches »</b> — il se trouve à gauche du bouton
<b>New</b>.</span></li>
<li><span class="n">3</span><span>Reliez les colonnes <b>Assigné à</b>,
<b>Statut</b> et <b>Échéance</b>, puis validez.</span></li>
</ol>
<p class="my-tasks-empty-hint">Jusqu'à {data["max_sources"]} bases
peuvent alimenter ce tableau de bord.</p>
<a href="/db" class="my-tasks-empty-btn">Ouvrir mes bases</a>
</div>"""
def _render_no_workspace() -> str:
return """<div class="my-tasks-empty">
<div class="my-tasks-empty-icon">📋</div>
<h2>My Tasks</h2>
<p>Aucun workspace. Créez un workspace, puis une base de tâches : elle
alimentera ce tableau de bord.</p>
<a href="/workspaces" class="my-tasks-empty-btn">Créer un workspace</a>
</div>"""
# ── Routes ───────────────────────────────────────────────────────────────
@router.get("", response_class=HTMLResponse)
def my_tasks_dashboard(request: Request, view: str = "table", due: str = "all",
sort: str = "due", hide_done: bool = False):
"""My Tasks — toutes vos tâches, tous workspaces confondus."""
user = _get_current_user(request)
user.get("login", "admin") if user else "admin"
if not user or not user.get("id"):
return RedirectResponse("/auth/login?provider=local", status_code=302)
view, due, sort = _validated(view, due, sort)
with get_conn() as conn:
if not _workspace_names(conn, user["id"]):
return _render_shell(request, _render_no_workspace(), app_js=False)
data = _payload(conn, request, user["id"], due=due, sort=sort,
hide_done=hide_done)
# Dès qu'au moins une base alimente le tableau de bord, le rendu est pris
# en charge par le client (3 vues + états vides contextualisés). Sans
# aucune source, le serveur affiche l'invite à convertir une base — le
# fichier JS n'est alors pas chargé.
if data["total"] or data["sources_total"]:
return _render_shell(request, _render_app(data, view))
return _render_shell(request, _render_empty(data), app_js=False)
@router.get("/api")
def my_tasks_api(request: Request, view: str = "table", due: str = "all",
sort: str = "due", hide_done: bool = False):
"""API: tâches normalisées + sources connectées (alimente les 3 vues)."""
user = _require_user(request)
view, due, sort = _validated(view, due, sort)
with get_conn() as conn:
return _payload(conn, request, user["id"], due=due, sort=sort,
hide_done=hide_done)
@router.post("/api/task")
def my_tasks_create(request: Request, body: dict = Body(default={})):
"""Création rapide multi-destinations : la tâche part dans la base choisie.
Elle est **auto-assignée** : sans colonne « Assigné à » renseignée, une
tâche nouvelle n'apparaîtrait pas dans « mes tâches », ce qui serait
illisible pour l'utilisateur qui vient de la créer.
"""
user = _require_user(request)
collection_id = body.get("collection_id")
title = str(body.get("title") or "").strip()
if not collection_id:
raise HTTPException(status_code=400, detail="collection_id is required")
if not title:
raise HTTPException(status_code=400, detail="title is required")
with get_conn() as conn:
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
result = {}
coll = conn.execute(
"SELECT id, task_assignee_prop, task_due_prop FROM collections "
"WHERE id=? AND is_task=1",
(collection_id,),
).fetchone()
if coll is None:
raise HTTPException(status_code=400,
detail="This database does not feed My Tasks")
for col in collections:
pages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
(col["id"],),
props_meta = [
dict(p) for p in conn.execute(
"SELECT id, name, prop_type FROM collection_properties "
"WHERE collection_id=?",
(collection_id,),
).fetchall()
]
tasks = []
for p in pages:
props = json.loads(p["property_values_json"])
tasks.append({
"id": p["id"], "title": p["title"], "icon": p["icon"],
"properties": props,
})
properties: dict = {}
title_prop = next((p for p in props_meta if p["prop_type"] == "title"), None)
if title_prop:
properties[str(title_prop["id"])] = title
if tasks:
result[col["name"]] = tasks
if body.get("due") and coll["task_due_prop"]:
properties[str(coll["task_due_prop"])] = str(body["due"])[:10]
return {"tasks": result, "total": sum(len(t) for t in result.values())}
if coll["task_assignee_prop"]:
member = conn.execute(
"SELECT id, login, full_name, avatar_url, avatar_color "
"FROM users WHERE id=?",
(user["id"],),
).fetchone()
if member:
properties[str(coll["task_assignee_prop"])] = [{
"id": member["id"],
"login": member["login"],
"full_name": member["full_name"],
"avatar_url": member["avatar_url"] or "",
"avatar_color": member["avatar_color"] or "",
}]
apply_auto_properties(props_meta, properties, user, is_create=True)
nxt = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages "
"WHERE collection_id=?",
(collection_id,),
).fetchone()[0]
cur = conn.execute(
"""INSERT INTO collection_pages
(collection_id, title, icon, position, property_values_json)
VALUES (?, ?, '📄', ?, ?)""",
(collection_id, title, nxt, json.dumps(properties)),
)
page_id = cur.lastrowid
conn.commit()
if title_prop:
from app.services.row_pages import sync_row_title_to_page
sync_row_title_to_page(conn, page_id)
conn.commit()
logger.info("my-tasks: task %s created in collection %s", page_id, collection_id)
return {"status": "created", "id": page_id, "collection_id": collection_id,
"title": title}
@router.put("/api/task/{page_id}")
def my_tasks_update(request: Request, page_id: int, body: dict = Body(default={})):
"""Édition en direct du statut / de l'échéance / du titre.
L'écriture passe par le **mapping enregistré de la base source** (et non
par une devinette « première colonne de type X »), puis la tâche est relue
et renvoyée normalisée : le front n'a rien à recalculer.
"""
user = _require_user(request)
field = body.get("field")
if field not in ("status", "due", "title", "assignee"):
raise HTTPException(status_code=400, detail="Unsupported field")
with get_conn() as conn:
row = conn.execute(
"SELECT id, collection_id, title, property_values_json "
"FROM collection_pages WHERE id=?",
(page_id,),
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Task not found")
coll = conn.execute(
"SELECT id, is_task, task_assignee_prop, task_status_prop, task_due_prop "
"FROM collections WHERE id=?",
(row["collection_id"],),
).fetchone()
if coll is None:
raise HTTPException(status_code=404, detail="Source not found")
if not coll["is_task"]:
raise HTTPException(status_code=400,
detail="This database no longer feeds My Tasks")
column = {"status": coll["task_status_prop"],
"due": coll["task_due_prop"],
"assignee": coll["task_assignee_prop"]}.get(field)
if field != "title" and not column:
raise HTTPException(status_code=400,
detail=f"This database has no {field} column")
try:
pv = json.loads(row["property_values_json"] or "{}")
except (json.JSONDecodeError, TypeError):
pv = {}
if not isinstance(pv, dict):
pv = {}
new_title = None
if field == "title":
new_title = str(body.get("value") or "").strip()
title_prop = conn.execute(
"SELECT id FROM collection_properties "
"WHERE collection_id=? AND prop_type='title' ORDER BY position LIMIT 1",
(coll["id"],),
).fetchone()
if title_prop:
pv[str(title_prop["id"])] = new_title
elif field == "assignee":
# La valeur est une liste de personnes : elle est écrite telle
# quelle (booleenner écraserait la colonne).
pv[str(column)] = body.get("value") or []
elif field == "due":
pv[str(column)] = str(body.get("value") or "")[:10]
else:
pv[str(column)] = str(body.get("value") or "")
if new_title is not None:
conn.execute(
"UPDATE collection_pages SET title=?, property_values_json=?, "
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
(new_title, json.dumps(pv), page_id),
)
from app.services.row_pages import sync_row_title_to_page
sync_row_title_to_page(conn, page_id)
else:
conn.execute(
"UPDATE collection_pages SET property_values_json=?, "
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(pv), page_id),
)
conn.commit()
tasks = collect_tasks(conn, user["id"], mine_only=True,
login=user.get("login", ""))
updated = next((t for t in tasks if t["id"] == page_id), None)
if updated is None:
# La tâche vient de cesser d'être « mienne » (désassignée) : le front
# doit la retirer, pas la re-rendre à l'identique.
return {"status": "updated", "task": None, "left_mytasks": True}
return {"status": "updated", "task": updated, "left_mytasks": False}
+50 -19
View File
@@ -8,7 +8,7 @@ import json
import logging
import sqlite3
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, StreamingResponse
from app.auth.session import SessionManager
@@ -16,14 +16,32 @@ from app.db import get_conn
from app.services.automations import fire_event, run_event_sync
logger = logging.getLogger(__name__)
router = APIRouter(tags=["workspace"], prefix="/workspace")
ROLES = ["admin", "editor", "commenter", "viewer"]
def _require_session(request: Request) -> dict:
"""A3/A4 — session obligatoire sur TOUT le router `/workspace`.
Avant, ces routes s'exécutaient sans session : `GET /workspace/collections/
{id}/export/csv` renvoyait le contenu réel d'une collection à un visiteur
anonyme, et les POST créaient des données au nom de l'utilisateur 1.
La seule route publique (`/workspace/public/...`) vit désormais dans
``public_router``, sans dépendance.
"""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
return user
router = APIRouter(tags=["workspace"], prefix="/workspace", dependencies=[Depends(_require_session)])
# Route de partage public : AUCUNE dépendance d'authentification.
public_router = APIRouter(tags=["workspace-public"], prefix="/workspace")
def _current_user(request: Request) -> dict:
s = request.cookies.get("flowdeck_session", "")
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
"""Session obligatoire — plus de repli « admin implicite » (A3/A4)."""
return _require_session(request)
def _require_admin(request: Request) -> dict:
@@ -209,45 +227,58 @@ def record_history(request: Request, page_id: int, body: dict = Body(default={})
@router.get("/favorites")
def list_favorites(request: Request):
user = _current_user(request)
uid = user.get("id", 1)
"""List the caller's favorites.
v7.46.0 — aligne sur le schéma v2.2.0 (``app/db.py``) : la table
``favorites`` n'a plus de colonne ``collection_id`` et ``page_id``
référence ``pages(id)``. L'ancienne requête (jointure ``collection_pages``)
levait ``sqlite3.OperationalError: no such column: f.collection_id`` → 500.
"""
user = _require_session(request)
uid = user["id"]
with get_conn() as conn:
rows = conn.execute(
"""SELECT f.*, cp.title as page_title, c.name as collection_name
"""SELECT f.*, p.title AS page_title
FROM favorites f
LEFT JOIN collection_pages cp ON f.page_id=cp.id
LEFT JOIN collections c ON f.collection_id=c.id
LEFT JOIN pages p ON f.page_id=p.id
WHERE f.user_id=? ORDER BY f.position""",
(uid,),
).fetchall()
return {"favorites": [dict(r) for r in rows]}
@router.post("/favorites")
def add_favorite(request: Request, body: dict = Body(default={})):
user = _current_user(request)
uid = user.get("id", 1)
user = _require_session(request)
uid = user["id"]
page_id = body.get("page_id")
collection_id = body.get("collection_id")
if not page_id:
raise HTTPException(400, "page_id required")
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login, is_admin) VALUES (?,?,1)", (uid, user.get("login", "admin")))
conn.execute(
"INSERT OR IGNORE INTO favorites (user_id, page_id, collection_id) VALUES (?,?,?)",
(uid, page_id, collection_id),
"INSERT OR IGNORE INTO favorites (user_id, page_id) VALUES (?,?)",
(uid, page_id),
)
conn.commit()
try:
run_event_sync(fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid}))
run_event_sync(fire_event("favorite.added", {"page_id": page_id, "user_id": uid}))
except Exception:
logger.exception("add_favorite")
return {"status": "favorited"}
@router.delete("/favorites/{fav_id}")
def remove_favorite(request: Request, fav_id: int):
user = _require_session(request)
with get_conn() as conn:
conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,))
cur = conn.execute(
"DELETE FROM favorites WHERE id=? AND user_id=?", (fav_id, user["id"])
)
conn.commit()
if cur.rowcount == 0:
raise HTTPException(404, "Favorite not found")
return {"status": "removed"}
@@ -714,7 +745,7 @@ def delete_webhook(request: Request, wh_id: int):
# ── Public Sharing ──
@router.get("/public/{collection_id}")
@public_router.get("/public/{collection_id}")
def public_view(request: Request, collection_id: int):
"""Simple public read-only view — no auth required.
+8
View File
@@ -18,6 +18,7 @@ import re
from app.config import settings
from app.db import get_conn
from app.services import agent_memory
from app.services.agent_policies import check_tool, get_policy
from app.services.context_builder import ContextBuilder
from app.services.llm_client import LLMClient
@@ -156,6 +157,11 @@ class AgentEngine:
skills = [s for s in (self._load_skill(i, scope) for i in ids) if s]
system = self._build_system_prompt(agent, skills)
context = self.ctx.build(mentions=mentions, files=files)
# v7.54.0 — mémoire de la conversation (toggle) : le moteur n'envoie
# jamais l'historique, sans elle chaque run repart de zéro.
memory = agent_memory.context_for(conversation_id)
if memory:
context = (context + "\n\n" + memory) if context else memory
if extra_context and extra_context.strip():
context += "\n\n## Document / contexte fourni par l'utilisateur\n" + extra_context.strip()
@@ -304,6 +310,8 @@ class AgentEngine:
self._persist_message(conversation_id, "assistant", final_text,
model=used_model, tokens=self._tokens)
await self._autotitle(conversation_id, objective, final_text)
# v7.54.0 — memorise l'echange (no-op si le toggle memoire est OFF).
agent_memory.remember(conversation_id, objective, final_text)
# v6.4.0: emit agent.run.finished (outbound webhooks only).
await _fire_agent_webhook("agent.run.finished", {
"conversation_id": conversation_id,
+89
View File
@@ -0,0 +1,89 @@
"""Mémoire de l'agent (v7.54.0).
Une ligne résumé **par conversation**, mise à jour à chaque run et ré-injectée
au contexte du run suivant — le moteur n'envoie jamais l'historique des messages
(`AgentEngine.run()` ne construit que system + objectif courant), donc sans
mémoire chaque run repart de zéro.
Toggle : colonne `agent_conversations.memory_enabled` — OFF = aucune lecture ni
écriture (contexte strict du run courant).
ponytail : mémoire par conversation seulement. Si besoin de notes partagées
entre conversations, ajouter des lignes avec ``conversation_id NULL`` +
``workspace_id`` et élargir la lecture dans ``context_for()``.
"""
from __future__ import annotations
import logging
import sqlite3
from app.db import get_conn
logger = logging.getLogger(__name__)
INJECT_BUDGET = 4000 # caractères de mémoire max injectés dans un prompt
NOTE_LINES = 20 # échanges max conservés
NOTE_OBJECTIVE = 180 # troncature de l'objectif
NOTE_ANSWER = 700 # troncature de la réponse
def _enabled(conn: sqlite3.Connection, conversation_id: int) -> bool:
row = conn.execute(
"SELECT memory_enabled FROM agent_conversations WHERE id=?",
(conversation_id,),
).fetchone()
if row is None:
return False
return bool(row["memory_enabled"])
def _one_line(text: str, limit: int) -> str:
return " ".join((text or "").split())[:limit]
def context_for(conversation_id: int) -> str:
"""Bloc « mémoire » à préfixer au contexte, ou ``''`` (toggle OFF / vide)."""
with get_conn() as conn:
if not _enabled(conn, conversation_id):
return ""
row = conn.execute(
"SELECT content FROM agent_memory WHERE conversation_id=?",
(conversation_id,),
).fetchone()
content = ((row["content"] if row else "") or "").strip()
if not content:
return ""
if len(content) > INJECT_BUDGET:
content = "…(début de mémoire tronqué)\n" + content[-INJECT_BUDGET:]
return "## Mémoire de la conversation (échanges précédents)\n" + content
def remember(conversation_id: int, objective: str, final_text: str) -> None:
"""Ajoute un échange à la mémoire — no-op si le toggle est OFF.
Ne doit **jamais** faire échouer un run : toute erreur est journalisée.
"""
note = f"- **{_one_line(objective, NOTE_OBJECTIVE)}** → {_one_line(final_text, NOTE_ANSWER)}"
try:
with get_conn() as conn:
if not _enabled(conn, conversation_id):
return
row = conn.execute(
"SELECT content FROM agent_memory WHERE conversation_id=?",
(conversation_id,),
).fetchone()
lines = [ln for ln in ((row["content"] if row else "") or "").split("\n") if ln.strip()]
lines.append(note)
if len(lines) > NOTE_LINES:
lines = lines[-NOTE_LINES:]
conn.execute(
"INSERT INTO agent_memory (conversation_id, content, updated_at) "
"VALUES (?, ?, CURRENT_TIMESTAMP) "
"ON CONFLICT(conversation_id) DO UPDATE SET "
"content=excluded.content, updated_at=CURRENT_TIMESTAMP",
(conversation_id, "\n".join(lines)),
)
conn.commit()
except Exception: # noqa: BLE001 — la mémoire ne casse jamais un run
logger.exception("Agent memory save failed for conversation #%s", conversation_id)
+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"
+28
View File
@@ -86,6 +86,34 @@ BUILTIN_TEMPLATES: dict[str, dict] = {
_b("bulleted_list"),
],
},
"design_system": {
"name": "Design System",
"icon": "🎨",
"description": "Tokens, composants et grille — canevas de référence UI.",
"blocks": [
_b("heading_1", "Design System"),
_b("callout",
"Tokens : couleur, espacement, typographie, rayon, ombres "
"(static/css/design-tokens.css).",
icon="🎨"),
_b("table_of_contents"),
_b("heading_2", "Composants"),
_b("toggle", "Boutons & actions", expanded=False,
children=[_b("paragraph", "Primaire · secondaire · danger — états idle, hover, focus, disabled.")]),
_b("toggle", "Cartes & panneaux", expanded=False,
children=[_b("paragraph", "Élévation, rayon, bordure, padding, zones de clic.")]),
_b("toggle", "Formulaires", expanded=False,
children=[_b("paragraph", "Champs, labels, hints, erreurs, focus visible.")]),
_b("heading_2", "Grille & espacement"),
_b("bulleted_list", "Base 4 px — pas d'espacement hors échelle."),
_b("bulleted_list", "Colonnes : 12 / 8 / 4 selon le point de rupture."),
_b("heading_2", "Revue UI"),
_b("to_do", "Contraste AA vérifié", checked=False),
_b("to_do", "États hover / focus / disabled", checked=False),
_b("to_do", "Responsive mobile", checked=False),
_b("quote", "Une décision de design vaut mieux qu'une justification après coup."),
],
},
}
+6
View File
@@ -278,6 +278,12 @@ def _row_date(values: dict, prop_id: str, prop_name: str) -> str:
return str(raw or "")
# Public aliases — read-only helpers reused by the router layer (sidebar
# "Meeting" tab needs the date property + value of a collection row).
date_prop_id = _date_prop_id
row_date = _row_date
def _to_epoch(value: str | None) -> float:
if not value:
return 0.0
+222
View File
@@ -0,0 +1,222 @@
"""FlowDeck — cohérence collections ↔ workspace.
Une database (`collections`) peut être :
* **de workspace** — créée via `/db/api`, rattachée par `workspace_id` seul ;
* **de page** — page convertie en base (`collections.parent_page_id` +
`pages.collection_id`) ou base inline insérée dans une page.
Ce module centralise les deux sens de la relation :
* **lecture** — quelles collections sont encore *vivantes* dans un workspace
(leur page hôte n'est pas à la corbeille / n'a pas été supprimée) et
quelles lignes sont encore vivantes (leur page contenu n'est pas à la
corbeille). Sans ce filtre, ``/my-tasks`` continue d'afficher les tâches
d'un workspace dont tous les documents ont été supprimés ;
* **écriture** — la suppression en cascade d'une collection. L'ordre des
écritures est dicté par le schéma SQLite :
- ``pages.collection_id`` référence ``collections(id)`` en ``NO ACTION`` :
la page hôte doit être détachée **avant** la suppression ;
- ``collection_data_sources.source_collection_id`` référence également en
``NO ACTION`` : les vues liées qui pointent vers la collection doivent
être détachées ;
- ``collection_pages`` (lignes), ``collection_properties``,
``collection_views``, ``collection_permissions`` et les pages contenu
(``pages.collection_row_id``) partent en ``CASCADE``.
Sans ce détachement, ``DELETE FROM collections`` lève un ``IntegrityError``
dès qu'une page hôte (ou une vue liée) existe encore.
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
# ── Lecture ──────────────────────────────────────────────────────────────
#: SQL du workspace **effectif** d'une collection.
#:
#: ``collections.workspace_id`` est renseigné pour les databases créées via
#: ``/db/api``, mais reste **NULL** pour une base portée par une page (page
#: convertie en base, base inline insérée dans un document) : son workspace
#: est alors celui de la page hôte. Sans ce repli, la requête ``c.workspace_id
#: = ?`` de `live_collection_ids` ne trouve jamais ces bases — et un JOIN
#: ``workspaces`` les exclut définitivement.
EFFECTIVE_WORKSPACE_SQL = (
"COALESCE({a}.workspace_id,"
" (SELECT p.workspace_id FROM pages p WHERE p.id = {a}.parent_page_id))"
)
def effective_workspace_sql(alias: str = "c") -> str:
"""Workspace effectif d'une collection (colonne ou ``NULL`` si orpheline)."""
return EFFECTIVE_WORKSPACE_SQL.format(a=alias)
def user_workspace_ids(conn, user_id: int) -> list[int]:
"""IDs des workspaces accessibles à l'utilisateur.
Propriétaire **ou** membre : c'est le même périmètre que la recherche
globale (`app/services/search._scope_where`), à la différence près qu'on
n'inclut pas les éléments sans workspace — une base non rattachable
n'appartient à personne et ne doit donc apparaître chez personne.
"""
rows = conn.execute(
"SELECT id FROM workspaces WHERE owner_id = ?"
" UNION SELECT workspace_id FROM workspace_members WHERE user_id = ?",
(user_id, user_id),
).fetchall()
return [r[0] for r in rows]
def live_collection_ids(conn, workspace_id: int) -> list[int]:
"""IDs des collections rattachées au workspace qui sont encore vivantes.
Une collection est exclue dès que sa page hôte a disparu :
* ``parent_page_id`` renseigné mais page absente ou à la corbeille ;
* une page ``content_format='collection'`` qui pointe encore vers elle
mais est à la corbeille.
Les collections sans page hôte (databases de workspace créées via
``/db/api``) restent toujours visibles.
Le rattachement passe par le workspace **effectif** : une base créée depuis
un document a ``workspace_id`` NULL et n'est repérée que via sa page hôte.
"""
rows = conn.execute(
f"""
SELECT c.id
FROM collections c
WHERE {effective_workspace_sql("c")} = ?
AND (c.parent_page_id IS NULL
OR EXISTS (SELECT 1 FROM pages p
WHERE p.id = c.parent_page_id AND p.deleted_at IS NULL))
AND NOT EXISTS (SELECT 1 FROM pages p
WHERE p.collection_id = c.id AND p.deleted_at IS NOT NULL)
ORDER BY c.name
""",
(workspace_id,),
).fetchall()
return [r["id"] for r in rows]
def live_row_ids(conn, collection_id: int) -> list[int]:
"""IDs des lignes racines d'une collection dont la page contenu vit.
La page contenu d'une ligne est créée paresseusement (``ensure_row_page``)
: son absence ne dit rien, mais son passage à la corbeille signifie que
l'utilisateur a supprimé le document → la ligne sort de My Tasks.
"""
rows = conn.execute(
"""
SELECT cp.id
FROM collection_pages cp
WHERE cp.collection_id = ?
AND cp.parent_id IS NULL
AND NOT EXISTS (SELECT 1 FROM pages p
WHERE p.collection_row_id = cp.id AND p.deleted_at IS NOT NULL)
ORDER BY cp.position
""",
(collection_id,),
).fetchall()
return [r["id"] for r in rows]
def live_document_count(conn, workspace_id: int) -> int:
"""Nombre de documents visibles du workspace (mêmes règles que l'arbre
de la sidebar : racines, non supprimés, hors pages contenu de ligne)."""
return conn.execute(
"SELECT COUNT(*) FROM pages "
"WHERE workspace_id = ? AND parent_id IS NULL "
"AND deleted_at IS NULL AND collection_row_id IS NULL",
(workspace_id,),
).fetchone()[0]
def collections_hosted_by_pages(conn, page_ids) -> list[int]:
"""IDs des collections dont l'une de ces pages est la page hôte.
Les deux relations sont couvertes : ``collections.parent_page_id`` (page
convertie en base, base inline) et ``pages.collection_id`` (page hôte).
Les pages contenu de ligne (``collection_row_id`` renseigné) n'hébergent
aucune collection et ne remontent donc rien.
À appeler **avant** de supprimer les pages si l'on veut aussi capturer la
relation ``pages.collection_id``.
"""
ids = [int(p) for p in page_ids if p is not None]
if not ids:
return []
qs = ",".join("?" * len(ids))
rows = conn.execute(
f"""
SELECT id FROM collections WHERE parent_page_id IN ({qs})
UNION
SELECT collection_id FROM pages
WHERE id IN ({qs}) AND collection_id IS NOT NULL
""",
[*ids, *ids],
).fetchall()
return [r["id"] for r in rows]
def collections_hosted_by_page(conn, page_id: int) -> list[int]:
"""``collections_hosted_by_pages`` pour une seule page."""
return collections_hosted_by_pages(conn, [page_id])
def workspace_collection_ids(conn, workspace_id: int) -> list[int]:
"""IDs de **toutes** les collections du workspace (sans filtre de
vivacité) — utilisé lors de la suppression du workspace."""
rows = conn.execute(
"SELECT id FROM collections WHERE workspace_id = ?", (workspace_id,)
).fetchall()
return [r["id"] for r in rows]
# ── Écriture ─────────────────────────────────────────────────────────────
def delete_collections(conn, collection_ids) -> int:
"""Supprime définitivement des collections et tout ce qu'elles portent.
Détache d'abord les références ``NO ACTION`` (pages hôtes, sources des
vues liées) puis supprime : le reste part en ``CASCADE``.
Ne fait **pas** de ``commit`` — l'appelant garde la main sur la
transaction. Retourne le nombre de collections supprimées.
"""
ids = [int(i) for i in collection_ids if i is not None]
if not ids:
return 0
qs = ",".join("?" * len(ids))
# 1. Pages hôtes : `pages.collection_id` est en NO ACTION. Une page dont le
# contenu EST la base redevient une page blocs normale.
conn.execute(
f"""UPDATE pages
SET collection_id = NULL,
content_format = CASE WHEN content_format = 'collection'
THEN 'blocks' ELSE content_format END
WHERE collection_id IN ({qs})""",
ids,
)
# 2. Vues liées : `source_collection_id` est en NO ACTION également. La vue
# liée perd sa source (elle n'a plus rien à afficher) mais survit.
conn.execute(
f"DELETE FROM collection_data_sources WHERE source_collection_id IN ({qs})",
ids,
)
# 3. Suppression : lignes, propriétés, vues, permissions et pages contenu
# suivent en CASCADE.
cur = conn.execute(f"DELETE FROM collections WHERE id IN ({qs})", ids)
return cur.rowcount or 0
def delete_collections_hosted_by_pages(conn, page_ids) -> int:
"""Supprime les databases portées par des pages définitivement supprimées
(corbeille purgée, suppression définitive, workspace effacé)."""
return delete_collections(conn, collections_hosted_by_pages(conn, page_ids))
+336
View File
@@ -0,0 +1,336 @@
"""Connecteurs de l'agent (v7.55.0) — socle : catalogue, statut, fetch gardé SSRF.
5 connecteurs **natifs** (gitea, github, web, google, ms365) servis à la volée,
plus des connecteurs **personnels** persistés (URL + clé) en base avec un
``kind`` : ``custom`` | ``discord`` (auth « Bot ») | ``telegram`` (jeton dans
l'URL via ``{secret}``) | ``mcp`` (serveur Model Context Protocol, cache
d'outils dans ``tools_json``).
ponytail : un seul fichier (pas de package ``connectors/`` ni de classe par
provider) — 3 probes dans un dict + 1 fetch. Les adapters lourds des phases 6-7
(Google, M365, Discord/Telegram, MCP) arriveront avec leur propre module.
"""
from __future__ import annotations
import json
import logging
from app.config import settings
from app.db import get_conn
from app.services import oauth_connectors
from app.services.sso_provisioning import decrypt_secret, encrypt_secret
logger = logging.getLogger(__name__)
NATIVE_KINDS = ("gitea", "github", "web", "google", "ms365")
CUSTOM_KINDS = ("custom", "discord", "telegram", "mcp")
AUTH_SCHEMES = ("bearer", "bot", "none")
# Presets proposés par le formulaire du menu + (le jeton reste toujours saisi
# par l'utilisateur — jamais codé en dur ici).
PRESETS = {
"discord": {"url": "https://discord.com/api/v10", "auth": "bot",
"hint": "Bot Discord Developers → Token"},
"telegram": {"url": "https://api.telegram.org/bot{secret}", "auth": "none",
"hint": "BotFather → Bot Token (jeton dans l'URL)"},
"mcp": {"url": "https://", "auth": "bearer",
"hint": "URL du endpoint MCP (streamable HTTP)"},
}
OAUTH_KINDS = oauth_connectors.OAUTH_KINDS
FETCH_LIMIT = 20000 # caractères max renvoyés au LLM (ponytail : borne fixe)
# ── Natifs (config, sans réseau) ─────────────────────────────────────────────
def native_state(kind: str, user_id: int | None = None) -> tuple[str, str]:
"""(status, detail) d'un connecteur natif — dérivé de la config, sans réseau."""
if kind in OAUTH_KINDS:
try:
oauth_connectors._client(kind) # lève si client_id/secret absents
except ValueError as exc:
return ("missing", str(exc))
tok = oauth_connectors.tokens(kind, user_id)
if tok.get("access_token"):
scope = (tok.get("scope") or "").split()
return ("ok", f"connecté · {len(scope)} scope(s)")
return ("missing", "non connecté — lancer la connexion OAuth")
if kind == "gitea":
ok = bool(settings.gitea_url) and settings.gitea_token not in ("", "change-me")
return ("ok", settings.gitea_url if ok else "GITEA_TOKEN non configuré")
if kind == "github":
if settings.github_token:
return ("ok", "PAT configuré (30 req/min)")
return ("missing", "aucun GITHUB_TOKEN (10 req/min anonyme)")
return ("ok", f"recherche web via {settings.web_search_provider or 'duckduckgo'}")
def _row(r) -> dict:
"""Ligne `agent_connectors` → dict API (le jeton n'y figure jamais)."""
try:
tools = json.loads(r["tools_json"] or "[]") if "tools_json" in r.keys() else []
except (ValueError, TypeError):
tools = []
return {
"id": r["id"], "builtin": False, "kind": r["kind"], "name": r["name"],
"url": r["url"], "auth": r["auth"], "enabled": bool(r["enabled"]),
"status": r["status"], "detail": r["detail"] or "",
"has_secret": bool(r["secret_encrypted"]), "oauth": False,
"tools_count": len(tools),
}
def list_connectors(user_id: int | None = None) -> list[dict]:
"""Catalogue : natifs (toujours présents) + connecteurs personnels."""
out: list[dict] = []
for kind in NATIVE_KINDS:
status, detail = native_state(kind, user_id)
out.append({
"id": None, "builtin": True, "kind": kind,
"name": (oauth_connectors.PROVIDERS[kind]["name"] if kind in OAUTH_KINDS
else kind.capitalize()),
"url": "", "enabled": True, "status": status, "detail": detail,
"has_secret": False, "oauth": kind in OAUTH_KINDS,
})
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name, url, secret_encrypted, enabled, status, detail, "
"kind, auth, tools_json FROM agent_connectors ORDER BY id"
).fetchall()
return out + [_row(r) for r in rows]
def get(connector_id: int) -> dict | None:
with get_conn() as conn:
r = conn.execute(
"SELECT id, name, url, secret_encrypted, enabled, status, detail, "
"kind, auth, tools_json FROM agent_connectors WHERE id=?",
(connector_id,),
).fetchone()
return _row(r) if r else None
# ── CRUD (personnels) ────────────────────────────────────────────────────────
def create_connector(name: str, url: str, secret: str = "", *,
kind: str = "custom", auth: str = "bearer") -> dict:
"""Valide l'URL (garde SSRF) puis stocke la clé **chiffrée** (Fernet)."""
from app.services.importers.url_fetch import _validate_url
name = (name or "").strip()
if not name:
raise ValueError("name est requis")
if kind not in CUSTOM_KINDS:
raise ValueError(f"kind invalide: {kind} (attendu {', '.join(CUSTOM_KINDS)})")
if auth not in AUTH_SCHEMES:
raise ValueError(f"auth invalide: {auth} (attendu {', '.join(AUTH_SCHEMES)})")
url = (url or "").strip()
if "{secret}" in url and not (secret or "").strip():
raise ValueError("clé requise : l'URL contient {secret}")
url = _validate_url(url) # lève ValueError si hôte interne
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO agent_connectors (name, url, secret_encrypted, status, "
"detail, kind, auth) VALUES (?,?,?,?,?,?,?)",
(name, url, encrypt_secret(secret or ""), "unknown", "", kind, auth),
)
conn.commit()
cid = cur.lastrowid
return get(cid)
def update_connector(connector_id: int, *, name=None, enabled=None, secret=None) -> dict | None:
row = get(connector_id)
if row is None:
return None
sets, params = [], []
if name is not None:
name = str(name).strip()
if not name:
raise ValueError("name est requis")
sets.append("name=?")
params.append(name)
if enabled is not None:
sets.append("enabled=?")
params.append(1 if enabled else 0)
if secret is not None:
sets.append("secret_encrypted=?")
params.append(encrypt_secret(str(secret)))
if sets:
with get_conn() as conn:
params.append(connector_id)
conn.execute(f"UPDATE agent_connectors SET {', '.join(sets)} WHERE id=?", params)
conn.commit()
return get(connector_id)
def delete_connector(connector_id: int) -> bool:
with get_conn() as conn:
cur = conn.execute("DELETE FROM agent_connectors WHERE id=?", (connector_id,))
conn.commit()
return cur.rowcount > 0
# ── Réseau (toujours gardé SSRF) ─────────────────────────────────────────────
def _headers(connector_id: int | None = None) -> dict:
"""En-têtes d'appel : la clé n'est lue (et déchiffrée) qu'ici, jamais renvoyée.
``auth`` = bearer (défaut) | bot (Discord, jeton préfixé) | none (jeton ailleurs).
"""
headers = {"User-Agent": "FlowDeck-Connectors/1.0"}
secret, auth = "", "bearer"
if connector_id:
with get_conn() as conn:
r = conn.execute(
"SELECT secret_encrypted, auth FROM agent_connectors WHERE id=?",
(connector_id,),
).fetchone()
if r:
secret = decrypt_secret(r["secret_encrypted"] or "")
auth = r["auth"] or "bearer"
if secret and auth == "bot":
headers["Authorization"] = f"Bot {secret}"
elif secret and auth != "none":
headers["Authorization"] = f"Bearer {secret}"
headers["X-Api-Key"] = secret
return headers
def _with_secret(url: str, connector_id: int) -> str:
"""Substitue ``{secret}`` (Telegram : le jeton vit dans l'URL, jamais stocké clair)."""
if "{secret}" not in url:
return url
with get_conn() as conn:
r = conn.execute(
"SELECT secret_encrypted FROM agent_connectors WHERE id=?",
(connector_id,),
).fetchone()
secret = decrypt_secret((r["secret_encrypted"] if r else "") or "")
if not secret:
raise ValueError("clé manquante pour une URL contenant {secret}")
return url.replace("{secret}", secret)
async def _get(url: str, headers: dict | None = None) -> tuple[int, str]:
"""GET gardé SSRF (validation + re-vérification après redirection).
Point d'injection des tests : on monkeypatche ``connectors._get``.
"""
from app.services.http_client import shared_client
from app.services.importers.url_fetch import _is_public_host, _validate_url
safe = _validate_url(url)
async with shared_client(timeout=10, follow_redirects=True, headers=headers or {}) as client:
resp = await client.get(safe)
if resp.url.host and not _is_public_host(resp.url.host):
raise ValueError("Redirection vers un hôte non autorisé")
return resp.status_code, (resp.text or "")[:FETCH_LIMIT]
def _resolve(connector: str) -> tuple[str, str | int]:
"""« 3 », « Ma clé API » ou « gitea » → (kind, id_custom|kind)."""
token = str(connector or "").strip()
if not token:
raise ValueError("connector est requis")
if token.isdigit():
row = get(int(token))
if row is None:
raise ValueError(f"Connecteur inconnu: {token}")
return (row["kind"], row["id"])
low = token.lower()
if low in NATIVE_KINDS:
return (low, low)
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM agent_connectors WHERE lower(name)=?", (low,)
).fetchone()
if row:
return (get(row["id"])["kind"], row["id"])
raise ValueError(f"Connecteur inconnu: {token}")
async def probe(connector: str, user_id: int | None = None) -> dict:
"""Teste un connecteur et **persiste** le résultat (personnel uniquement)."""
kind, ref = _resolve(connector)
try:
if kind == "mcp":
from app.services import mcp_client
tools = await mcp_client.initialize_and_list(int(ref))
status, detail = "ok", f"MCP · {len(tools)} outil(s)"
elif kind in OAUTH_KINDS:
res = await oauth_connectors.api_get(kind, user_id,
oauth_connectors.PROVIDERS[kind]["probe_path"])
status, detail = res["status"], res["text"][:200]
elif kind == "gitea":
code, _ = await _get(f"{settings.gitea_url.rstrip('/')}/api/v1/version",
{"Authorization": f"token {settings.gitea_token}"})
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
elif kind == "github":
code, _ = await _get("https://api.github.com/rate_limit", _gh_headers())
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
elif kind == "web":
status, detail = "ok", native_state("web")[1]
else:
row = get(int(ref))
url = _with_secret(row["url"], int(ref))
code, _ = await _get(url, _headers(int(ref)))
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
except Exception as exc: # noqa: BLE001 — un probe ne casse jamais l'UI
logger.info("Connector probe failed (%s): %s", connector, exc)
status, detail = "error", str(exc)[:200]
if kind not in NATIVE_KINDS:
with get_conn() as conn:
conn.execute(
"UPDATE agent_connectors SET status=?, detail=? WHERE id=?",
(status, detail, int(ref)),
)
conn.commit()
return {"connector": connector, "status": status, "detail": detail}
def _gh_headers() -> dict:
headers = {"User-Agent": "FlowDeck-Connectors/1.0",
"Accept": "application/vnd.github+json"}
if settings.github_token:
headers["Authorization"] = f"Bearer {settings.github_token}"
return headers
async def connector_fetch(connector: str, path: str = "", query: str = "",
user_id: int | None = None) -> dict:
"""Lit un connecteur pour l'LLM : natif = API, web = recherche, perso = GET."""
kind, ref = _resolve(connector)
path = (path or "").strip()
if kind in OAUTH_KINDS:
return await oauth_connectors.api_get(kind, user_id, path)
if kind == "mcp":
from app.services import mcp_client
return {"status": "ok", "text": mcp_client.tools_text(int(ref))}
if kind == "gitea":
base = settings.gitea_url.rstrip("/")
url = f"{base}/{path.lstrip('/')}" if path else f"{base}/api/v1/version"
code, text = await _get(url, {"Authorization": f"token {settings.gitea_token}"})
elif kind == "github":
url = "https://api.github.com/" + path.lstrip("/") if path else "https://api.github.com/rate_limit"
code, text = await _get(url, _gh_headers())
elif kind == "web":
from app.services.web_search import search_web
if not (query or "").strip():
return {"status": "error", "text": "query est requis pour le connecteur web"}
results, provider = await search_web(query.strip(), 5)
text = "\n".join(f"- {r.get('title', '')} — {r.get('url', '')}\n {r.get('snippet', '')}"
for r in results) or "Aucun résultat."
return {"status": "ok", "text": f"provider: {provider}\n{text}"[:FETCH_LIMIT]}
else:
row = get(int(ref))
if row is None:
return {"status": "error", "text": "Connecteur supprimé"}
if not row["enabled"]:
return {"status": "error", "text": "Connecteur désactivé"}
url = _with_secret(row["url"], int(ref)).rstrip("/")
if path:
url += "/" + path.lstrip("/")
code, text = await _get(url, _headers(int(ref)))
if code >= 400:
return {"status": "error", "text": f"HTTP {code} — {text[:400]}"}
return {"status": "ok", "text": text[:FETCH_LIMIT]}
+34 -1
View File
@@ -118,7 +118,7 @@ class ContextBuilder:
return "\n".join(lines)
def _mentions_context(self, mentions: list[str]) -> str:
"""Resolve @document:x / @collection:x / @page:y / @repo:o/r mentions.
"""Resolve @document:x / @collection:x / @page:y / @folder:d / @repo:o/r mentions.
Mentions bring the *actual content* of the referenced object into the
context so the LLM can summarise / rewrite / analyse it directly without
@@ -135,6 +135,8 @@ class ContextBuilder:
elif m.startswith("page:"):
pid = m.split(":", 1)[1]
lines.append(self._single_page(pid))
elif m.startswith("folder:"):
lines.append(self._single_folder(m.split(":", 1)[1]))
elif m.startswith("repo:"):
lines.append(f"- @repo: {m.split(':', 1)[1]} (Gitea issues available via read_gitea_issues)")
elif m == "ws":
@@ -209,6 +211,37 @@ class ContextBuilder:
return f"{head}:\n{body[:9000]}"
return head
def _single_folder(self, fid: str) -> str:
"""Folder (répertoire) mention : titre du dossier + ses documents directs.
ponytail : enfants directs seulement, budget ~12k caractères —
ajouter une profondeur récursive si les arbres profonds deviennent réels.
"""
with get_conn() as conn:
row = conn.execute(
"SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL",
(fid,),
).fetchone()
if not row:
return f"- dossier #{fid}: introuvable"
kids = conn.execute(
"SELECT id FROM pages WHERE parent_id=? AND deleted_at IS NULL "
"ORDER BY sort_order ASC, created_at DESC",
(fid,),
).fetchall()
title = row["title"] or "Sans titre"
head = f"- dossier #{row['id']} **{title}** ({len(kids)} élément(s))"
out = [head]
used = len(head)
for k in kids:
part = self._single_document(str(k["id"]))
if used + len(part) + 1 > 12000:
out.append("- … (contenu du dossier tronqué)")
break
used += len(part) + 1
out.append(part)
return "\n".join(out)
def _single_collection(self, cid: str) -> str:
with get_conn() as conn:
row = conn.execute("SELECT id, name, icon, schema_json FROM collections WHERE id=?", (cid,)).fetchone()
+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 = (
+181
View File
@@ -0,0 +1,181 @@
"""Client MCP (Model Context Protocol) — streamable HTTP, v7.57.0.
JSON-RPC 2.0 : ``initialize`` → ``notifications/initialized`` → ``tools/list``
→ ``tools/call``. Les outils sont **cachés** dans
``agent_connectors.tools_json`` (kind ``mcp``) puis exposés au LLM par
``ToolRegistry`` sous des noms ``mcp_<serveur>_<outil>`` — outils dynamiques,
sans état en mémoire.
ponytail : **1 seul seam** ``_rpc()`` (monkeypatché en test) ; on lit soit la
réponse JSON directe, soit la première ligne ``data:`` d'une
``text/event-stream`` — pas de client SSE à l'état (les méthodes utilisées
répondent en JSON chez la grande majorité des serveurs).
"""
from __future__ import annotations
import json
import logging
import re
from app.db import get_conn
from app.services.connectors import _headers, _with_secret, get
logger = logging.getLogger(__name__)
# Version la plus répandue côté serveurs (un serveur plus récent rétrograde).
PROTOCOL_VERSION = "2024-11-05"
CLIENT_INFO = {"name": "FlowDeck", "version": "7"}
def tool_name(server_name: str, tool_name: str) -> str:
"""Nom LLM stable : ``mcp_<serveur>_<outil>`` (slug minuscules/underscores)."""
def slug(text: str) -> str:
return re.sub(r"[^a-z0-9]+", "_", str(text).lower()).strip("_")
joined = f"mcp_{slug(server_name)}_{slug(tool_name)}"
return re.sub(r"_{2,}", "_", joined)
def parse_body(content_type: str, body: str, status: int) -> dict:
"""Corps MCP → dict JSON (JSON direct ou événement ``data:`` d'un flux SSE)."""
text = body or ""
if "text/event-stream" in (content_type or ""):
for line in text.splitlines():
if line.startswith("data:"):
text = line[5:].strip()
break
try:
data = json.loads(text)
except ValueError as exc:
raise ValueError(f"Réponse MCP illisible (HTTP {status})") from exc
if isinstance(data, dict) and data.get("error"):
err = data["error"] if isinstance(data["error"], dict) else {}
raise ValueError(f"MCP {err.get('code', '?')} : {err.get('message', 'erreur')}")
return data if isinstance(data, dict) else {}
async def _rpc(url: str, payload: dict, headers: dict | None = None) -> dict:
"""POST JSON-RPC gardé SSRF → réponse décodée. Point d'injection des tests."""
from app.services.http_client import shared_client
from app.services.importers.url_fetch import _validate_url
safe = _validate_url(url)
hdrs = {"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
**(headers or {})}
async with shared_client(timeout=15, headers=hdrs) as client:
resp = await client.post(safe, json=payload)
return parse_body(resp.headers.get("content-type", ""), resp.text or "",
resp.status_code)
async def _notify(url: str, payload: dict, headers: dict | None = None) -> None:
"""Notification JSON-RPC (202 sans corps) — les erreurs sont ignorées."""
try:
await _rpc(url, payload, headers)
except Exception as exc: # noqa: BLE001
logger.debug("MCP notification ignorée: %s", exc)
def _server(connector_id: int) -> dict:
row = get(connector_id)
if row is None:
raise ValueError("Serveur MCP introuvable")
if row["kind"] != "mcp":
raise ValueError("Ce connecteur n'est pas un serveur MCP")
if not row["enabled"]:
raise ValueError("Serveur MCP désactivé")
return {"id": row["id"], "name": row["name"], "url": row["url"],
"headers": _headers(connector_id)}
def _normalize(server: dict, raw: list) -> list[dict]:
out = []
for t in raw if isinstance(raw, list) else []:
if not isinstance(t, dict) or not t.get("name"):
continue
params = t.get("inputSchema")
if not isinstance(params, dict):
params = {"type": "object", "properties": {}}
out.append({
"name": tool_name(server["name"], t["name"]),
"original": str(t["name"]),
"description": str(t.get("description") or "Outil MCP"),
"parameters": params,
})
return out
def _save_tools(connector_id: int, tools: list[dict]) -> None:
with get_conn() as conn:
conn.execute("UPDATE agent_connectors SET tools_json=? WHERE id=?",
(json.dumps(tools), connector_id))
conn.commit()
async def initialize_and_list(connector_id: int) -> list[dict]:
"""Handshake + ``tools/list`` → met à jour le cache de la base."""
srv = _server(connector_id)
url = _with_secret(srv["url"], connector_id)
await _rpc(url, {
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {},
"clientInfo": CLIENT_INFO},
}, srv["headers"])
await _notify(url, {"jsonrpc": "2.0", "method": "notifications/initialized"},
srv["headers"])
data = await _rpc(url, {"jsonrpc": "2.0", "id": 2, "method": "tools/list",
"params": {}}, srv["headers"])
result = data.get("result") if isinstance(data.get("result"), dict) else {}
tools = _normalize(srv, result.get("tools") or [])
_save_tools(connector_id, tools)
return tools
def cached_tools() -> list[dict]:
"""Outils MCP des serveurs **activés** (cache en base) — lu par ToolRegistry."""
with get_conn() as conn:
rows = conn.execute(
"SELECT id, tools_json FROM agent_connectors WHERE kind='mcp' AND enabled=1"
).fetchall()
out: list[dict] = []
for r in rows:
try:
tools = json.loads(r["tools_json"] or "[]")
except (ValueError, TypeError):
tools = []
for t in tools if isinstance(tools, list) else []:
if isinstance(t, dict) and t.get("name"):
out.append({**t, "connector_id": r["id"]})
return out
def tools_text(connector_id: int) -> str:
"""Liste lisible des outils cachés (utilisée par ``connector_fetch``)."""
tools = [t for t in cached_tools() if t["connector_id"] == connector_id]
if not tools:
return "Aucun outil en cache — lancez « Tester le connecteur » (tools/list)."
return "\n".join(f"- {t['name']} : {t.get('description', '')}" for t in tools)
def _content_text(result: dict) -> str:
parts = []
for item in result.get("content") or []:
if isinstance(item, dict) and item.get("type") == "text":
parts.append(str(item.get("text") or ""))
else:
parts.append(json.dumps(item, ensure_ascii=False)[:2000])
return "\n".join(p for p in parts if p) or json.dumps(result, ensure_ascii=False)[:4000]
async def call_tool(connector_id: int, tool: str, arguments: dict | None = None) -> dict:
"""``tools/call`` → {status, text} (isError → error, borné à 20 000 car.)."""
srv = _server(connector_id)
url = _with_secret(srv["url"], connector_id)
data = await _rpc(url, {
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": {"name": tool, "arguments": arguments or {}},
}, srv["headers"])
result = data.get("result") if isinstance(data.get("result"), dict) else {}
text = _content_text(result)[:20000]
return {"status": "error" if result.get("isError") else "ok", "text": text}
+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}
+255
View File
@@ -0,0 +1,255 @@
"""Connecteurs OAuth Google / Microsoft 365 (v7.56.0 — phase 6).
Flow : ``begin()`` (URL d'autorisation PKCE S256 + state) → callback
``complete()`` (échange du code) → tokens chiffrés Fernet → ``api_get()`` avec
refresh automatique.
Réutilise : `_encrypt_tokens` / `_decrypt_tokens` (`calendar_sync`, déjà Fernet)
et `shared_client` (A42). Les scopes sont **figés en lecture seule**.
ponytail : 2 providers décrits par 1 dict (pas de classe par provider) ; les
scopes au choix et un écran de connexion dédié arriveront si le besoin se
présente — l'état vit dans le catalogue du menu +.
"""
from __future__ import annotations
import base64
import hashlib
import logging
import secrets
import time
from urllib.parse import urlencode
from app.config import settings
from app.db import get_conn
from app.services.calendar_sync import _decrypt_tokens, _encrypt_tokens
logger = logging.getLogger(__name__)
PROVIDERS: dict[str, dict] = {
"google": {
"name": "Google (Drive · Gmail · Calendar)",
"authorize": "https://accounts.google.com/o/oauth2/v2/auth",
"token": "https://oauth2.googleapis.com/token",
"api": "https://www.googleapis.com",
"scopes": [
"openid", "email", "profile",
"https://www.googleapis.com/auth/drive.readonly",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/calendar.readonly",
],
"extra": {"access_type": "offline", "include_granted_scopes": "true"},
"probe_path": "/oauth2/v3/userinfo",
},
"ms365": {
"name": "Microsoft 365 (Outlook · OneDrive)",
"authorize": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
"token": "https://login.microsoftonline.com/common/oauth2/v2.0/token",
"api": "https://graph.microsoft.com/v1.0",
"scopes": [
"openid", "email", "profile", "offline_access",
"User.Read", "Files.Read", "Mail.Read", "Calendars.Read",
"ChannelMessage.Read.All", # Teams (phase 7) — consentement admin
],
"extra": {"response_mode": "query", "prompt": "consent"},
"probe_path": "/me",
},
}
OAUTH_KINDS = tuple(PROVIDERS)
# l'access token est renouvelé 60 s avant expiration
_REFRESH_SKEW = 60
# ── Config client ───────────────────────────────────────────────────────────
def _client(kind: str) -> tuple[str, str, str]:
"""(client_id, client_secret, redirect_uri) — lève si non configuré."""
if kind not in PROVIDERS:
raise ValueError(f"Connecteur OAuth inconnu: {kind}")
if kind == "google":
cid, secret = settings.google_client_id, settings.google_client_secret
else:
cid, secret = settings.ms_client_id, settings.ms_client_secret
if not cid or not secret:
raise ValueError(
f"Connecteur {kind} non configuré (GOOGLE_CLIENT_ID/SECRET ou MS_CLIENT_ID/SECRET)"
)
redirect = f"{settings.app_base_url.rstrip('/')}/api/agent/connectors/oauth/{kind}/callback"
return cid, secret, redirect
def callback_path(kind: str) -> str:
return f"/api/agent/connectors/oauth/{kind}/callback"
def _pkce_pair() -> tuple[str, str]:
verifier = secrets.token_urlsafe(48)
digest = hashlib.sha256(verifier.encode("ascii")).digest()
return verifier, base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
# ── Flow ────────────────────────────────────────────────────────────────────
def begin(kind: str) -> dict:
"""URL d'autorisation + state + code_verifier (le route met les cookies)."""
cid, _secret, redirect = _client(kind)
verifier, challenge = _pkce_pair()
state = secrets.token_urlsafe(24)
params = {
"client_id": cid,
"redirect_uri": redirect,
"response_type": "code",
"scope": " ".join(PROVIDERS[kind]["scopes"]),
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
params.update(PROVIDERS[kind].get("extra", {}))
return {
"url": f"{PROVIDERS[kind]['authorize']}?{urlencode(params)}",
"state": state,
"verifier": verifier,
"redirect_uri": redirect,
}
def _normalize(data: dict) -> dict:
expires_in = int(data.get("expires_in") or 3600)
return {
"access_token": str(data.get("access_token") or ""),
"refresh_token": str(data.get("refresh_token") or ""),
"scope": str(data.get("scope") or ""),
"expires_at": int(time.time()) + expires_in,
}
async def complete(kind: str, code: str, verifier: str) -> dict:
"""Échange le code contre des tokens (aucun réseau ici hors `_post_form`)."""
cid, secret, redirect = _client(kind)
data = await _post_form(PROVIDERS[kind]["token"], {
"client_id": cid,
"client_secret": secret,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect,
"code_verifier": verifier,
})
tokens = _normalize(data)
if not tokens["access_token"]:
raise ValueError(str(data.get("error_description") or data.get("error") or "échec de l'échange du code"))
return tokens
# ── Stockage (chiffré Fernet, comme calendar_sync) ──────────────────────────
def save(kind: str, user_id: int, tokens: dict) -> None:
with get_conn() as conn:
conn.execute(
"INSERT INTO connector_tokens (kind, user_id, tokens_enc, updated_at) "
"VALUES (?,?,?,CURRENT_TIMESTAMP) "
"ON CONFLICT(kind, user_id) DO UPDATE SET "
"tokens_enc=excluded.tokens_enc, updated_at=CURRENT_TIMESTAMP",
(kind, user_id, _encrypt_tokens(tokens)),
)
conn.commit()
def tokens(kind: str, user_id: int | None) -> dict:
if not user_id:
return {}
with get_conn() as conn:
row = conn.execute(
"SELECT tokens_enc FROM connector_tokens WHERE kind=? AND user_id=?",
(kind, user_id),
).fetchone()
return _decrypt_tokens(row["tokens_enc"]) if row else {}
def clear(kind: str, user_id: int) -> bool:
with get_conn() as conn:
cur = conn.execute(
"DELETE FROM connector_tokens WHERE kind=? AND user_id=?", (kind, user_id)
)
conn.commit()
return cur.rowcount > 0
def is_connected(kind: str, user_id: int | None) -> bool:
return bool(tokens(kind, user_id).get("access_token"))
# ── Réseau ──────────────────────────────────────────────────────────────────
async def _post_form(url: str, data: dict) -> dict:
"""POST x-www-form-urlencoded vers le token endpoint → dict JSON.
Point d'injection des tests (on monkeypatche ``oauth_connectors._post_form``).
"""
from app.services.http_client import shared_client
async with shared_client(timeout=15, headers={"Accept": "application/json"}) as client:
resp = await client.post(url, data=data)
return resp.json()
async def _api_get(url: str, headers: dict) -> tuple[int, str]:
"""GET d'une API fournisseur — 2ᵉ point d'injection des tests."""
from app.services.http_client import shared_client
async with shared_client(timeout=15, headers=headers) as client:
resp = await client.get(url)
return resp.status_code, (resp.text or "")[:20000]
async def access_token(kind: str, user_id: int | None) -> str:
"""Access token valide, rafraîchi (refresh_token) si nécessaire."""
tok = tokens(kind, user_id)
if not tok.get("access_token"):
return ""
if tok.get("expires_at", 0) > time.time() + _REFRESH_SKEW:
return tok["access_token"]
if not tok.get("refresh_token"):
return "" # expiré sans refresh → à reconnexion
try:
cid, secret, redirect = _client(kind)
data = await _post_form(PROVIDERS[kind]["token"], {
"client_id": cid,
"client_secret": secret,
"grant_type": "refresh_token",
"refresh_token": tok["refresh_token"],
"redirect_uri": redirect,
})
fresh = _normalize(data)
if not fresh["access_token"]:
raise ValueError(data.get("error_description") or "refresh refusé")
# Google ne renvoie parfois pas de nouveau refresh_token : on garde l'ancien
fresh["refresh_token"] = fresh["refresh_token"] or tok["refresh_token"]
save(kind, int(user_id), fresh)
return fresh["access_token"]
except Exception as exc: # noqa: BLE001 — jamais d'exception qui casse un run
logger.warning("OAuth refresh failed (%s): %s", kind, exc)
return ""
async def api_get(kind: str, user_id: int | None, path: str = "") -> dict:
"""GET sur l'API du fournisseur avec le token de l'utilisateur."""
if kind not in PROVIDERS:
return {"status": "error", "text": f"Connecteur OAuth inconnu: {kind}"}
access = await access_token(kind, user_id)
if not access:
return {"status": "error", "text": f"{kind} non connecté (lancez la connexion OAuth)"}
if "://" in (path or ""):
return {"status": "error", "text": "path doit être relatif (pas d'URL absolue)"}
# base fixe + chemin : pas d'urljoin (une URL absolue ne peut pas dévier
# l'hôte), puis validation SSRF par principe.
url = PROVIDERS[kind]["api"].rstrip("/") + "/" + (path or "").lstrip("/")
from app.services.importers.url_fetch import _validate_url
try:
_validate_url(url)
except ValueError as exc:
return {"status": "error", "text": str(exc)}
code, text = await _api_get(url, {"Authorization": f"Bearer {access}"})
if code >= 400:
return {"status": "error", "text": f"HTTP {code} — {text[:400]}"}
return {"status": "ok", "text": text[:20000]}
+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()
+195
View File
@@ -109,6 +109,201 @@ GALLERY: dict[str, dict] = {
),
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
},
# ── Vague 1 : skills 100 % locaux (aucun nouveau tool requis) ──────────
"veille-documentaire": {
"name": "Veille documentaire",
"icon": "🔎",
"description": "Repère les pages du workspace sans mise à jour récente et génère un digest d'entrée.",
"prompt_template": (
"Fais une veille documentaire du workspace :\n"
"1. Repère les documents dont le titre évoque une décision, un chantier ou un arbitrage "
"(search_workspace : « décision », « ADR », « roadmap », « spec », « bilan »).\n"
"2. Lis les plus pertinents (read_document) et note pour chacun : date de mise à jour, "
"statut (vivant / obsolète / contradictoire), propriétaire mentionné quand il existe.\n"
"3. Crée un document « Veille — <date> » (create_document) en 3 sections : "
"À valider (décisions non actées), À mettre à jour (docs dépassés par d'autres), "
"Cohérences (points de vue contradictoires entre documents).\n"
"4. Ne modifie aucun document : tu proposes, l'humain décide."
),
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
},
"chasse-decisions": {
"name": "Chasse aux décisions",
"icon": "⚖️",
"description": " Rassemble en une page toutes les décisions prises dans le workspace, avec leur statut.",
"prompt_template": (
"Construis le registre des décisions du workspace :\n"
"1. Cherche les documents de décision (search_workspace : « décision », « arbitrage », "
"« choix de », « remplacement de »).\n"
"2. Lis-les (read_document) et extrais pour chacune : la décision, la date, la motivation, "
"les options écartées.\n"
"3. Crée un document « Registre des décisions » (create_document) en tableau : "
"Décision · Date · Motivation · Statut (actif / révoqué / jamais tranché).\n"
"4. Signale explicitement les sujets récurrents où aucune décision n'a été actée, "
"plutôt que de combler les trous."
),
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
},
"onboarding-nouveau": {
"name": "Onboarding nouveau",
"icon": "🧭",
"description": "Génère un parcours de lecture ordonné pour une personne qui rejoint l'équipe.",
"prompt_template": (
"Prépare l'onboarding d'une nouvelle personne :\n"
"1. Repère les documents d'entrée : onboarding existant, README, architecture, process, "
"glossaire (search_workspace).\n"
"2. Lis-les (read_document) et classe-les par jour d'arrivée : Jour 1, Semaine 1, "
"Puis plus tard.\n"
"3. Crée un document « Onboarding — <prénom> » (create_document) : pour chaque étape, "
"le document à lire, ce qu'on doit en retenir, et 2 questions pour vérifier la compréhension.\n"
"4. Termine par les 5 questions qu'une personne doit savoir répondre à la fin de la semaine 1."
),
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
},
"audit-base": {
"name": "Audit de base",
"icon": "🧹",
"description": "Audite une collection : colonnes vides, doublons, lignes sans propriétaire, propriétés inutilisées.",
"prompt_template": (
"Audite la collection demandée :\n"
"1. Lis son schéma et ses lignes (read_collection).\n"
"2. Détecte : propriétés jamais remplies, lignes sans propriétaire, doublons de titre, "
"valeurs incohérentes dans une même colonne, colonnes ajoutées puis abandonnées.\n"
"3. Crée un document « Audit — <collection> » (create_document) avec, par anomalie : "
"l'ampleur (nombre de lignes), des exemples concrets, et la correction proposée.\n"
"4. N'applique AUCUNE correction : liste d'abord, l'humain valide ensuite."
),
"allowed_tools": ["search_workspace", "read_collection", "read_page", "create_document", "write_blocks"],
},
"sprint-review": {
"name": "Revue de sprint",
"icon": "🏁",
"description": "Construit une revue de sprint depuis les issues Gitea : accompli, en retard, risques.",
"prompt_template": (
"Prépare la revue de sprint à partir des issues Gitea :\n"
"1. Lis les issues du dépôt (read_gitea_issues, state=all) et resynchronise si nécessaire (sync_gitea).\n"
"2. Classe-les : accompli (fermées), en retard (ouvertes et dépassées), à trier (sans label).\n"
"3. Croise avec les documents du workspace (search_workspace) pour voir si des décisions "
"changent la priorisation.\n"
"4. Crée un document « Revue de sprint — <période> » (create_document) : Accompli · En retard · "
"Risques · Prochaines priorités, avec le numéro d'issue pour chaque affirmation.\n"
"5. Ne ferme ni ne modifie aucune issue."
),
"allowed_tools": ["read_gitea_issues", "sync_gitea", "search_workspace",
"create_document", "write_blocks"],
},
# ── Vague 2 : skills web (web_search / fetch_url / search_code) ────────
"recherche-marche": {
"name": "Recherche marché",
"icon": "🌍",
"description": "Étudie un sujet de marché sur le web et produit une note sourcée avec alternatives.",
"prompt_template": (
"Produis une étude de marché sourcée sur « <sujet> » :\n"
"1. Cherche 3 requêtes distinctes (web_search) : le sujet lui-même, « <sujet> alternatives », "
"« <sujet> prix tarifs 2026 ».\n"
"2. Approfondis les 3 pages les plus prometteuses (fetch_url).\n"
"3. Crée un document « Marché — <sujet> » (create_document) : acteurs et positionnement, "
"grille tarifaire, forces/faiblesses, et 3 recommandations.\n"
"4. OBLIGATION : chaque affirmation porte sa source au format [titre — URL]. "
"Si une information n'est pas sourcée, écris « non vérifié » plutôt que d'inventer.\n"
"5. Indique la date de la recherche : ces données périment vite."
),
"allowed_tools": ["web_search", "fetch_url", "create_document", "write_blocks"],
},
"veille-techno": {
"name": "Veille technologique",
"icon": "🛰️",
"description": "Surveille l'actualité d'une techno : versions, ruptures, dépendances et advisories.",
"prompt_template": (
"Fais une veille technologique sur « <techno> » :\n"
"1. Web : dernières annonces et sorties de version (web_search).\n"
"2. GitHub : dépôts de référence et issues récentes résolues (search_code, kind=issues).\n"
"3. Approfondis les annonces importantes (fetch_url).\n"
"4. Crée un document « Veille — <techno> — <date> » (create_document) : "
"Nouveautés depuis le dernier point · Risques de rupture · Actions recommandées (mettre à jour / "
"surveiller / ignorer).\n"
"5. Une ligne par fait, avec [titre — URL]. Ne présente jamais une rumeur comme une release."
),
"allowed_tools": ["web_search", "fetch_url", "search_code",
"create_document", "write_blocks"],
},
"debug-web": {
"name": "Debug web",
"icon": "🐛",
"description": "Diagnostique un bug technique en croisant les docs, le code GitHub et les discussions.",
"prompt_template": (
"Diagnostique le problème suivant : <erreur / symptôme>.\n"
"1. Reformule le symptôme en une recherche web précise (web_search) : message d'erreur exact "
"+ bibliothèque + version.\n"
"2. Cherche si c'est un bug connu (search_code, kind=issues) et lis les discussions pertinentes (fetch_url).\n"
"3. Crée un document « Debug — <symptôme> » (create_document) : Cause probable (classée par "
"probabilité) · Vérifications à faire dans cet ordre · Correctif proposé.\n"
"4. Distingue clairement ce qui est vérifié par une source de ce qui est ton hypothèse."
),
"allowed_tools": ["web_search", "search_code", "fetch_url",
"create_document", "write_blocks"],
},
# ── Vague 3 : skills « pouvoir » ──────────────────────────────────────
"triage-incident": {
"name": "Triage d'incident",
"icon": "🚨",
"description": "Prend un incident décrit, ouvre un runbook pré-rempli et pose les premières questions de qualification.",
"prompt_template": (
"Traite l'incident suivant : <description>.\n"
"1. Qualifie avant de chercher : quel service, depuis quand, quel périmètre d'impact, "
"erreur observable. Les inconnues deviennent des questions, pas des suppositions.\n"
"2. Cherche les incidents similaires déjà résolus dans le workspace (search_workspace) "
"et les runbooks existants.\n"
"3. Crée un document « Incident — <titre court> » (create_document) : Symptôme · Impact · "
"Cause probable · Hypothèses classées · Étapes de résolution · Questions à poser à l'utilisateur.\n"
"4. Ne modifie aucun code ni aucune configuration : tu produis un runbook, pas un correctif.\n"
"5. Termine par les 3 informations qui feraient le plus progresser le diagnostic."
),
"allowed_tools": ["search_workspace", "read_document", "web_search",
"create_document", "write_blocks"],
},
"nettoyage-base": {
"name": "Nettoyage de base",
"icon": "🧽",
"description": "Prépare un plan de nettoyage d'une collection (doublons, valeurs vides) avec aperçu avant écriture.",
"prompt_template": (
"Prépare le nettoyage de la collection « <collection> » :\n"
"1. Lis son schéma et ses lignes (read_collection).\n"
"2. Identifie les corrections : lignes vides à supprimer, doublons à fusionner, "
"valeurs parasites à normaliser, propriétés obsolètes.\n"
"3. PRODUIRE UN APERÇU D'AVANT : pour chaque catégorie de correction, le nombre de lignes "
"concernées et 3 exemples concrets.\n"
"4. Règle absolue : n'applique AUCUNE modification avant validation explicite de l'utilisateur. "
"Le plan est le livrable.\n"
"5. Crée le document « Plan de nettoyage — <collection> » (create_document) avec le plan, "
"puis demande la validation."
),
"allowed_tools": ["search_workspace", "read_collection", "read_page",
"create_document", "write_blocks"],
},
"briefing-quotidien": {
"name": "Briefing quotidien",
"icon": "🌅",
"description": "Prépare le point du matin : activités de la veille, décisions en attente, blocages.",
"prompt_template": (
"Prépare le briefing du jour :\n"
"1. Activités : ce qui a bougé dans le workspace et le dépôt (read_gitea_issues, "
"search_workspace) — uniquement des faits datés.\n"
"2. Décisions en attente : documents de décision sans statut clair.\n"
"3. Blocages : tickets ouverts sans mouvement, sujets qui reviennent dans plusieurs documents.\n"
"4. Crée un document « Briefing — <date> » (create_document) en moins d'une page : "
"Hier en bref · À trancher aujourd'hui · Bloquants · Une chose à surveiller.\n"
"5. Si une section n'a aucun élément, écris « rien à signaler » plutôt que d'inventer."
),
"allowed_tools": ["search_workspace", "read_document", "read_gitea_issues",
"create_document", "write_blocks"],
},
}
+2 -1
View File
@@ -22,6 +22,7 @@ import logging
import time
from app.db import get_conn
from app.services.collection_lifecycle import delete_collections
logger = logging.getLogger(__name__)
@@ -381,7 +382,7 @@ class SyncEngine:
def _mut_collection_delete(self, user_id: int, payload: dict) -> dict:
cid = payload.get("collection_id")
with get_conn() as conn:
conn.execute("DELETE FROM collections WHERE id=?", (cid,))
delete_collections(conn, [cid])
conn.commit()
return {"collection_id": cid, "server_version": None}
+438
View File
@@ -0,0 +1,438 @@
"""FlowDeck — My Tasks : sources de tâches et normalisation.
Une base ne diffuse ses tâches dans My Tasks qu'après **conversion
explicite** (``collections.is_task``) et le mapping explicite de ses trois
propriétés requises :
=========================== ==========================================
Rôle Colonne sur ``collections``
=========================== ==========================================
``assignee`` (Assigné à) ``task_assignee_prop``
``status`` (Statut) ``task_status_prop``
``due`` (Échéance) ``task_due_prop``
=========================== ==========================================
Ce module est la lecture unique de ce mapping : My Tasks, les filtres, les
vues Kanban/calendrier et l'ajout rapide s'appuient tous sur
``collect_tasks`` — donc une même tâche ne peut pas être rendue
différemment selon l'appelant.
Périmètre : **tous les workspaces de l'utilisateur** (mode Notion *Home*),
pas seulement le workspace actif — le tableau de bord est un poste de
travail transverse.
"""
from __future__ import annotations
import json
import logging
import unicodedata
from datetime import UTC, date, datetime, timedelta
from app.services.collection_lifecycle import (
effective_workspace_sql,
live_collection_ids,
live_row_ids,
user_workspace_ids,
)
logger = logging.getLogger(__name__)
#: Notion refuse au-delà de 10 bases connectées au widget My Tasks.
MAX_TASK_SOURCES = 10
#: Rôles requis, dans l'ordre d'affichage de la modale de conversion.
TASK_ROLES = ("assignee", "status", "due")
#: Types acceptés pour chaque rôle (une base « bien formée » est refusée
#: sinon : une colonne Date n'a pas de sens comme Assigné à).
ROLE_TYPES = {
"assignee": ("person",),
"status": ("status", "select"),
"due": ("date",),
}
ROLE_LABELS = {
"assignee": "Assigné à",
"status": "Statut",
"due": "Date d'échéance",
}
#: Premier mot d'un statut valant « terminé », une fois normalisé (minuscules,
#: sans accents). On compare des formes normalisées et non des libellés exacts :
#: « Terminée », « Terminé », « Complete » ou « Closed » doivent tous être
#: reconnus, quelle que soit la casse et l'accentuation.
#:
#: On matche le **premier mot** et non un simple préfixe : « Clôture »
#: commence comme « clos » mais n'est pas un statut terminé.
DONE_WORDS = frozenset({
# anglais
"done", "complete", "completed", "closed", "closed?", "terminated", "finished",
# français (masculin / féminin, singulier / pluriel)
"clos", "close", "termine", "terminee", "termines", "terminees",
"annule", "annulee", "annules", "annulees", "cancelled", "canceled",
"resolu", "resolue", "resolus", "resolues", "acheve", "achevee",
})
def _normalize(value) -> str:
"""Minuscules, accents retirés, espaces compactés."""
text = unicodedata.normalize("NFKD", str(value or ""))
text = "".join(c for c in text if not unicodedata.combining(c))
return " ".join(text.lower().split())
def is_done(status: str) -> bool:
"""Le statut vaut-il « terminé » ?
Le premier mot suffit : « Terminée », « terminated », « Complete! » et
« done (closed) » sont reconnus ; « Clôture » ne l'est pas.
"""
norm = _normalize(status)
if not norm:
return False
first = norm.split()[0] if norm.split() else ""
return first.strip("!?.,:;()") in DONE_WORDS
def user_today(conn, user_id: int) -> date:
"""« Aujourd'hui » dans le fuseau de l'utilisateur.
Le projet stocke déjà ce fuseau (`users.timezone`, cf. migration v5.8.0) et
l'utilise déjà pour le calendrier des bases. My Tasks s'en sert aussi :
sans cela, une tâche due « aujourd'hui » bascule dans « demain » (ou
l'inverse) dès que l'utilisateur est loin d'UTC.
"""
row = conn.execute(
"SELECT timezone FROM users WHERE id=?", (user_id,)
).fetchone()
tz_name = (row["timezone"] if row and "timezone" in row.keys() else "") or ""
if tz_name:
try:
from zoneinfo import ZoneInfo
return datetime.now(ZoneInfo(tz_name)).date()
except Exception: # fuseau inconnu / base tz absente → UTC
logger.debug("timezone « %s » illisible, repli sur UTC", tz_name)
return datetime.now(UTC).date()
def task_source_columns() -> str:
"""Colonnes de ``collections`` lues par les requêtes de sources.
Les 3 colonnes de mapping n'existent que depuis la migration 30 : sur une
base antérieure elles vaudront NULL de toute façon.
Préfixées par ``c.`` car `list_task_sources` joint ``pages`` : sans
qualification, ``id`` est ambigu.
"""
return ("c.id, c.name, c.icon, c.workspace_id, c.is_task, "
"c.task_assignee_prop, c.task_status_prop, c.task_due_prop")
def list_task_sources(conn, user_id: int) -> list[dict]:
"""Bases de tâches configurées par l'utilisateur, tous workspaces
confondus. Renvoie aussi ``configured`` (les 3 rôles mappés) et
``missing_roles`` pour piloter l'invite de conversion.
Deux exclusions :
* une base dont la page hôte est à la corbeille (ou supprimée) est
**retirée** : sans ce filtre, une base portée par un document supprimé
continuerait d'alimenter My Tasks — le bug d'origine ;
* une base dont le workspace n'est pas accessible à l'utilisateur n'est
pas listée (une base sans workspace rattachable n'appartient à personne).
Le workspace retenu est l'**effectif** (`collections.workspace_id`, sinon
celui de la page hôte) : une base créée depuis un document a
``workspace_id`` NULL, et un ``JOIN workspaces`` la faisait disparaître de
My Tasks alors qu'elle venait d'être convertie.
"""
ws_ids = user_workspace_ids(conn, user_id)
if not ws_ids:
return []
qs = ",".join("?" * len(ws_ids))
ws_sql = effective_workspace_sql("c")
rows = conn.execute(
f"""SELECT {task_source_columns()}, {ws_sql} AS ws_effective,
(SELECT w.name FROM workspaces w WHERE w.id = {ws_sql})
AS workspace_name
FROM collections c
WHERE c.is_task = 1 AND {ws_sql} IN ({qs})
ORDER BY c.name""",
[*ws_ids],
).fetchall()
# Un workspace peut apparaître plusieurs fois : on ne recalcule le
# jeu de bases vivantes qu'une fois par workspace.
live_by_ws: dict[int, set[int]] = {}
sources = []
for r in rows:
d = dict(r)
ws = d.get("ws_effective")
if ws not in live_by_ws:
live_by_ws[ws] = set(live_collection_ids(conn, ws))
if d["id"] not in live_by_ws[ws]:
logger.debug("base %s exclue : sa page hôte n'est plus vivante", d["id"])
continue
missing = [role for role in TASK_ROLES if not d.get(f"task_{role}_prop")]
d["configured"] = not missing
d["missing_roles"] = missing
d["status_options"] = _status_options(conn, d.get("task_status_prop"))
sources.append(d)
return sources
def _status_options(conn, prop_id) -> list[str]:
"""Libellés du statut de la base source (colonnes du Kanban)."""
if not prop_id:
return []
row = conn.execute(
"SELECT options_json FROM collection_properties WHERE id=?", (prop_id,)
).fetchone()
if not row:
return []
try:
opts = json.loads(row["options_json"] or "[]")
except (json.JSONDecodeError, TypeError):
return []
return [o if isinstance(o, str) else (o or {}).get("name", "") for o in opts
if isinstance(o, str) or isinstance(o, dict)]
def source_state(conn, collection_id: int) -> dict:
"""État de conversion d'une base pour la modale « convertir en base de
tâches » : mapping courant + colonnes candidates pour chaque rôle."""
coll = conn.execute(
f"SELECT {task_source_columns()} FROM collections c WHERE c.id=?",
(collection_id,),
).fetchone()
if coll is None:
return {"exists": False}
d = dict(coll)
props = conn.execute(
"SELECT id, name, prop_type, options_json, position FROM collection_properties "
"WHERE collection_id=? ORDER BY position, id",
(collection_id,),
).fetchall()
missing = [role for role in TASK_ROLES if not d.get(f"task_{role}_prop")]
used = {d.get(f"task_{role}_prop") for role in TASK_ROLES if d.get(f"task_{role}_prop")}
return {
"exists": True,
"collection_id": collection_id,
"name": d.get("name"),
"is_task": bool(d.get("is_task")),
"mapping": {role: d.get(f"task_{role}_prop") for role in TASK_ROLES},
"missing_roles": missing,
"configured": not missing,
# Par rôle : les colonnes compatibles (une colonne déjà affectée à un
# autre rôle est exclue — Notion n'autorise pas le double emploi).
"candidates": {
role: [
{"id": p["id"], "name": p["name"], "prop_type": p["prop_type"]}
for p in props
if p["prop_type"] in ROLE_TYPES[role] and p["id"] not in used
]
for role in TASK_ROLES
},
"all_properties": [
{"id": p["id"], "name": p["name"], "prop_type": p["prop_type"]}
for p in props
],
"suggested_names": {role: ROLE_LABELS[role] for role in TASK_ROLES},
"max_sources": MAX_TASK_SOURCES,
"sources_count": _sources_count(conn, d.get("workspace_id"), collection_id),
}
def _sources_count(conn, workspace_id, exclude_id=None) -> int:
"""Nombre de bases déjà connectées (pour la limite de 10)."""
row = conn.execute(
"SELECT COUNT(*) FROM collections WHERE is_task = 1 AND workspace_id = ?",
(workspace_id,),
).fetchone()
n = row[0] if row else 0
if exclude_id is not None:
row = conn.execute(
"SELECT is_task FROM collections WHERE id=?", (exclude_id,)
).fetchone()
if row and row[0]:
n -= 1
return max(n, 0)
def _row_value(raw: dict, prop_id):
"""Valeur d'une propriété de ligne, indexée par ID (ou par nom, pour les
lignes historicisées écrites avant le passage aux clés indexées)."""
if prop_id is None:
return None
if prop_id in raw:
return raw[prop_id]
return raw.get(str(prop_id))
def _person_values(value) -> list:
"""Aplatit une valeur `person` en liste d'éléments (dicts `person` ou
chaînes), sans perdre l'id — c'est lui qui identifie l'assigné."""
if value is None:
return []
if isinstance(value, dict):
return [value]
if isinstance(value, list):
out: list = []
for v in value:
out.extend(_person_values(v))
return out
if isinstance(value, str) and value.strip():
return [value]
return []
def _person_label(v) -> str:
if isinstance(v, dict):
return str(v.get("login") or v.get("full_name") or "")
return str(v)
def _assigned_to_me(values: list, user_id: int, login: str) -> bool:
"""L'assigné est-il l'utilisateur courant ?
Une valeur `person` porte `{id, login, full_name}` : on compare l'id en
priorité (stable), puis le login en repli pour les lignes historicisées
qui ne stockent qu'un nom.
"""
if not login:
return False
for v in values:
if isinstance(v, dict):
if v.get("id") is not None and str(v["id"]) == str(user_id):
return True
if _person_label(v).strip().lower() == login.strip().lower():
return True
return False
def collect_tasks(conn, user_id: int, *, mine_only: bool = True,
login: str = "") -> list[dict]:
"""Tâches normalisées de toutes les bases connectées.
Chaque entrée porte sa source (``collection_id``/``collection_name``) et
les identifiants des propriétés à écrire pour une édition en direct
(``status_prop``/``due_prop``/``assignee_prop``) : le front n'a plus à
deviner quoi écrire.
"""
tasks: list[dict] = []
today = user_today(conn, user_id)
for src in list_task_sources(conn, user_id):
if not src["configured"]:
# Base connectée mais mapping incomplet : elle n'alimente pas
# My Tasks (l'UI le signale), plutôt que d'inventer des colonnes.
continue
status_prop = src["task_status_prop"]
due_prop = src["task_due_prop"]
assignee_prop = src["task_assignee_prop"]
for row_id in live_row_ids(conn, src["id"]):
row = conn.execute(
"SELECT id, title, icon, created_at, property_values_json "
"FROM collection_pages WHERE id=?",
(row_id,),
).fetchone()
if row is None:
continue
try:
raw = json.loads(row["property_values_json"] or "{}")
except (json.JSONDecodeError, TypeError):
raw = {}
if not isinstance(raw, dict):
raw = {}
persons = _person_values(_row_value(raw, assignee_prop))
assignees = [_person_label(p) for p in persons if _person_label(p)]
if mine_only and persons and not _assigned_to_me(persons, user_id, login):
continue
status = _row_value(raw, status_prop)
if isinstance(status, list):
status = status[0] if status else ""
status = str(status or "")
due_raw = _row_value(raw, due_prop)
due = str(due_raw or "")[:10]
due_state = ""
if due:
try:
d = date.fromisoformat(due)
except ValueError:
d = None
if d:
due_state = ("overdue" if d < today
else "today" if d == today
else "upcoming")
tasks.append({
"id": row["id"],
"title": row["title"] or "Untitled",
"icon": row["icon"] or "📄",
"created_at": row["created_at"],
"collection_id": src["id"],
"collection_name": src["name"],
"collection_icon": src["icon"] or "📋",
"workspace_id": src["workspace_id"],
"workspace_name": src["workspace_name"],
"assignees": assignees,
"status": status,
"status_options": src["status_options"],
"done": is_done(status),
"due": due,
"due_state": due_state,
"status_prop": status_prop,
"due_prop": due_prop,
"assignee_prop": assignee_prop,
})
return tasks
def filter_and_sort(tasks: list[dict], *, due: str = "all", hide_done: bool = False,
sort: str = "due", today: date | None = None) -> list[dict]:
"""Filtres et tri globaux de My Tasks (portés par l'URL).
``today`` est le jour de référence de l'utilisateur (fuseau compris) : il
doit être celui utilisé par `collect_tasks`, sinon « aujourd'hui » et
« cette semaine » ne cibleront pas le même jour que `due_state`.
"""
out = tasks
if hide_done:
out = [t for t in out if not t["done"]]
if due and due != "all":
if due == "today":
out = [t for t in out if t["due_state"] == "today"]
elif due == "overdue":
out = [t for t in out if t["due_state"] in ("today", "overdue")]
elif due == "week":
# « Cette semaine » est **borné** à J+7 : sans borne, une tâche
# datée dans six mois apparaîtrait sous « Cette semaine ».
ref = today or datetime.now(UTC).date()
limit = (ref + timedelta(days=7)).isoformat()
out = [t for t in out
if t["due"] and (t["due_state"] in ("today", "overdue")
or t["due"] <= limit)]
if sort == "status":
return sorted(out, key=lambda t: (t["done"], t["status"].lower(),
(t["due"] or "9999")))
if sort == "source":
return sorted(out, key=lambda t: (t["collection_name"].lower(),
(t["due"] or "9999"), t["title"].lower()))
if sort == "created":
return sorted(out, key=lambda t: str(t.get("created_at") or ""))
# "due" : les tâches datées d'abord (les plus urgentes en tête), puis
# les tâches sans date — comme une liste de rappels.
return sorted(out, key=lambda t: ((t["due"] or "9999-12-31"),
t["done"], t["title"].lower()))
+416 -24
View File
@@ -15,12 +15,14 @@ from __future__ import annotations
import json
import logging
import re
import sqlite3
from dataclasses import dataclass, field
from datetime import UTC
from typing import Any
from app.db import get_conn
from app.services.collection_lifecycle import delete_collections
logger = logging.getLogger(__name__)
@@ -701,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 ══════════════════════════
@@ -785,7 +889,7 @@ class DeleteCollection(Tool):
coll = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
if not coll:
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
conn.execute("DELETE FROM collections WHERE id=?", (cid,))
delete_collections(conn, [cid])
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="collection", target_id=cid,
@@ -877,16 +981,281 @@ class CreateGiteaIssue(Tool):
)
# ══════════════════════════ Web tools ══════════════════════════
# Outils réseau : recherche web, lecture d'une page, recherche GitHub.
# Ils réutilisent les garde-fous SSRF déjà éprouvés par l'importeur URL
# (``importers.url_fetch`` / ``og_fetcher``) — aucune adresse privée,
# re-vérifiée à chaque saut de redirection.
class WebSearch(Tool):
name = "web_search"
description = (
"Recherche sur le web (actualités, docs, libs, comparatifs) et "
"renvoie titre + URL + extrait pour chaque résultat. Utilise-la AVANT de "
"répondre sur un fait récent ou technique, puis CITE les URL obtenues."
)
parameters = {
"type": "object",
"properties": {
"query": {"type": "string", "description": "requête de recherche"},
"num_results": {"type": "integer",
"description": "nombre de résultats (1-10, défaut 5)"},
},
"required": ["query"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.services.web_search import available_providers, search_web
query = str(args.get("query") or "").strip()
if not query:
return ToolResult(status="error", tool=self.name, message="query vide")
try:
num = max(1, min(int(args.get("num_results") or 5), 10))
except (TypeError, ValueError):
num = 5
try:
results, provider = await search_web(query, num)
except Exception as exc: # noqa: BLE001
return ToolResult(status="error", tool=self.name,
message=f"Recherche web indisponible: {exc}")
if not results:
hint = ""
if not available_providers().get(provider, True):
hint = " — configurez EXA_API_KEY dans les réglages FlowDeck"
return ToolResult(status="error", tool=self.name,
message=f"Aucun résultat pour « {query} » (provider: {provider}){hint}")
return ToolResult(
status="success", tool=self.name, target_type="web_search",
data={"query": query, "provider": provider, "results": results,
"count": len(results)},
message=f"{len(results)} résultat(s) via {provider}",
)
class FetchUrl(Tool):
name = "fetch_url"
description = (
"Télécharge une page web et la convertit en Markdown (titres, listes, "
"code, tableaux) pour pouvoir la lire et la citer. Utilise-la après "
"web_search pour approfondir un résultat. Les URLs internes / privées "
"sont refusées."
)
parameters = {
"type": "object",
"properties": {
"url": {"type": "string", "description": "URL http(s) de la page"},
"max_chars": {"type": "integer",
"description": "longueur max du markdown renvoyé (défaut 20000)"},
},
"required": ["url"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.config import settings
from app.services.http_client import shared_client
from app.services.importers.html_notes import _html_to_markdown
from app.services.importers.url_fetch import _validate_url
from app.services.og_fetcher import parse_og
raw = str(args.get("url") or "").strip()
if not raw:
return ToolResult(status="error", tool=self.name, message="url vide")
# « example.com/page » → https://example.com/page, mais un schéma
# explicite (file:, ftp:, …) est conservé pour être REJETÉ par le
# garde-fou, au lieu d'être transformé en requête réseau hasardeuse.
if not re.match(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://", raw):
raw = "https://" + raw
try:
url = _validate_url(raw)
except ValueError as exc:
return ToolResult(status="error", tool=self.name, message=str(exc))
try:
max_chars = max(500, min(int(args.get("max_chars")
or settings.web_fetch_max_chars), 60000))
except (TypeError, ValueError):
max_chars = int(settings.web_fetch_max_chars or 20000)
current = url
resp = None
try:
async with shared_client(timeout=15.0, follow_redirects=False,
headers={"User-Agent": "FlowDeck-Agent/1.0 (fetch_url)"}) as client:
for _ in range(6):
resp = await client.get(current)
if resp.status_code in (301, 302, 303, 307, 308):
loc = resp.headers.get("location")
if not loc:
raise ValueError("redirection sans cible")
from urllib.parse import urljoin
current = urljoin(current, loc)
# re-validation : une URL publique peut rediriger vers 169.254.x
current = _validate_url(current)
continue
resp.raise_for_status()
break
else:
raise ValueError("trop de redirections")
except ValueError as exc:
return ToolResult(status="error", tool=self.name, message=str(exc))
except Exception as exc: # noqa: BLE001
return ToolResult(status="error", tool=self.name,
message=f"Page injoignable: {exc}")
if resp is None:
return ToolResult(status="error", tool=self.name, message="Aucune réponse")
ctype = (resp.headers.get("content-type") or "").lower()
body = resp.text
if "html" not in ctype and "xhtml" not in ctype:
# JSON / texte brut : on le renvoie tel quel, c'est souvent une API.
markdown = body
meta: dict = {}
else:
meta = parse_og(body, current)
markdown = _html_to_markdown(body)
truncated = len(markdown) > max_chars
if truncated:
markdown = markdown[:max_chars] + "\n\n[contenu tronqué]"
title = (meta.get("title") or "").strip() or current
return ToolResult(
status="success", tool=self.name, target_type="web_page", target_id=current,
data={"url": current, "title": title[:200], "markdown": markdown,
"truncated": truncated, "chars": len(markdown)},
message=f"Page lue : {title[:80]} ({len(markdown)} car.)",
)
class SearchCode(Tool):
name = "search_code"
description = (
"Recherche GitHub : dépôts, code source ou issues (uniquement GitHub "
"public, sans clé possible mais limitée à 10 requêtes/min). Sert à "
"trouver une implémentation de référence, une lib ou l'historique d'un bug."
)
parameters = {
"type": "object",
"properties": {
"query": {"type": "string", "description": "termes GitHub (lang:python, repo:, etc.)"},
"kind": {"type": "string",
"description": "repositories | code | issues (défaut repositories)"},
"limit": {"type": "integer", "description": "nombre de résultats (1-10, défaut 5)"},
},
"required": ["query"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.services.web_search import search_github
query = str(args.get("query") or "").strip()
if not query:
return ToolResult(status="error", tool=self.name, message="query vide")
kind = str(args.get("kind") or "repositories")
try:
limit = max(1, min(int(args.get("limit") or 5), 10))
except (TypeError, ValueError):
limit = 5
try:
results = await search_github(query, kind, limit)
except Exception as exc: # noqa: BLE001
return ToolResult(status="error", tool=self.name,
message=f"Recherche GitHub impossible: {exc}")
return ToolResult(
status="success", tool=self.name, target_type="github",
target_id=f"{kind}:{query}",
data={"query": query, "kind": kind, "results": results, "count": len(results)},
message=f"{len(results)} résultat(s) GitHub ({kind})",
)
# ══════════════════════════ Registry ══════════════════════════
class ConnectorFetch(Tool):
name = "connector_fetch"
description = (
"Lit un connecteur configuré dans FlowDeck : API Gitea / GitHub (natives), "
"recherche web (natif « web »), ou un connecteur personnalisé (URL + clé). "
"`path` est relatif à l'URL du connecteur (ex. « /api/v1/repos ») ; "
"`query` sert au connecteur web. Les hôtes privés sont refusés."
)
parameters = {
"type": "object",
"properties": {
"connector": {"type": "string",
"description": "id, nom ou kind (gitea / github / web)"},
"path": {"type": "string", "description": "chemin relatif (API natives)"},
"query": {"type": "string", "description": "terme de recherche (web)"},
},
"required": ["connector"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.services import connectors
try:
res = await connectors.connector_fetch(
str(args.get("connector") or ""),
str(args.get("path") or ""),
str(args.get("query") or ""),
user_id=user_id,
)
except ValueError as exc:
return ToolResult(status="error", tool=self.name, message=str(exc))
except Exception as exc: # noqa: BLE001 — réseau : erreur outil, pas run
logger.warning("connector_fetch failed: %s", exc)
return ToolResult(status="error", tool=self.name,
message=f"Connecteur indisponible: {exc}")
if res.get("status") != "ok":
return ToolResult(status="error", tool=self.name,
message=(res.get("text") or "erreur")[:500])
text = res.get("text") or ""
return ToolResult(status="success", tool=self.name, target_type="connector",
message=text[:800], data={"text": text})
class McpTool(Tool):
"""Outil MCP dynamique — instance construite à la volée depuis le cache
en base (`mcp_client.cached_tools()`), pas enregistrée dans TOOL_CLASSES."""
def __init__(self, entry: dict):
self.name = entry["name"]
self.description = entry.get("description") or "Outil MCP"
self.parameters = entry.get("parameters") or {"type": "object", "properties": {}}
self.entry = entry
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.services import mcp_client
try:
res = await mcp_client.call_tool(self.entry["connector_id"],
self.entry.get("original") or "", args)
except ValueError as exc:
return ToolResult(status="error", tool=self.name, message=str(exc))
except Exception as exc: # noqa: BLE001 — réseau : erreur outil, pas run
logger.warning("MCP tool failed (%s): %s", self.name, exc)
return ToolResult(status="error", tool=self.name,
message=f"MCP indisponible: {exc}")
if res.get("status") != "ok":
return ToolResult(status="error", tool=self.name,
message=(res.get("text") or "erreur MCP")[:500])
text = res.get("text") or ""
return ToolResult(status="success", tool=self.name, target_type="mcp",
message=text[:800], data={"text": text})
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,
WebSearch, FetchUrl, SearchCode, ConnectorFetch,
]
@@ -896,24 +1265,47 @@ class ToolRegistry:
def __init__(self):
self.tools: dict[str, Tool] = {t.name: t() for t in TOOL_CLASSES}
def _all(self) -> dict[str, Tool]:
"""Outils statiques + **outils MCP dynamiques** (cache en base).
Les MCP ne sont jamais mis en cache en mémoire : un run re-lit la base,
donc « Tester le connecteur » suffit à rendre un outil disponible.
"""
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("dynamic tools load failed")
return tools
def list(self, scope: dict | None = None) -> list[str]:
"""Tool names allowed by an agent scope (default: all)."""
tools = self._all()
allowed = (scope or {}).get("tools")
if allowed is None:
return list(self.tools.keys())
return [n for n in self.tools if n in allowed]
return list(tools.keys())
return [n for n in tools if n in allowed]
def schema(self, scope: dict | None = None) -> list[dict]:
"""Function-calling schema for the LLM, filtered by scope."""
tools = self._all()
return [
{"name": self.tools[n].name,
"description": self.tools[n].description,
"parameters": self.tools[n].parameters}
{"name": tools[n].name,
"description": tools[n].description,
"parameters": tools[n].parameters}
for n in self.list(scope)
]
async def execute(self, tool: str, args: dict, *, user_id: int | None = None) -> ToolResult:
impl = self.tools.get(tool)
impl = self._all().get(tool)
if not impl:
return ToolResult(status="error", tool=tool, message=f"Outil inconnu: {tool}")
return await impl.execute(args, user_id=user_id)
+8
View File
@@ -13,6 +13,10 @@ import re
from datetime import UTC, datetime, timedelta
from app.db import get_conn
from app.services.collection_lifecycle import (
collections_hosted_by_page,
delete_collections,
)
logger = logging.getLogger(__name__)
@@ -57,6 +61,9 @@ def purge_expired(days: int = 30) -> dict:
if dt is None or dt >= cutoff:
continue
page_id = row["id"]
# A purged host page takes its database with it, otherwise the
# workspace kept advertising tasks whose document is gone.
collections = collections_hosted_by_page(conn, page_id)
# Promote direct children to the deleted page's parent so no live
# sub-tree is orphaned (matches the permanent-delete semantics).
conn.execute(
@@ -65,6 +72,7 @@ def purge_expired(days: int = 30) -> dict:
(page_id, page_id),
)
conn.execute("DELETE FROM pages WHERE id=?", (page_id,))
delete_collections(conn, collections)
purged.append(page_id)
conn.commit()
if purged:
+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)
+295
View File
@@ -0,0 +1,295 @@
"""FlowDeck — Web search + GitHub search pour les tools agent (v7.46.0).
Alimente les deux tools réseau qui manquaient au registre : la recherche
**en ligne** (`web_search`) et la recherche **GitHub** (`search_code`).
Choix de conception :
* **Provider configurable** (``settings.web_search_provider``) : Exa
(recherche sémantique + snippets, clé requise) en priorité, DuckDuckGo en
repli sans compte. Le repli parse du HTML — c'est volontairement *dégradé* :
il n'est jamais utilisé si une clé Exa est configurée, et sert surtout à
garder le skill utilisable sur une instance sans compte externe.
* **Aucune dépendance ajoutée** : httpx déjà présent, ``shared_client`` pour le
pool de connexions (A42), et les garde-fous SSRF déjà éprouvés par
``app.services.importers.url_fetch`` / ``og_fetcher``.
* **Toujours des sources** : chaque résultat porte titre + URL + extrait, pour
que l'IA puisse citer au lieu d'inventer.
La couche réseau est injectable (``transport``) pour que les tests n'aient
jamais besoin du réseau.
"""
from __future__ import annotations
import html as htmlmod
import logging
import re
from typing import Any
from urllib.parse import parse_qs, urlparse
from app.config import settings
from app.services.http_client import shared_client
logger = logging.getLogger(__name__)
USER_AGENT = "FlowDeck-Agent/1.0 (+web_search)"
#: Domaines de repli interdits : réponses « sous conditions » / anti-bot qui
#: pollueraient les résultats (DuckDuckGo sert parfois une page de challenge).
_BLOCKED_RESULT_HOSTS = frozenset({
"duckduckgo.com", "lite.duckduckgo.com", "duckduckgo.com.yahoo.net",
})
EXA_ENDPOINT = "https://api.exa.ai/search"
DDG_ENDPOINT = "https://lite.duckduckgo.com/lite/"
GITHUB_API = "https://api.github.com"
MAX_RESULTS = 10
_SNIPPET_CHARS = 300
def _clean(text: str | None) -> str:
return re.sub(r"\s+", " ", (text or "")).strip()
def _clip(text: str, limit: int = _SNIPPET_CHARS) -> str:
text = _clean(text)
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
def _is_resultable(url: str) -> bool:
"""Ignore les redirections vers le moteur lui-même et les URLs non http."""
parsed = urlparse(url or "")
if parsed.scheme not in ("http", "https") or not parsed.hostname:
return False
return parsed.hostname.lower() not in _BLOCKED_RESULT_HOSTS
# ══════════════════════ Exa ══════════════════════
async def _search_exa(query: str, num_results: int, transport=None) -> list[dict]:
key = (settings.exa_api_key or "").strip()
if not key:
return []
payload = {
"query": query,
"numResults": max(1, min(num_results, MAX_RESULTS)),
"contents": {"text": {"maxCharacters": 1200}},
}
kwargs: dict[str, Any] = {"timeout": 15.0}
if transport is not None:
kwargs["transport"] = transport
async with shared_client(**kwargs) as client:
resp = await client.post(
EXA_ENDPOINT,
json=payload,
headers={"x-api-key": key, "Content-Type": "application/json",
"User-Agent": USER_AGENT},
)
if resp.status_code != 200:
raise RuntimeError(f"Exa HTTP {resp.status_code}")
results = []
for item in (resp.json() or {}).get("results", []):
url = item.get("url") or ""
if not _is_resultable(url):
continue
results.append({
"title": _clip(item.get("title") or url, 160),
"url": url,
"snippet": _clip(item.get("text") or item.get("summary") or ""),
"published": (item.get("publishedDate") or "")[:10],
"source": "exa",
})
return results[:MAX_RESULTS]
# ══════════════════════ DuckDuckGo (repli sans compte) ══════════════════════
_DDG_ROW_RE = re.compile(
r"<a[^>]+class=\"[^\"]*result-link[^\"]*\"[^>]+href=\"(?P<href>[^\"]+)\"[^>]*>(?P<title>.*?)</a>",
re.I | re.S,
)
_DDG_SNIPPET_RE = re.compile(
r"class=\"[^\"]*result-snippet[^\"]*\"[^>]*>(?P<snippet>.*?)</td>", re.I | re.S
)
_TAG_RE = re.compile(r"<[^>]+>")
def _unwrap_ddg(href: str) -> str:
"""DuckDuckGo emballe les résultats dans ``/l/?uddg=<url encodé>``."""
if not href:
return ""
parsed = urlparse(htmlmod.unescape(href))
if "uddg" in (parsed.query or ""):
values = parse_qs(parsed.query).get("uddg")
if values:
return values[0]
return href
async def _search_duckduckgo(query: str, num_results: int, transport=None) -> list[dict]:
kwargs: dict[str, Any] = {"timeout": 12.0}
if transport is not None:
kwargs["transport"] = transport
async with shared_client(**kwargs) as client:
resp = await client.post(
DDG_ENDPOINT,
data={"q": query, "kl": "wt-wt"},
headers={"User-Agent": USER_AGENT,
"Content-Type": "application/x-www-form-urlencoded"},
)
if resp.status_code != 200:
raise RuntimeError(f"DuckDuckGo HTTP {resp.status_code}")
body = resp.text
links = list(_DDG_ROW_RE.finditer(body))
snippets = [_clean(_TAG_RE.sub("", m.group("snippet"))) for m in _DDG_SNIPPET_RE.finditer(body)]
results = []
for idx, match in enumerate(links):
url = _unwrap_ddg(match.group("href"))
if not _is_resultable(url):
continue
results.append({
"title": _clean(_TAG_RE.sub("", match.group("title"))),
"url": url,
"snippet": _clip(snippets[idx]) if idx < len(snippets) else "",
"published": "",
"source": "duckduckgo",
})
if len(results) >= min(num_results, MAX_RESULTS):
break
return results
# ══════════════════════ API publique ══════════════════════
async def search_web(query: str, num_results: int = 5,
transport=None) -> tuple[list[dict], str]:
"""Recherche web → ``(results, provider)``.
Essaie le provider configuré puis l'autre ; si aucun ne répond, renvoie
une liste vide — c'est au tool de traduire ça en message actionnable pour
le modèle (« configurez EXA_API_KEY ») plutôt qu'en erreur réseau opaque.
"""
q = (query or "").strip()
if not q:
return [], ""
provider = (settings.web_search_provider or "exa").strip().lower()
order = ("exa", "duckduckgo") if provider == "exa" else ("duckduckgo", "exa")
errors: list[str] = []
for name in order:
try:
if name == "exa":
results = await _search_exa(q, num_results, transport)
else:
results = await _search_duckduckgo(q, num_results, transport)
except Exception as exc: # noqa: BLE001 — un provider qui tombe ne doit pas tuer l'autre
logger.info("web_search provider %s failed: %s", name, exc)
errors.append(f"{name}: {exc}")
continue
if results:
return results, name
if errors:
logger.warning("web_search sans résultat — %s", "; ".join(errors))
return [], order[0]
return [], order[0]
def available_providers() -> dict[str, bool]:
"""Quel provider est réellement utilisable (affiché dans les settings)."""
return {
"exa": bool((settings.exa_api_key or "").strip()),
"duckduckgo": True,
"github": True, # sans PAT : 10 req/min suffisent pour un usage agent
}
# ══════════════════════ GitHub ══════════════════════
_GH_KINDS = {
"repositories": "repos",
"repos": "repos",
"code": "code",
"issues": "issues",
}
def _gh_headers() -> dict[str, str]:
headers = {"Accept": "application/vnd.github+json",
"User-Agent": USER_AGENT,
"X-GitHub-Api-Version": "2022-11-28"}
token = (settings.github_token or "").strip()
if token:
headers["Authorization"] = f"Bearer {token}"
return headers
def _gh_item_name(item: dict, kind: str) -> str:
if kind == "repos":
return item.get("full_name") or item.get("name") or ""
if kind == "code":
return item.get("path") or ""
return item.get("title") or f"#{item.get('number')}"
def _gh_item_url(item: dict, kind: str) -> str:
if kind == "code":
# L'API code search ne renvoie pas d'URL exploitable : on reconstruit.
repo = (item.get("repository") or {}).get("full_name", "")
return f"https://github.com/{repo}/blob/HEAD/{item.get('path', '')}" if repo else ""
return item.get("html_url") or ""
def _gh_item_snippet(item: dict, kind: str) -> str:
if kind == "code":
return ""
if kind == "repos":
return _clip(item.get("description") or "")
body = _clean(item.get("body") or "")
return _clip(body) if body else _clip(item.get("title") or "")
async def search_github(query: str, kind: str = "repositories", limit: int = 5,
transport=None) -> list[dict]:
"""Recherche GitHub (repos | code | issues) → liste de résultats sourcés.
Fonctionne sans PAT (limite 10 req/min) ; avec ``GITHUB_TOKEN``, 30 req/min.
"""
q = (query or "").strip()
if not q:
return []
k = _GH_KINDS.get((kind or "").strip().lower(), "repos")
per_page = max(1, min(int(limit or 5), MAX_RESULTS))
kwargs: dict[str, Any] = {"timeout": 15.0}
if transport is not None:
kwargs["transport"] = transport
async with shared_client(**kwargs) as client:
resp = await client.get(
f"{GITHUB_API}/search/{k}",
params={"q": q, "per_page": per_page},
headers=_gh_headers(),
)
if resp.status_code == 403:
raise RuntimeError("GitHub: quota dépassé (403) — configurez GITHUB_TOKEN")
if resp.status_code == 422:
raise RuntimeError("GitHub: requête de recherche invalide (422)")
if resp.status_code != 200:
raise RuntimeError(f"GitHub HTTP {resp.status_code}")
items = (resp.json() or {}).get("items", [])
results = []
for item in items[:per_page]:
results.append({
"title": _gh_item_name(item, k),
"url": _gh_item_url(item, k),
"snippet": _gh_item_snippet(item, k),
"stars": item.get("stargazers_count") if k == "repos" else None,
"state": item.get("state") if k == "issues" else "",
"source": f"github/{k}",
})
return results
+3 -9
View File
@@ -125,13 +125,7 @@
<div class="db-col-menu-item" id="db-col-insert-right"><span>→┤ Insert right</span></div>
</div>
<!-- Side Peek Panel for Database Pages -->
<div id="db-side-peek" class="db-side-peek" style="display:none">
<div class="db-side-peek-resize" id="db-side-peek-resize"></div>
<div class="db-side-peek-header">
<button id="db-side-peek-close" class="db-side-peek-close">×</button>
<button id="db-side-peek-full" class="db-side-peek-full" title="Open full page">↗</button>
</div>
<iframe id="db-side-peek-iframe" src="" style="width:100%;height:100%;border:none"></iframe>
</div>
<!-- Le panneau de peek (#db-side-peek) est créé à la demande par
database_table.js (ensureDbPeek) : une seule source de markup pour
la page de base et les bases embarquées dans un document. -->
</div>
@@ -107,4 +107,6 @@
.db-list-cell{color:var(--text-secondary);font-size:12px;min-width:110px}
</style>
<script type="application/json" id="db-config" nonce="{{ csp_nonce() }}">{{ ({"collection": collection_data.collection, "properties": collection_data.properties, "pages": collection_data.pages, "views": collection_data.views if collection_data.views is defined else []} if collection_data else none) | tojson }}</script>
<link rel="stylesheet" href="/static/css/my_tasks.css?v={{ asset_version }}">
<script data-cfasync="false" src="/static/js/database_table.js?v={{ asset_version }}"></script>
<script data-cfasync="false" src="/static/js/task_db_link.js?v={{ asset_version }}" defer></script>
+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>
+2 -1
View File
@@ -109,7 +109,8 @@
// globales window — probe « Undefined variable: accountsData »).
document.addEventListener('alpine:init', function () { Alpine.data('accountsData', accountsData); });
if (window.Alpine) { Alpine.data('accountsData', accountsData); }
else document.addEventListener('alpine:init', function () { Alpine.data('accountsData', accountsData); });
function accountsData() {
return {
profile: { full_name: '', email: '' },
+96 -3
View File
@@ -2,12 +2,19 @@
<style>
#fd-agent-fab{position:fixed;right:22px;bottom:22px;width:54px;height:54px;border-radius:50%;background:var(--text-primary,#37352F);color:var(--bg-primary,#fff);border:none;cursor:pointer;box-shadow:0 8px 26px rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;z-index:1700;transition:transform .15s,box-shadow .15s}
#fd-agent-fab:hover{transform:scale(1.06);box-shadow:0 12px 34px rgba(0,0,0,.45)}
#fd-agent-panel{position:fixed;top:0;right:0;bottom:0;width:470px;max-width:100vw;background:var(--bg-primary,#191919);border-left:1px solid var(--border,rgba(255,255,255,.1));z-index:1850;display:flex;flex-direction:column;box-shadow:-16px 0 48px rgba(0,0,0,.55);transform:translateX(102%);transition:transform .24s cubic-bezier(.16,1,.3,1)}
#fd-agent-panel{position:fixed;top:0;right:0;bottom:0;width:var(--fd-agent-width,470px);max-width:100vw;background:var(--bg-primary,#191919);border-left:1px solid var(--border,rgba(255,255,255,.1));z-index:1850;display:flex;flex-direction:column;box-shadow:-16px 0 48px rgba(0,0,0,.55);transform:translateX(102%);transition:transform .24s cubic-bezier(.16,1,.3,1)}
#fd-agent-panel.open{transform:translateX(0)}
#fd-agent-panel ::-webkit-scrollbar{width:8px;height:8px}
#fd-agent-panel ::-webkit-scrollbar-thumb{background:rgba(128,128,128,.35);border-radius:4px}
#fd-agent-panel ::-webkit-scrollbar-track{background:transparent}
/* Resize handle (left edge) */
.fd-ap-resize{position:absolute;top:0;bottom:0;left:0;width:6px;cursor:col-resize;z-index:5}
.fd-ap-resize::before{content:"";position:absolute;top:0;bottom:0;left:2px;width:2px;background:transparent;border-radius:2px;transition:background .12s ease}
.fd-ap-resize:hover::before,body.fd-ap-resizing .fd-ap-resize::before{background:var(--accent,#2383E2)}
body.fd-ap-resizing{cursor:col-resize!important;user-select:none}
body.fd-ap-resizing *{cursor:col-resize!important}
/* Header */
.fd-ap-head{display:flex;align-items:center;gap:12px;padding:16px 18px 12px}
.fd-ap-logo{width:36px;height:36px;border-radius:50%;background:var(--text-primary,#37352F);color:var(--bg-primary,#fff);display:flex;align-items:center;justify-content:center;flex-shrink:0;box-shadow:0 2px 8px rgba(0,0,0,.25)}
@@ -173,6 +180,26 @@
.fd-mention-sep{font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--text-dim,#888);padding:8px 14px 2px}
.fd-mention-empty,.fd-mention-load{padding:11px 12px;font-size:12px;color:var(--text-dim,#aaa);text-align:center}
/* Menu + (sections) — réutilise le skin du menu de mentions */
.fd-ap-plus-menu{max-height:min(340px,60vh)}
.fd-ap-plus-menu .fd-mention-item.disabled{opacity:.45;cursor:default;background:none}
.fd-ap-plus-menu .fd-mention-item.disabled:hover{background:none}
.fd-plus-head{display:flex;align-items:center;gap:8px;padding:7px 11px 6px;border-bottom:1px solid var(--border,rgba(255,255,255,.08));margin-bottom:3px}
.fd-plus-back{background:none;border:1px solid var(--border,rgba(255,255,255,.14));color:var(--text,#ddd);border-radius:7px;width:26px;height:22px;cursor:pointer;line-height:1;flex-shrink:0}
.fd-plus-back:hover{background:var(--bg-hover,#2b2b2b)}
.fd-plus-title{font-size:11px;font-weight:600;color:var(--text-secondary,#ddd);text-transform:uppercase;letter-spacing:.4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fd-plus-filter{padding:7px 11px 2px}
.fd-plus-filter input{width:100%;background:var(--bg-tertiary,#232323);border:1px solid var(--border,rgba(255,255,255,.14));border-radius:8px;color:var(--text,#fff);font-size:12.5px;padding:6px 8px;outline:none;box-sizing:border-box}
.fd-plus-form{padding:4px 11px 12px}
.fd-plus-form label{display:block;font-size:10.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim,#999);margin:9px 0 3px}
.fd-plus-form input,.fd-plus-form textarea{width:100%;background:var(--bg-tertiary,#232323);border:1px solid var(--border,rgba(255,255,255,.14));border-radius:8px;color:var(--text,#fff);font-size:12.5px;padding:7px 8px;outline:none;font-family:inherit;box-sizing:border-box}
.fd-plus-form textarea{min-height:110px;resize:vertical;line-height:1.5}
.fd-plus-file{display:none}
.fd-plus-chev{margin-left:auto;color:var(--text-dim,#999);opacity:.75;font-size:15px;flex-shrink:0}
.fd-plus-crumb{display:flex;gap:5px;flex-wrap:wrap;padding:7px 11px 3px;font-size:11px;border-bottom:1px solid var(--border,rgba(255,255,255,.08));margin-bottom:3px}
.fd-plus-crumb-it{color:var(--accent,#2383E2);cursor:pointer;white-space:nowrap}
.fd-plus-crumb-it:hover{text-decoration:underline}
/* Toast */
.fd-ap-toast{position:absolute;left:14px;right:14px;bottom:calc(100% + 10px);background:var(--bg-modal,#1c1c1c);border:1px solid rgba(35,131,226,.5);color:var(--text,#fff);font-size:12px;border-radius:9px;padding:8px 12px;box-shadow:0 10px 30px rgba(0,0,0,.45);z-index:90;display:flex;gap:8px;align-items:center;justify-content:space-between}
.fd-ap-toast.err{border-color:rgba(227,98,98,.55)}
@@ -189,7 +216,7 @@
.fd-agent-msg-markdown ul,.fd-agent-msg-markdown ol{margin:0 0 8px;padding-left:20px}
.fd-agent-msg-markdown li{margin:2px 0}
.fd-agent-msg-markdown code{background:rgba(127,127,127,.18);border-radius:4px;padding:1px 5px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em}
.fd-agent-msg-markdown pre{background:var(--bg-code,#111);border:1px solid var(--border,#333);border-radius:8px;padding:12px;overflow-x:auto;margin:8px 0}
.fd-agent-msg-markdown pre{background:var(--bg-code,var(--bg-tertiary,#1f1f1f));border:1px solid var(--border,rgba(255,255,255,.12));border-radius:8px;padding:12px;overflow-x:auto;margin:8px 0;color:var(--text,#ececec)}
.fd-agent-msg-markdown pre code{background:none;padding:0;font-size:12px;line-height:1.5}
.fd-md-table-wrap{overflow-x:auto;margin:8px 0}
.fd-md-table{border-collapse:collapse;width:100%;font-size:13px}
@@ -211,6 +238,7 @@
{# ── Panel ── #}
<div id="fd-agent-panel" class="fd-ap" :class="{open: open}" x-data="agentPanel()" @keydown.escape.window="close()" role="dialog" aria-label="Agent FlowDeck">
<div class="fd-ap-resize" x-ref="resizeHandle" role="separator" aria-orientation="vertical" aria-label="Redimensionner le panneau" tabindex="0" title="Glisser pour redimensionner — double-clic pour réinitialiser"></div>
<div class="fd-ap-head">
<span class="fd-ap-logo"><svg viewBox="0 0 24 24" width="19" height="19" fill="currentColor" aria-hidden="true"><path d="M12 3l1.9 5.8a2 2 0 0 0 1.3 1.3L21 12l-5.8 1.9a2 2 0 0 0-1.3 1.3L12 21l-1.9-5.8a2 2 0 0 0-1.3-1.3L3 12l5.8-1.9a2 2 0 0 0 1.3-1.3L12 3Z"/></svg></span>
<div class="fd-ap-head-main">
@@ -372,6 +400,71 @@
</template>
</template>
</div>
<div class="fd-ap-mention-menu fd-ap-plus-menu" :class="{show: plusOpen}" x-ref="plusMenu">
<div class="fd-plus-head" x-show="plusSection!=='root'" x-cloak>
<button class="fd-plus-back" type="button" @click="plusBack()" title="Retour" aria-label="Retour">←</button>
<span class="fd-plus-title" x-text="plusTitle"></span>
</div>
<div class="fd-plus-filter" x-show="plusSection==='skills-gallery'" x-cloak>
<input type="search" placeholder="Filtrer les compétences…" aria-label="Filtrer les compétences" x-model="galleryFilter">
</div>
<div class="fd-plus-crumb" x-show="plusSection==='browse'">
<span class="fd-plus-crumb-it" @click="plusGoto(-1)">Racine</span>
<template x-for="(c, ci) in plusCrumb" :key="c.id">
<span class="fd-plus-crumb-it" @click="plusGoto(ci)" x-text="'› ' + c.name"></span>
</template>
</div>
<div class="fd-mention-load" x-show="plusSection==='browse' && plusLoading" x-cloak>Chargement…</div>
<div class="fd-mention-empty" x-show="plusSection==='browse' && !plusLoading && !plusItems.length" x-cloak>Dossier vide</div>
<template x-for="(it, i) in plusItems" :key="it.key">
<div>
<div class="fd-mention-sep" x-show="it._sep" x-text="it._sep"></div>
<div class="fd-mention-item" x-show="!it._sep" :class="{focus: i===plusFocus, disabled: !!it.disabled}"
@click="plusAction(it)" @mouseenter="it.disabled ? null : plusFocus = i">
<span class="fd-mention-ico" x-text="it.icon"></span>
<div class="fd-mention-main">
<div class="fd-mention-lbl" x-text="it.label"></div>
<div class="fd-mention-sub" x-text="it.sub"></div>
</div>
<span class="fd-plus-chev" x-show="it.chev || it._folder">›</span>
</div>
</div>
</template>
<div class="fd-plus-form" x-show="plusSection==='skills-edit'" x-cloak>
<label for="fd-plus-skill-name">Nom</label>
<input id="fd-plus-skill-name" type="text" x-model="skillForm.name" placeholder="ex. synthese-reunion" @keydown.enter.prevent="saveSkill()">
<label for="fd-plus-skill-desc">Description</label>
<input id="fd-plus-skill-desc" type="text" x-model="skillForm.description" placeholder="Une phrase">
<label for="fd-plus-skill-prompt">Prompt (consigne appliquée à la demande)</label>
<textarea id="fd-plus-skill-prompt" x-model="skillForm.prompt_template" placeholder="Fais…"></textarea>
<div class="fd-proposal-bar">
<button class="fd-proposal-btn primary" type="button" @click="saveSkill()">✓ Enregistrer</button>
<button class="fd-proposal-btn" type="button" x-show="skillForm.id" @click="deleteSkill()" x-cloak>✕ Supprimer</button>
<button class="fd-proposal-btn" type="button" x-show="skillForm.id" @click="exportSkill()" x-cloak>⤓ Export</button>
<button class="fd-proposal-btn" type="button" @click="plusBack()">← Retour</button>
</div>
</div>
<div class="fd-plus-form" x-show="plusSection==='connector-new'" x-cloak>
<label for="fd-plus-cn-kind">Type</label>
<select id="fd-plus-cn-kind" x-model="connectorForm.kind" @change="connectorPreset()">
<option value="custom">Connecteur personnalisé (HTTP + clé)</option>
<option value="discord">Discord (bot)</option>
<option value="telegram">Telegram (bot)</option>
<option value="mcp">Serveur MCP (streamable HTTP)</option>
</select>
<label for="fd-plus-cn-name">Nom</label>
<input id="fd-plus-cn-name" type="text" x-model="connectorForm.name" placeholder="ex. Base de connaissances" @keydown.enter.prevent="connectorCreate()">
<label for="fd-plus-cn-url" x-text="connectorForm.hint || 'URL publique (hôtes privés refusés)'"></label>
<input id="fd-plus-cn-url" type="text" x-model="connectorForm.url" placeholder="https://api.exemple.com">
<label for="fd-plus-cn-secret">Clé / jeton — chiffrée, jamais renvoyée</label>
<input id="fd-plus-cn-secret" type="password" x-model="connectorForm.secret" placeholder="sk-…" autocomplete="off">
<div class="fd-proposal-bar">
<button class="fd-proposal-btn primary" type="button" @click="connectorCreate()">✓ Ajouter</button>
<button class="fd-proposal-btn" type="button" @click="plusBack()">← Retour</button>
</div>
</div>
<input type="file" accept="application/json,.json" x-ref="skillImport" class="fd-plus-file" aria-label="Importer un skill JSON" @change="importSkillFile($event)">
</div>
</div>
<div class="fd-ap-chips" x-show="contextChips.length" x-cloak>
<div class="fd-chip-row" x-show="mentionChips.length">
@@ -398,7 +491,7 @@
data-placeholder="Demandez à l'agent… Tapez @ pour référencer une page/base, / pour un skill. Entrée = envoyer, Maj+Entrée = ligne"
@input="onInput()" @keydown="onComposerKeydown($event)" @paste="onPaste($event)"></div>
<div class="fd-ap-toolbar">
<button class="fd-ap-add" type="button" title="Ajouter un élément au contexte (@ / +)" @click="toggleAddPicker()">+</button>
<button class="fd-ap-add" type="button" title="Ajouter au contexte : fichiers, répertoires, compétences…" aria-label="Menu d'ajout au contexte" aria-haspopup="menu" :aria-expanded="plusOpen" @click="toggleAddPicker()">+</button>
<span class="fd-ap-tip" x-text="composerHint"></span>
<div class="fd-ap-tools">
<button class="fd-ap-model" :class="{off: !hasActiveProvider}" type="button" @click="modelOpen=!modelOpen" title="Choisir fournisseur et modèle">
+808 -131
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -104,7 +104,8 @@
// globales window — probe « Undefined variable: cardDetail »).
document.addEventListener('alpine:init', function () { Alpine.data('cardDetail', cardDetail); });
if (window.Alpine) { Alpine.data('cardDetail', cardDetail); }
else document.addEventListener('alpine:init', function () { Alpine.data('cardDetail', cardDetail); });
function cardDetail() {
return {
updateField(field, value) {
+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>
+49 -14
View File
@@ -18,7 +18,7 @@
<style>
/* ── Library layout ── */
.lib-container{max-width:1100px;margin:0 auto;padding:20px 24px;}
/* .lib-container est défini dans app.css (largeur standard des pages) */
.lib-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;}
.lib-header h1{font-size:24px;font-weight:700;color:var(--text-primary);margin:0;}
.lib-header-actions{display:flex;align-items:center;gap:8px;}
@@ -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;}
@@ -150,7 +168,7 @@
.peek-header button{background:transparent;border:none;color:var(--text-secondary);cursor:pointer;padding:6px;border-radius:4px;font-size:13px;display:flex;align-items:center;}
.peek-header button:hover{background:var(--bg-hover);color:var(--text-primary);}
.peek-header button svg{width:15px;height:15px;}
.peek-body{flex:1;overflow-y:auto;padding:16px 20px;}
.peek-body{flex:1;overflow:hidden;padding:0;}
.peek-body .peek-empty{text-align:center;padding:60px 20px;color:var(--text-dim);}
.peek-loading{text-align:center;padding:40px;color:var(--text-dim);}
@@ -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>
@@ -408,7 +437,7 @@
<div class="peek-overlay" :class="{ open: peekItem !== null }" @click.outside="closePeek()">
<div id="lib-peek-resize-handle" style="position:absolute;left:-4px;top:0;width:8px;height:100%;cursor:col-resize;z-index:501;" title="Drag to resize — click to close"></div>
<template x-if="peekItem">
<div style="display:flex;flex-direction:column;height:100%;">
<div style="flex-shrink:0;">
<div class="peek-header">
<button @click="closePeek()" title="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
@@ -445,11 +474,17 @@
</div>
</div>
</div>
<div class="peek-body" style="flex:1;display:flex;flex-direction:column;">
<iframe id="lib-peek-iframe" :src="peekItem ? '/pages/' + peekItem.id + '?embed=1' : ''" style="width:100%;height:100%;border:none;flex:1;"></iframe>
</div>
</div>
</template>
<!-- Le corps et l'iframe sont HORS du <template x-if> : ils existent donc en
permanence, ce qui permet de piloter `src` en JS (le build CSP
d'Alpine n'evalue pas le binding `:src` — l'iframe restait donc vide).
Charger `/pages/{id}?embed=1` : le peek affiche uniquement le
document en mode edition, sans sidebar ni barre de navigation. -->
<div class="peek-body" style="flex:1;display:flex;flex-direction:column;min-height:0;">
<div class="peek-loading" id="lib-peek-loading">Chargement…</div>
<iframe id="lib-peek-iframe" src="about:blank" title="Document" style="width:100%;height:100%;border:none;flex:1;min-height:0;"></iframe>
</div>
</div>
</div><!-- /x-data -->
+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>
@@ -5,6 +5,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% set right_actions %}<span class="topbar-edited" style="cursor:pointer;" @click="edCall('toggleActivityOpen')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn share-btn" @click="edCall('toggleShareOpen')"><span x-show="!edShared()">{{ fd_icon("lock",14) }} Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall('copyPageLink')" title="Copy link">{{ fd_icon("link",14) }}</button><button class="topbar-btn star-btn" @click="edCall('toggleFavorite')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall('toggleMoreOpen')">⋯</button>{% endset %}
{% include '_header.html' %}
{% endblock %} {% block content %}
<h1 class="database-page-title">{{ page.title }}</h1>
{% include "_database_table.html" %}
{% endblock %} {% block scripts %}
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
+5
View File
@@ -8,4 +8,9 @@
{% endblock %}
{% block scripts %}
{% include '_page_editor_scripts.html' %}
{# database_table.js = FlowDeckDB + le panneau de peek des bases : sans cet
include, une base embarquée dans un document restait bloquée à
« Loading database… » dans le peek et l'icône Open de fin de ligne ne
faisait rien. #}
{% include '_database_table_scripts.html' %}
{% endblock %}
+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>
+2 -1
View File
@@ -104,7 +104,8 @@
// globales window — probe « Undefined variable: tableView »).
document.addEventListener('alpine:init', function () { Alpine.data('tableView', tableView); });
if (window.Alpine) { Alpine.data('tableView', tableView); }
else document.addEventListener('alpine:init', function () { Alpine.data('tableView', tableView); });
function tableView() {
return {
sortField: '',
+2 -1
View File
@@ -48,7 +48,8 @@
// globales window — probe « Undefined variable: teamLoad »).
document.addEventListener('alpine:init', function () { Alpine.data('teamLoad', teamLoad); });
if (window.Alpine) { Alpine.data('teamLoad', teamLoad); }
else document.addEventListener('alpine:init', function () { Alpine.data('teamLoad', teamLoad); });
function teamLoad() {
return {};
}
+270 -35
View File
@@ -11,46 +11,94 @@
</div>
</div>
<div x-data="trashData()" style="padding: 0 24px; max-width: 800px;">
<!-- Search -->
<div style="position:relative; margin-bottom:12px;">
<span style="position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--text-dim);">{{ fd_icon("search",14) }}</span>
<input type="text" placeholder="Search pages in Trash" x-model="search"
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 x-data="trashData()" class="lib-container">
<!-- Toolbar : recherche + tri + emplacement + purge -->
<div style="display:flex; align-items:center; gap:8px; margin-bottom:12px;">
<div style="position:relative; flex:1;">
<span style="position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--text-dim);">{{ fd_icon("search",14) }}</span>
<input type="text" placeholder="Search pages in Trash" x-model="search"
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>
<!-- Tri -->
<div class="lib-toolbar-wrap">
<button class="lib-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSort()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
</button>
<div class="lib-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition>
<div class="dd-label">Sort by</div>
<div class="dd-item" :class="{active: sort==='recent'}" @click="setSort('recent')"><span class="check" x-text="sort==='recent' ? '✓' : ''"></span> Recently deleted</div>
<div class="dd-item" :class="{active: sort==='oldest'}" @click="setSort('oldest')"><span class="check" x-text="sort==='oldest' ? '✓' : ''"></span> Oldest first</div>
<div class="dd-item" :class="{active: sort==='name'}" @click="setSort('name')"><span class="check" x-text="sort==='name' ? '✓' : ''"></span> Page name A → Z</div>
</div>
</div>
<!-- Emplacement -->
<div class="lib-toolbar-wrap" x-show="locations.length > 1">
<button class="lib-icon-btn" :class="{active: locOpen || locFilter !== 'all'}" title="Filter by location" @click.stop="toggleLoc()">
{{ fd_icon("folder",16) }}
</button>
<div class="lib-dropdown" x-show="locOpen" @click.outside="locOpen=false" x-transition>
<div class="dd-label">Location</div>
<div class="dd-item" :class="{active: locFilter==='all'}" @click="setLoc('all')"><span class="check" x-text="locFilter==='all' ? '✓' : ''"></span> All locations</div>
<template x-for="l in locations" :key="l">
<div class="dd-item" :class="{active: locFilter===l}" @click="setLoc(l)"><span class="check" x-text="locFilter===l ? '✓' : ''"></span> <span x-text="l"></span></div>
</template>
</div>
</div>
<!-- Purge totale -->
<button class="btn-sm btn-sm-danger" x-show="items.length" @click="emptyTrash()">Empty Trash</button>
</div>
<!-- Filters -->
<div style="display:flex; gap:8px; margin-bottom:16px;">
<button class="trash-filter active">
<span style="color:var(--accent);">{{ fd_icon("user",14) }}</span> Last edited by ▾
</button>
<button class="trash-filter">
<span>{{ fd_icon("folder",14) }}</span> In ▾
</button>
<!-- Sélection + compteur + actions groupées -->
<div style="display:flex; align-items:center; gap:14px; margin-bottom:8px; font-size:13px; color:var(--text-dim);" x-show="items.length">
<label style="display:flex; align-items:center; gap:6px; cursor:pointer;">
<input type="checkbox" style="accent-color:var(--accent); cursor:pointer;" :checked="allSelected" @change="toggleAll()">
<span x-text="selected.length ? selected.length + ' selected' : 'Select all'"></span>
</label>
<span x-text="countLabel"></span>
<div style="margin-left:auto; display:flex; gap:6px;" x-show="selected.length">
<button class="btn-sm" @click="restoreSelected()">Restore</button>
<button class="btn-sm btn-sm-danger" @click="deleteSelected()">Delete</button>
<button class="btn-sm" @click="clearSel()">Clear</button>
</div>
</div>
<!-- Items -->
<div style="min-height:200px;">
<template x-for="item in filteredItems" :key="item.id">
<div class="trash-item">
<input type="checkbox" style="accent-color:var(--accent); cursor:pointer; flex-shrink:0;"
:checked="selected.includes(item.id)" @change="toggle(item.id)">
<span class="trash-item-icon" x-text="item.icon"></span>
<div class="trash-item-info">
<span class="trash-item-name" x-text="item.name"></span>
<span class="trash-item-path" x-text="item.path"></span>
<span class="trash-item-path">
<span x-text="item.path"></span> · <span x-text="ago(item.deleted_at)"></span><span x-text="leftLabel(item.deleted_at)"></span>
</span>
</div>
<button class="trash-item-btn" title="Restore" @click="restore(item.id)">
↩️
</button>
<button class="trash-item-btn" title="Restore" @click="restore(item.id)">↩️</button>
<button class="trash-item-btn" title="Delete permanently" @click="deleteForever(item.id)">
{{ fd_icon("trash",14) }}
</button>
</div>
</template>
<div x-show="filteredItems.length === 0" class="lib-empty">
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("trash",14) }}</div>
<!-- État vide : aucun contenu -->
<div x-show="items.length === 0" class="lib-empty">
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("trash",24) }}</div>
<h3>Trash is empty</h3>
<p>Deleted pages will appear here for 30 days.</p>
</div>
<!-- État vide : filtres sans résultat -->
<div x-show="items.length > 0 && filteredItems.length === 0" class="lib-empty">
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("search",24) }}</div>
<h3>No pages match</h3>
<p x-text="items.length + ' page(s) in Trash — none matches the current search/location filter.'"></p>
<button class="btn-sm" style="margin-top:12px;" @click="clearFilters()">Clear filters</button>
</div>
</div>
<!-- Info -->
@@ -58,42 +106,229 @@
<span>Once a page has been in Trash for 30 days, it will be automatically deleted</span>
<span style="font-size:16px;">ⓘ</span>
</div>
<!-- Confirmation thématique (remplace confirm() du navigateur) — classes
flowdeck-modal définies dans base.html, thème variables du site -->
<div id="fd-trash-confirm" class="flowdeck-modal-overlay" x-show="confirm.show" x-transition.opacity
@click.self="cancelConfirm()" @keydown.escape.window="cancelConfirm()" style="display:none;">
<div class="flowdeck-modal" x-transition role="alertdialog" aria-modal="true" aria-labelledby="fd-trash-confirm-title">
<div class="flowdeck-modal-header">
<h3 id="fd-trash-confirm-title" x-text="confirm.title"></h3>
<button class="flowdeck-modal-close" @click="cancelConfirm()" aria-label="Close">&times;</button>
</div>
<div class="flowdeck-modal-body">
<p x-text="confirm.message"></p>
</div>
<div class="flowdeck-modal-footer">
<button class="flowdeck-modal-btn cancel" @click="cancelConfirm()">Cancel</button>
<button class="flowdeck-modal-btn confirm" @click="acceptConfirm()" x-text="confirm.okText"></button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: trashData »).
if (window.Alpine) { Alpine.data('trashData', trashData); }
else document.addEventListener('alpine:init', function () { Alpine.data('trashData', trashData); });
document.addEventListener('alpine:init', function () { Alpine.data('trashData', trashData); });
function trashData() {
// Rétention alignée sur app/services/trash.py (purge auto quotidienne).
var RETENTION_DAYS = 30;
return {
search: '',
sort: 'recent',
locFilter: 'all',
sortOpen: false,
locOpen: false,
selected: [],
items: [],
// Confirmation thématique (remplace confirm() du navigateur)
confirm: { show: false, title: '', message: '', okText: 'Delete', action: null },
async init() {
const csrf = getCsrf();;
const token = csrf;
try {
const r = await fetch('/board/api/trash', { headers: { 'X-CSRF-Token': token } });
const r = await fetch('/board/api/trash', { headers: { 'X-CSRF-Token': getCsrf() } });
if (!r.ok) throw new Error('HTTP ' + r.status);
this.items = await r.json();
} catch(e) { this.items = []; }
} catch (e) {
this.items = [];
window.showToast('Could not load the Trash: ' + e.message, 'error');
}
},
get locations() {
const set = {};
this.items.forEach(function (i) { set[i.path || 'Private'] = true; });
return Object.keys(set).sort();
},
get filteredItems() {
const q = this.search.toLowerCase();
return this.items.filter(i => !q || i.name.toLowerCase().includes(q) || (i.path||'').toLowerCase().includes(q));
const loc = this.locFilter;
const out = this.items.filter(function (i) {
const hitQ = !q || i.name.toLowerCase().includes(q) || (i.path || '').toLowerCase().includes(q);
return hitQ && (loc === 'all' || (i.path || 'Private') === loc);
});
const key = function (i) { return i.deleted_at || ''; };
if (this.sort === 'recent') out.sort(function (a, b) { return key(b).localeCompare(key(a)); });
else if (this.sort === 'oldest') out.sort(function (a, b) { return key(a).localeCompare(key(b)); });
else out.sort(function (a, b) { return a.name.localeCompare(b.name); });
return out;
},
get countLabel() {
const n = this.filteredItems.length, t = this.items.length;
return n === t ? t + ' page' + (t === 1 ? '' : 's') : n + ' of ' + t + ' pages';
},
get allSelected() {
const list = this.filteredItems;
const self = this;
return list.length > 0 && list.every(function (i) { return self.selected.includes(i.id); });
},
toggle(id) {
const i = this.selected.indexOf(id);
if (i >= 0) this.selected.splice(i, 1); else this.selected.push(id);
},
toggleAll() {
this.selected = this.allSelected ? [] : this.filteredItems.map(function (i) { return i.id; });
},
clearSel() { this.selected = []; },
clearFilters() { this.search = ''; this.locFilter = 'all'; },
toggleSort() { this.sortOpen = !this.sortOpen; },
toggleLoc() { this.locOpen = !this.locOpen; },
setSort(v) { this.sort = v; this.sortOpen = false; },
setLoc(v) { this.locFilter = v; this.locOpen = false; },
// ── Confirmation (modal thème FlowDeck, classes flowdeck-modal de base) ──
// Mutations de propriétés individuelles (remplacer l'objet entier tue la
// réactivité Alpine — pitfall connu).
askConfirm(title, message, okText, action) {
this.confirm.title = title;
this.confirm.message = message;
this.confirm.okText = okText || 'Delete';
this.confirm.action = action;
this.confirm.show = true;
},
cancelConfirm() {
this.confirm.show = false;
this.confirm.action = null;
},
acceptConfirm() {
const action = this.confirm.action;
this.confirm.show = false;
this.confirm.action = null;
if (action) action();
},
// ── API ──────────────────────────────────────────────────────────
// getCsrf() renvoie la CHAÎNE du jeton (base.html) : l'ancien code faisait
// csrf?.[1] → 2e caractère → 403 CSRF sur restore/delete, silencieux.
_headers() { return { 'X-CSRF-Token': getCsrf() }; },
_drop(ids) {
this.items = this.items.filter(function (i) { return !ids.includes(i.id); });
this.selected = this.selected.filter(function (id) { return !ids.includes(id); });
},
async _restore(id) {
const r = await fetch('/board/api/trash/' + id + '/restore', { method: 'POST', headers: this._headers() });
if (r.ok) this._drop([id]);
return r.ok;
},
async _delete(id) {
const r = await fetch('/board/api/trash/' + id, { method: 'DELETE', headers: this._headers() });
if (r.ok) this._drop([id]);
return r.ok;
},
async restore(id) {
const csrf = getCsrf();;
const r = await fetch(`/board/api/trash/${id}/restore`, { method: 'POST', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
try {
if (await this._restore(id)) window.showToast('Page restored', 'success');
else window.showToast('Restore failed', 'error');
} catch (e) { window.showToast('Restore failed: ' + e.message, 'error'); }
},
async deleteForever(id) {
if (!confirm('Permanently delete this page? This cannot be undone.')) return;
const csrf = getCsrf();;
const r = await fetch(`/board/api/trash/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
async restoreSelected() {
const ids = this.selected.slice();
let ok = 0;
for (const id of ids) {
try { if (await this._restore(id)) ok++; } catch (e) { /* compté en échec */ }
}
window.showToast(ok + ' of ' + ids.length + ' page(s) restored', ok === ids.length ? 'success' : 'error');
},
async _deleteAndToast(ids) {
let ok = 0;
for (const id of ids) {
try { if (await this._delete(id)) ok++; } catch (e) { /* compté en échec */ }
}
const msg = ids.length === 1
? (ok ? 'Page permanently deleted' : 'Delete failed')
: ok + ' of ' + ids.length + ' page(s) deleted';
window.showToast(msg, ok === ids.length ? 'success' : 'error');
},
deleteForever(id) {
const self = this;
const item = this.items.find(function (i) { return i.id === id; });
const name = item ? item.name : 'this page';
this.askConfirm('Delete permanently',
'“' + name + '” will be deleted for good. This cannot be undone.',
'Delete',
function () { self._deleteAndToast([id]); });
},
deleteSelected() {
const self = this;
const ids = this.selected.slice();
this.askConfirm('Delete ' + ids.length + ' page(s) for good?',
'These pages will be permanently deleted. This cannot be undone.',
'Delete all',
function () { self._deleteAndToast(ids); });
},
async emptyTrash() {
const self = this;
const n = this.items.length;
this.askConfirm('Empty Trash?',
'All ' + n + ' page(s) in the Trash will be permanently deleted. This cannot be undone.',
'Empty Trash',
function () { self._emptyAndToast(n); });
},
async _emptyAndToast(n) {
try {
const r = await fetch('/board/api/trash/empty', { method: 'POST', headers: this._headers() });
if (!r.ok) throw new Error('HTTP ' + r.status);
this.items = [];
this.selected = [];
window.showToast(n + ' page(s) purged', 'success');
} catch (e) { window.showToast('Empty Trash failed: ' + e.message, 'error'); }
},
// ── Dates (deleted_at : CURRENT_TIMESTAMP UTC ou ISO sans fuseau) ──
_ts(s) {
if (!s) return null;
let t = String(s).replace(' ', 'T');
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(t)) t += 'Z';
const d = new Date(t);
return isNaN(d.getTime()) ? null : d;
},
ago(s) {
const d = this._ts(s);
if (!d) return 'Deleted';
const sec = (Date.now() - d.getTime()) / 1000;
if (sec < 60) return 'Deleted just now';
if (sec < 3600) return 'Deleted ' + Math.floor(sec / 60) + ' min ago';
if (sec < 86400) return 'Deleted ' + Math.floor(sec / 3600) + ' h ago';
const days = Math.floor(sec / 86400);
return 'Deleted ' + days + ' day' + (days === 1 ? '' : 's') + ' ago';
},
leftLabel(s) {
const d = this._ts(s);
if (!d) return '';
const left = RETENTION_DAYS - Math.floor((Date.now() - d.getTime()) / 86400000);
if (left <= 0) return ' · auto-delete soon';
return ' · ' + left + ' day' + (left === 1 ? '' : 's') + ' left';
}
};
}

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