Compare commits

..
22 Commits
Author SHA1 Message Date
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 0f86294abc merge: main → develop (sync) 2026-07-20 11:02:42 -04:00
81 changed files with 6495 additions and 478 deletions
+1
View File
@@ -23,6 +23,7 @@ uv.lock
*.db-*
test-commit.md
upload_test.txt
commit_msg.txt
e2e/node_modules/
e2e/shots/
e2e/test-results/
+641
View File
@@ -1,5 +1,646 @@
# Changelog - FlowDeck
## v7.69.0 (2026-10-08) — La sélection multi-blocs fonctionne enfin (+ identité visuelle)
### Fixed
- **Ctrl+C / Ctrl+X multi-blocs ne faisaient RIEN** — cause racine : le navigateur
ne sait pas sérialiser une sélection qui traverse deux `contenteditable` (chaque
bloc en a un). Résultat : `getSelection().toString()` renvoie `""` et Chromium
**n'émet aucun événement `copy`** — nos handlers `ct.addEventListener('copy')`
n'étaient donc jamais atteints, et le raccourci mourait en silence.
→ le raccourci est maintenant intercepté au `keydown` (capture sur `document`,
garde `closest('#_blocksCt')` pour ne pas voler Ctrl+C ailleurs) : on écrit le
markdown dans le presse-papier nous-mêmes. La sélection mono-bloc reste native.
- `Ctrl+X` supprime les blocs traversés en conservant la tête du premier et la
queue du dernier.
- **Glisser la souris d'un bloc à l'autre sélectionnait un seul bloc** — cause
racine : la séquence complète est `mouseup` (on reconstruit la plage) → `click`
→ `focusin` → **le navigateur efface la sélection**. La plage était donc
reconstruite puis détruite dans la foulée. → la plage reconstruite est mise en
attente (`_mdSelRange`) et restaurée au tour de boucle suivant, si le focus
l'a effectivement effacée. Un nouveau geste (mousedown) remet l'attente à zéro,
donc un clic pour placer le curseur reste normal.
- **Le clic droit effaçait le surlignage** — la recherche du menu prenait le
focus 10 ms après son ouverture, ce qui détruisait la sélection. → on ne vole
plus le focus quand un texte est sélectionné. (Les actions Clipboard utilisaient
déjà la sélection figée à l'ouverture du menu : `d._clipSt`.)
- Le `preventDefault` du clic droit est bien sur `mousedown` (sur `contextmenu`
il arrive trop tard — le curseur a déjà déplacé la sélection).
### Added
- **Identité visuelle** : la bannière et le logo du projet sont intégrés.
- assets optimisés : `static/img/banner.webp` **22,5 Ko** (l'original fait
1,6 Mo) + `banner.jpg` de secours, `logo.webp` **5,3 Ko** + `logo-512.jpg` ;
originaux conservés dans `assets/` pour régénération.
- icônes PWA régénérées à partir du vrai logo (72 → 512 px, marge maskable
sur 192/512) + `apple-touch-icon`.
- landing : logo dans la barre de navigation, bannière en `<picture>`
(WebP + JPEG) avec dimensions déclarées pour éviter tout décalage de mise en
page ; `og:image`, `twitter:card` et `meta description` ajoutés au `<head>`.
### Tests
- `tests/test_v769_selection_persistence.py` (19 tests statiques) +
`e2e/v769_selection_persistence.spec.js` (5 tests, **vrais gestes** : glisser
de souris, `Ctrl+C`/`Ctrl+X` au clavier, clic droit).
- `e2e/v768_columns_clipboard.spec.js` : les gestes simulés (`execCommand`,
plage fabriquée en `evaluate`) sont remplacés par de vrais gestes.
## v7.68.0 (2026-10-08) — Colonnes rendues, tables IA, copier/couper/coller multi-blocs
### Fixed
- **Colonnes / équation / TOC n'étaient JAMAIS rendus** : `renderBlock()`
n'appelait pas `renderColumnsBlock()` / `renderMathBlock()` / `renderTOC()`
(3 renderers morts depuis l'extraction v7.22) → un bloc colonnes — créé par
le slash « Column list », par **Turn into → 2/3/4/5 colonnes** — tombait dans
le rendu générique : un paragraphe vide. Dispatch ajouté.
- la **× de suppression de colonne** était en `opacity:0` **sans règle de
survol** (invisible mais cliquable) → visible au survol du bloc colonne.
- **Tables rendues par l'IA** : `_tblIsData`/`_tblIsSep` exigeaient un `|` de
tête → le GFM sans pipe (`A | B` / `--- | ---`, ce que produit souvent le
modèle) devenait des paragraphes. Le pipe de tête est **optionnel** en GFM ;
garde conservée : une ligne de séparation doit faire ≥2 cellules (un « texte »
suivi d'un « --- » reste un séparateur de section). `md2b` et `paste2b`
partagent les helpers → alignés d'office.
- **Coller un SEUL bloc structuré** (table, code, titre) retombait en texte
brut (`if(parsed.length<=1) return fallback()`) → seul un bloc paragraphe
(une ligne) garde le comportement inline.
- **« Copy link to block »** du menu de bloc : `data-bm-act="link"` alors que
le handler attend `'copy-link'` → l'action ne faisait rien.
### Added
- **Copier / couper une sélection de texte MULTI-BLOCS** :
- `_clipState()` (sélection vivante traversant ≥2 blocs) + `_clipMarkdown()`
(début partiel → blocs entiers sérialisés par `blocksToMarkdown` → fin
partielle) ; `clipCopy()` / `clipCut()` (la coupure garde la tête du 1ᵉʳ
bloc et la queue du dernier, le milieu part) ;
- listeners **`copy` / `cut`** sur `#_blocksCt` : Ctrl+C / Ctrl+X passent par
les événements natifs du navigateur ; dans un seul bloc, on laisse le
navigateur faire (copie native) ;
- groupe **Clipboard** (`Cut Ctrl+X` / `Copy Ctrl+C` / `Paste Ctrl+V`) en
tête du menu du clic droit, avec la sélection **figée à l'ouverture** du
menu (la recherche prend le focus et détruit la sélection) ;
- clic droit **sur un bloc sélectionné** → la sélection multi-blocs (handle +
Maj-clic) est conservée et le Clipboard agit dessus (`copySelectedBlocks`) ;
- `pasteBlocks()` colle après le dernier bloc quand aucun bloc n'est focalisé.
### Tests
- `tests/test_v768_columns_clipboard.py` (4).
- **e2e** `e2e/v768_columns_clipboard.spec.js` (4) : slash « Column list » +
« Add column » + **Turn into → 2 colonnes** (bloc `.block-column` réellement
rendu), table IA **sans pipe de tête** (vrai `.ftable-editor` + garde
« texte »/« --- »), copier→`readText` → couper (blocs réduits à la tête +
queue) → coller (blocs revenus), clic droit → groupe Clipboard → Copy.
- le raccourci clavier n'est pas déclenché en headless Chromium (pas de
presse-papier OS) : le test passe par `document.execCommand('copy'/'cut')`,
qui émet les **mêmes** événements que Ctrl+C/X.
- `pytest tests/ -q -n auto` → **1394 passed, 0 failed**, `ruff check app tests`
→ OK.
## v7.67.0 (2026-10-08) — Réactions dans le tiroir Comments (+ suppression) et refonte du menu de sélection
### Added
- **Les réactions vivent dans le tiroir « Comments »** : section **Reactions**
au-dessus des fils — émoji, compte (si >1) et **texte ancré** lu dans le DOM
(`reactionList()` / `_anchorText()`, offsets source hors pastilles). L'état
vide « No comments yet » ne s'affiche plus s'il reste des réactions.
- **Suppression d'une réaction — deux entrées, un seul chemin** :
- **✕** sur la ligne de la section Reactions (visible uniquement sur **mes**
réactions) ;
- au **survol de la pastille**, l'émoji que j'ai posé devient une **croix**
(`::after`, aucun nœud ajouté → offsets source inchangés), un clic retire.
- les deux appellent `removeReaction()` → `POST /reactions` (toggle) →
`loadReactions()` + repeindre les ancres.
### Changed
- **Refonte du menu de sélection façon capture** :
- ligne 2 `[A] B I U T Tx` (le barré est conservé — absent de la maquette,
décision volontaire pour ne pas perdre la fonction) ;
- ligne 3 `🔗 S </> √x …` (la réaction en est sortie) ;
- **ligne 4 = une rangée scannée** : `💬 Comment` (libellé) à gauche,
`🙂+` réaction à droite (`.ft-row-split`) ;
- entête **Skills** avec `≡` à droite (`.ft-skills-head::after`), liste
**scrollable** (`.ft-skills`, `max-height` + `overflow-y`) ;
- **✨ Ask AI retiré** de la toolbar : doublon exact de « Edit with AI » du
pied (même `fmtAskAI()`).
### Fixed
- **Flake de gate corrigé (racine)** : `test_v55.py::TestOGParser::
test_fetch_og_metadata_success` dépendait d'un **vrai** `socket.getaddrinfo
("example.com")` via le garde SSRF `_is_public_host` ; sous `pytest -n auto`
un `socket.timeout` (non couvert par `except socket.gaierror`) remontait
dans le `except Exception` de `fetch_og_metadata` → repli silencieux
(`title='example.com'`) → AssertionError, 3 fois sur 5 runs.
- `_is_public_host` catch désormais **`OSError`** : le garde renvoie un
booléen, il ne lève jamais (les autres appelants gagnent aussi) ;
- fixture `stub_dns` sur `TestOGParser` (même motif que
`test_agent_web_tools.stub_dns`) : zéro DNS dans les tests OG.
### Tests
- `tests/test_v767_reactions_drawer.py` (3) ; `tests/test_v762_selection_toolbar.py`
(ligne 4 + styles) ; `tests/test_v764_comments_reactions.py` (position de
`fmtReact`).
- **e2e** : `v764_comments_reactions` étendu — section Reactions dans le tiroir
(texte ancré), **suppression A** (✕ du tiroir → pastille disparue),
**suppression B** (croix `::after` visible seulement au survol, puis clic) ;
`v762_selection_toolbar` — `Comment` + `ft-row-split .ft-react`,
`ft-skills-head` = « Skills », plus de bouton ✨.
- `pytest tests/ -q -n auto` → **1390 passed, 0 failed** (flake corrigé),
`ruff check app tests` → OK.
## v7.66.0 (2026-10-08) — Escape du menu slash retire le « / » + bouton « commenter » à droite du bloc
### Added
- **Bouton « commenter ce bloc » à droite du bloc** (`.block-comment-btn`,
gouttière 64px, visible au survol/focus comme le handle) : icône **bulle de
conversation avec un +**, `onclick="E.commentBlock(idx)"` → tiroir de
commentaires ancré sur le bloc (ou sur la sélection vivante s'il y en a une).
Icône = tracé `message-square` (Feather) + `+` centré.
Masqué sur mobile (2 breakpoints, comme le handle) et sur page verrouillée.
- remplace l'ancien `.block-actions` de `renderBlock` : rendu en `opacity:0`
**sans règle de survol** (jamais de classe `.visible`) → un `+` invisible
qui capait les clics dans la gouttière droite.
### Fixed
- **Escape ferme le menu slash ET retire le « / »** : nouvelle `SM.escClose()`
appelée par les **deux** chemins de fermeture (focus dans le bloc → `onKd`,
focus dans l'entrée du menu → listener `document`). Ne touche au caractère
que si c'est le `/` d'ouverture, remet le caret et sauvegarde (`sync` +
`autoSave`).
- **`fmtComment()` restaure la sélection de la toolbar** (`_fmtRestore()`)
avant d'ancrer : `commentBlock()` ne se replie plus sur `this._sel` (plage
potentiellement périmée reprise par le bouton de droite / le menu de bloc).
### Tests
- **e2e** `e2e/v766_slash_esc_comment_btn.spec.js` : « / » → menu visible →
Escape → menu fermé et bloc vide ; bouton présent, `opacity` 0 → 1 au
survol, **à droite** du wrapper (boîte mesurée), clic → tiroir → ancre = le
bloc entier.
- `tests/test_v766_slash_esc_comment_btn.py` (3) ; `pytest tests/ -q -n auto`
→ **1386 passed**, `ruff check app tests` → OK.
## v7.65.0 (2026-10-08) — Comment ancré sur la SÉLECTION de texte (pas le bloc) + retrait du bouton bleu « 💬 Comment »
### Changed
- **`commentBlock()` ancre sur la sélection de texte** : la plage vivante dans le
bloc est utilisée d'abord (plage de la toolbar de sélection en repli, le clic
sur le bouton ayant pu la faire disparaître du DOM), sinon tout le bloc.
Les offsets passent par `_srcOffset()` → `anchor_start/anchor_end` = offsets
dans le texte source, donc l'ancre jaune ne couvre que le texte sélectionné
(`_paintAnchors` / réactions inchangés).
- **Retiré le bouton bleu flottant « 💬 Comment »** (`#_commentSelBtn`) qui
apparaissait au-dessus de la sélection : le déclencheur est le bouton 💬 de
la toolbar de sélection, le menu de bloc (💬 Comment, Ctrl+⇧+M) et le raccourci.
`showCommentBtn()`/`hideCommentBtn()` et le listener `mousedown` associés sont
supprimés.
- Fermer le tiroir (`toggleComments`) remet `_commentSel` à zéro : un brouillon
abandonné ne peut plus attacher le commentaire suivant à une ancienne plage.
### Tests
- **e2e** `e2e/v764_comments_reactions.spec.js` : sélection d'une **sous-chaîne**
(`reaction a un`) → toolbar 💬 → tiroir → envoi ; assert `#_commentSelBtn`
absent et `.fd-anchor[data-cid]` = **uniquement** la plage sélectionnée
(avant **et** après reload).
- `pytest tests/ -q -n auto` → **1383 passed**, `ruff check app tests` → OK.
## v7.64.1 (2026-10-08) — Fix : Delete sur un bloc vide supprime le bloc
### Fixed
- **La touche Delete ne supprimait pas le bloc vide** : seul `Backspace` avait la
garde « bloc sans texte → supprimer le bloc ». La branche couvre désormais les
**deux touches** (`onKd`, une seule garde partagée) — Delete vide le bloc puis
le supprime au coup suivant, comme Backspace ; le dernier bloc de la page n'est
jamais supprimé (l'éditeur garde un paragraphe).
- Le vide est jugé avec **`gtTok(el)`** (le texte lu à la sauvegarde) et non
`textContent` : une pastille de réaction seule dans le bloc ne l'empêche plus
d'être supprimé.
### Tests
- **e2e** `e2e/v7641_delete_key.spec.js` : texte → Delete (bloc vidé, pas
supprimé) → Delete (bloc supprimé, le précédent reste) → dernier bloc vide
jamais supprimé.
## v7.64.0 (2026-10-08) — Commentaires façon Notion : ancres jaunes, tiroir refondu, réactions sur texte
### Added
- **Réactions emoji sur un texte sélectionné** :
- bouton **🙂+** dans la 3ᵉ ligne de la toolbar de sélection, infobulle
« React to selected text » → ouvre le **sélecteur d'émoji existant** en mode
réaction : en-tête = texte sélectionné (+ ✋), onglets masqués (emoji only),
ligne **Recent** au-dessus de la grille **People**, Filter… et barre de
catégories inchangées (dont `+` émoji custom).
- **stockage** : migration 37 (`text_reactions`, une ligne par plage × émoji ×
utilisateur, `UNIQUE` qui rend le POST idempotent) + endpoints
`GET/POST /api/pages/{id}/reactions` (GET groupé par plage : `emoji/count/mine`,
POST = toggle avec validation de plage non vide).
- **affichage** : la plage est peinte en jaune (`.fd-anchor-react`) avec une
**pastille** juste après (`😀` + compte si >1) ; clic sur la pastille →
réouvre le sélecteur pour cette plage (ajout/suppression).
- les pastilles ne sont **pas** du contenu : `gtTok`/`gtMd` les ignorent et les
offsets `_srcOffset/_srcNodeLen` les excluent → `sync()` conserve le texte
d'origine (vérifié dans le e2e) et les ancres ne décalent jamais.
- **Ancres de commentaires visibles** — `_paintAnchors()` (appelé après chaque
`render()`, après `loadComments` et `loadReactions`) entoure la plage commentée
d'un **jaune** (`.fd-anchor`, gris si résolu) ; **clic sur l'ancre → le tiroir
s'ouvre et centre le fil** (flash de surlignage).
- **Tiroir de commentaires refondu** (image 1) : fil en carte arrondie avec
**avatar coloré + nom + horodatage + ✓ (resolve/reopen) + ⋯** (menu Copy link /
Delete), étiquette « 💬 on selected text », et champ de saisie **« Add a
comment... »** avec **@** (insère la mention) et **bouton circulaire ↑** (envoi,
`Entrée` aussi) — remplace le bouton « Comment ».
- **Liens de commentaire** : `Copy link` copie `…#cmt-<id>` ; au chargement d'une
page avec ce hash, le fil correspondant s'ouvre et se centre.
- **Chargement initial** : `loadComments()` + `loadReactions()` à l'ouverture de la
page → les ancres/pastilles sont peintes dès le premier rendu (et `commentCount`
du tiroir est enfin renseigné).
### Fixed
- **`resolveComment` ne pouvait pas rouvrir** : il envoyait toujours
`resolved:true` → accepte désormais l'objet du fil et bascule
(`resolveComment(c)`), le bouton ✓ du fil est donc réellement un toggle.
- **`showCommentBtn` ignorait les sélections à ancre « élément »** (double-clic,
sélection programmatique) → `closest('[data-bid]')` partait du wrapper et le
bouton 💬 n'apparaissait pas → gère les 2 types de nœud (même fix que `showFmt`
en v7.62).
- **`gtTok` chemin rapide (`innerText`) incluait les pastilles de réaction** →
sélecteur enrichi (`.fd-react-chip`) pour basculer sur le marcheur qui les
exclut : le texte sauvé reste identique.
### Tests
- `tests/test_v764_comments_reactions.py` (8 tests) : table + migration 37,
endpoints enregistrés, **toggle API réel** (added → removed → added, grouping,
400 sur plage vide), markup toolbar/tiroir, peinture des ancres, mode réaction
du sélecteur.
- **e2e Playwright** `e2e/v764_comments_reactions.spec.js` : bouton 🙂+ avec la
bonne infobulle → sélecteur (en-tête = texte, Filter…, onglets masqués) →
pastille + plage jaune **avec contenu inchangé après `sync()`** → 💬 → tiroir
(avatar, nom, ✓, ⋯, Copy link) → ancre jaune cliquable → ✓ résout → reload :
ancres et pastilles rechargées depuis la base ; **3/3 specs e2e verts**
(`--workers=1`).
- **Suite complète** : `pytest tests/ -q -n auto` → **1383 passed / 0 failed**,
`ruff check app tests` → All checks passed.
## v7.63.0 (2026-10-08) — Menus mobiles façon Notion (barre au « / » + Insert block plein écran)
### Added
- **Barre horizontale mobile** — sur un viewport ≤ 768 px, taper `/` sur une ligne
vide affiche une **barre flottante défilante sous le curseur** (au lieu de la popup
desktop), 15 boutons dans l'ordre Notion : `+▾`, `Turn into▾`, `@`, 💬, 🖼, 🗑,
`←`, `→`, `↑`, `↓`, `↩`, `↪`, `Color▾`, `More▾`, `⌨` :
- `+▾` → **feuille plein écran « Insert block »** : titre centré, bouton bleu
« Cancel », liste verticale par groupes (Basic blocks / AI writing / Media /
Data / Synced / Actions) avec icône, nom et **raccourci markdown** à droite
(`#`, `##`, `-`, `1.`, `[]`, `>`, `"`, `---`…) — items partagés avec la popup
desktop (`SM._slashItemHtml`), choix → `applySlash` → tout se referme.
- `Turn into▾` / `Color▾` → feuilles réutilisant **les mêmes générateurs d'items**
que les sous-menus du menu de bloc (`_blockTurnItems` / `_blockColorItems`
extraits de `_blockTurnMenuOpen` / `_blockColorMenuOpen`).
- `More▾` → menu contextuel de bloc existant (recherche + 10 actions + pied).
- `@` insère `@` et ouvre le sélecteur de mention ; 💬 commentaire ; 🖼 insère un
bloc image ; 🗑 supprime ; `⌨` masque le clavier ; `↩ ↪` annuler/rétablir.
- **Déplacement de bloc `moveBlock(bid, dir)`** — `↑↓` réordonne (y compris dans un
parent toggle/columns), `→` indente dans le frère précédent (toggle/columns),
`←` ressort à la racine ; garde-fous avec toast (déjà en haut/bas, déjà à la
racine, pas de cible).
- **Fermeture automatique** de la barre quand le `/` n'est plus au début du bloc ;
taps dans la barre/la feuille n'essaient plus de fermer le menu (exclusion
`MTB.contains` sur le handler mousedown hors-zone).
- **Raccourcis slash alignés sur les préfixes markdown** (description Notion) :
`-` (puces), `[]` (à faire), `>` (toggle), `---` (séparateur) — partagés avec la
popup desktop ; groupe `SYNCED` libellé « Synced ».
### Tests
- `tests/test_v763_mobile_menus.py` : ordre des 15 boutons, câblage de chaque
action, structure de la feuille (titre/Cancel/groupes), branchement mobile,
styles — et **`moveBlock` exécuté pour de vrai sous node** (ordre, indent,
outdent, garde-fous).
- **e2e Playwright mobile** `e2e/v763_mobile_menus.spec.js` (viewport 390×844,
`hasTouch`) : barre visible au `/` avec les 15 boutons dans l'ordre + `overflow-x:auto`
et popup desktop masquée, « Insert block » (groupes, raccourci `#` de Heading 1)
→ le bloc devient `heading_1`, « Turn into » (coche du type courant), « Color »
(3 groupes), « More » (menu de bloc + pied), `↑` qui réordonne réellement les blocs.
- `tests/test_v761_block_menu.py` adapté aux extractions `_blockTurnItems` /
`_blockColorItems`.
## v7.62.0 (2026-10-08) — Menu contextuel au highlight de texte (toolbar façon Notion)
### Added
- **Toolbar de sélection** — dès qu'un texte est surligné dans l'éditeur, un menu
flottant apparaît **sous la sélection** (au-dessus si manque de place), centré et
clampé au viewport, en 4 lignes comme dans Notion :
1. **Style de texte** — `T` + libellé **dynamique** du type de bloc (Normal Text,
Heading 1… ; le type courant) + `›` → ouvre le sous-menu **Turn into** existant
(coche sur le type actuel, fermeture au survol sortant).
2. **A** (sous-menu Color : texte/fond + dernière couleur), **B** gras, *I* italique,
**U** souligné, **T** barré, **Tx** code inline.
3. 🔗 lien, **S** surlignage (`<mark>`), `</>` bloc code, **√x** équation inline
(prompt LaTeX → KaTeX), **…** → menu contextuel de bloc (recherche + 10 actions +
pied « Last edited by … / N words, M characters »).
4. 💬 Comment (`Ctrl+⇧+M`), ✨ Ask AI (`Ctrl+J`).
puis la section grise **Skills** (Improve writing, Proofread, Explain, Reformat →
`runSkill` sur le bloc) et le pied **Edit with AI** `Alt+⇧+E`.
- **Info-bulle claire à droite** des éléments du menu — extension du système de
tooltip existant (`app.js`) : `data-tooltip-html` (ex. en italique + description
pour la ligne 1) + classe `.tooltip-light`, position à droite de l'élément (débord
→ à gauche), sous l'élément hors toolbar.
- **Persistance du formatage inline** : nouveau sérialiseur **`gtMd()`** (DOM formaté
→ source markdown) appelé par `sync()` (blocs + enfants toggle/columns) — le
formatage posé ressort en `**gras**`, `*ital*`, `__souligné__`, `~~barré~~`,
`` `code` ``, `==surlignage==`, `[texte](url)` ; les chips wiki gardent leur token.
`mdEsc()` rend en plus `__…__` (`<u>`), `==…==` (`<mark>`) et `$$…$$`
(équation inline KaTeX, `data-tex` conservé pour l'aller-retour).
- **Fermeture propre** : clic hors sélection → le menu se referme ; clic **dans** la
toolbar → sélection et menu intacts (`@mousedown.prevent` + garde `mouseup`).
### Fixed
- **`askAI(idx)` était écrasé par `askAI()`** (même clé dans l'objet éditeur, définie
plus bas) : « Ask AI » du menu de bloc et `Ctrl+J` ouvraient l'IA de **page** au
lieu du composeur du bloc → renommé **`askAIBlock(idx)`** (appels : menu de bloc,
`Ctrl+J`, `Alt+⇧+E`).
- **`ed.__x` introuvable sous Alpine v3** — le data de `.page-editor-wrapper` était
lu via `ed.__x` (API Alpine v2, plus exposée depuis le bundle CSP) : `showFmt`,
`hideCommentBtn` et les handlers de mentions ne s'exécutaient **jamais** → la
toolbar de sélection n'apparaissait pas. Accès unifié via `_xed()`
(`_x_dataStack[0]`, repli `__x`), 4 sites éditeur.
- **Ancre de sélection** — `closest('[data-bid]')` partait du parent de l'ancre :
quand l'ancre est un nœud **élément** (sélection programmée, double-clic), le bloc
n'était jamais trouvé → `showFmt` pas appelé. Gestion des 2 types de nœud.
- **Barre d'espace = exception** — `checkMd()` recevait l'**id** du bloc au lieu de
l'index : chaque espace levait `Cannot read properties of undefined (reading 'id')`
et les raccourcis markdown (`# `, `- `) ne se déclenchaient jamais.
### Tests
- `tests/test_v762_selection_toolbar.py` : ordre des 4 lignes + Skills + pied,
câblage réel des actions, persistance `gtMd`/`mdEsc` (**roundtrip réel sous node**),
placement/fermeture, styles ; `tests/test_v761_block_menu.py` adapté
(`askAIBlock`).
- **e2e Playwright** `e2e/v762_selection_toolbar.spec.js` (instance 8081) : structure
des 4 lignes, sous-menu Turn into (coche du type courant), menu « … » (recherche +
pied), géométrie (sous la sélection, dans le viewport), **persistance du gras et du
surlignage après reload**, fermeture hors sélection → 1 passed (captures dans
`e2e/shots/` — répertoire gitignoré).
- **Suite complète** : `pytest tests/ -q -n auto` → **1368 passed / 0 failed**,
`ruff check app tests` → All checks passed.
## v7.61.0 (2026-10-08) — Menu contextuel de bloc refondu façon Notion
### Added
- **Panneau de gauche** : barre de recherche « Search actions... » (filtre en
direct), titre de section « Text », puis les 10 actions dans l'ordre Notion :
Turn into ›, Color ›, Copy link to block (`Alt+⇧+L`), Duplicate (`Ctrl+D`),
Move to (`Ctrl+⇧+P`), Delete (`Del`), Comment (`Ctrl+⇧+M`), Suggest edits
(`Ctrl+⇧+Alt+X`), Ask AI (`Ctrl+J`), Skills › — avec les raccourcis
affichés, et le pied de page « Last edited by … / Today at H:MM / N words,
M characters » (auteur lu dans le compte, heure = `updatedAt` du bloc page,
taille calculée sur le contenu du bloc).
- **Sous-menu « Turn into »** (survol du panneau de gauche → panneau de
droite, fermeture différée) : Text ✓ (type actuel coché), Heading 1-4,
Bulleted list, Numbered list, To-do list, Toggle list, Code, Quote,
Callout, Block equation, Synced block, et **2/3/4/5 columns** avec
info-bulle « Create N columns of blocks » ; `turnInto(idx, type, cols)`
crée désormais N colonnes et ouvre le sélecteur de source pour les blocs
synchronisés.
- **Sous-menu « Color »** : sections **Last used** (raccourci `Ctrl+⇧+H`),
**Text color** (Default, Gray, Brown, Orange, Yellow ✓, Green, Blue,
Purple, Pink, Red — glyphe « A » à la couleur) et **Background color**
(même palette avec carré de couleur) ; la couleur utilisée est cochée, la
dernière est mémorisée (`localStorage fdLastBlockColor`).
- **Sous-menu « Skills »** : liste réelle des compétences de l'espace
(`GET /api/agent/skills`) → applique le `prompt_template` au contenu du
bloc via l'IA et insère le résultat dessous.
- **Raccourcis** — handler clavier dédié : `Ctrl+J` (Ask AI), `Ctrl+⇧+H`
(dernière couleur), `Ctrl+⇧+M` (commenter le bloc : sélection du contenu +
tiroir de commentaires), `Ctrl+⇧+Alt+X` (suggérer une édition → résultat
inséré dessous), `Alt+⇧+L` (copier le lien), `Ctrl+⇧+P` (déplacer vers…),
`Ctrl+D` (dupliquer — déjà géré par la saisie, pas de double), `Del`
(**seulement avec le menu ouvert**, jamais pendant la saisie), `Échap`
(fermer).
- **Actions Suggest edits / Ask AI / Comment / Skills** réelles côté éditeur
(`suggestEdit`, `askAI`, `commentBlock`, `runSkill`) — Ask AI ouvre le
composeur inline sans effacer le contenu du bloc.
- **Styles** : `.bm-group`, `.bm-foot`, `.bm-check`, `.bm-current`,
`.bm-dot`, sous-menu `60vh` défilable, recherche focalisée à l'ouverture.
- **Tests** — `tests/test_v761_block_menu.py` : **9 tests** (structure et
ordre du panneau, dispatch des 10 actions, 3 sous-menus, survol/fermeture,
les 9 raccourcis, comportement node : `turnInto(…, 'columns', 3)` → 3
colonnes, styles) + `test_v7593` adapté à la nouvelle signature.
### Removed
- Entrées **Copy / Cut / Paste blocks** du menu (absentes de la spécification
Notion) — les raccourcis `Ctrl+C/X/V` sur blocs sélectionnés sont conservés.
- Pastilles de couleurs inline : remplacées par le sous-menu Color nommé
(palette portée à 10 couleurs alignées Notion : +Brown, Pink réel).
## v7.60.0 (2026-10-07) — Éditeur : bouton + à gauche du handle de bloc
### Added
- **Bouton `+` à gauche du handle** (les 6 points qui ouvrent le menu ⋮⋮ /
servent au drag) : clique → **nouveau bloc inséré sous le bloc courant**,
via le mécanisme déjà en place (`E.addAfter(idx)` → `addAt(idx+1)`).
- Positionné dans la gouttière (`left:-64px`, gouttière `.blocks-container`
de 64px, `-56px` quand elle passe à 32px), apparaît au survol/focus du bloc
comme le handle, masqué avec lui sur écran étroit (≤768px et le second
breakpoint — aucun débordement horizontal ajouté au conteneur.
- `aria-label` + `title` « Add block below » (accessibilité).
- **Tests** — `tests/test_v760_block_insert_btn.py` : **2 tests** (ordre
dans le wrapper, câblage `E.addAfter`, positions/masquages CSS).
## v7.59.3 (2026-10-07) — Correction : « Turn into » du menu contextuel
### Fixed
- **« Turn into… » ne faisait rien** — la garde validait le type avec
`_BLOCK_TURN_TYPES.indexOf(type)` alors que ce tableau contient des **objets**
`{id, name}` : `indexOf` d'une chaîne renvoyait toujours `-1` → `turnInto()`
retournait immédiatement après l'ouverture du sous-menu. Correction :
`_BLOCK_TURN_TYPES.some(t=>t.id===type)`.
- **Texte conservé** — convertir un bloc en **To-do** ou **Toggle** vidait le
contenu (ces deux types étaient exclus de la conservation par erreur) ; seul
un type sans champ texte (`image`, `embed`, `divider`, `button`, `columns`,
…) le vide désormais, comme dans `Notion`.
- **Tests** — `tests/test_v7593_turn_into.py` : **4 tests**, dont 1
comportemental **node** qui exécute la vraie `turnInto()` extraite de la
source : conversion effective du type, texte conservé (To-do = étiquette +
case décochée), contenu vidé pour un type sans champ texte.
## v7.59.2 (2026-10-07) — Corrections : aide, « Insert below », listes numérotées
### Fixed
- **Aide** — bouton **✕** en haut à droite du panneau (classe partagée
`settings-close` : 32 px desktop / **44 px mobile**) + méthode `close()` dans
`help.js` (retour à la page précédente, sinon `/workspaces`) ;
`.settings-panel` passé en `position:relative` pour ancrer les boutons au
panneau (hamburger + ✕) plutôt qu'à la page.
- **Éditeur — « Insert below » (/Write with AI)** : on insère le **markdown
source** conservé à la génération (`AIC._md`) au lieu du texte extrait du
HTML rendu (`_resultText()` ne reste qu'en repli pour les messages d'erreur) —
titres, listes, gras et numéros survivent à l'insertion.
- **Listes numérotées** — elles n'avaient **aucun numéro au rendu** : le CSS
ne déclarait que le retrait, jamais le marqueur. Le rang des blocs
`numbered_list` contigus est désormais calculé au rendu (`data-num`) et
affiché via `content: attr(data-num) ". "` → **`/numbered list` et
l'insertion depuis l'assistant affichent enfin 1, 2, 3**.
- **`md2b`** (markdown → blocs, chemin agent) : les cases à cocher `- [ ]` /
`- [x]` sont testées **avant** la branche des puces (une ligne `- [ ]`
était avalée par cette branche) — aligné sur l'ordre déjà correct de
`paste2b` ; le séparateur `1)` est accepté (CommonMark).
- **Menu slash** — une requête tapée **dans le bloc** (`/numbered list`, focus
qui n'a pas rejoint l'entrée du menu) est répercutée dans le filtre au lieu
d'être ignorée.
- **Tests** — `tests/test_v7592_ui_fixes.py` : **5 tests**, dont 1 test
comportemental **node** qui exécute le vrai `md2b` extrait de la source.
- `ruff` : ordre d'imports de `tests/test_agent.py` (I001 préexistant au pull).
## v7.59.1 (2026-10-07) — Fix fournisseur Mistral (clé valide, 403 tier_not_allowed)
### Fixed
- **Mistral « Test connection » échouait malgré une clé valide** — cause
racine : `mistral-large-latest` (modèle par défaut + tête de liste) n'est
pas servi par les plans d'abonnement basiques → `403 tier_not_allowed`
(code 1910). La clé elle-même fonctionne (`/v1/models` 200, chat 200 sur
small/medium/nemo/codestral — vérifié en direct).
- `PROVIDERS["mistral"]` default → `mistral-small-latest` (tous plans).
- `PROVIDER_MODELS["mistral"]` réordonnée : modèles tous-plans d'abord
(le test de connexion sélectionne le premier candidat).
- `mistral` ajouté à `_CHAT_VALIDATED_PROVIDERS` : le fetch des modèles
sonde chat/completions et exclut les modèles hors plan (46 listés →
25 réellement utilisables avec la clé du compte).
- **Migration 36** : `default_model` des lignes mistral pointant sur un
modèle bloqué réinitialisé (→ nouveau défaut), `llm_config.model` idem.
### Tests
- `tests/test_agent.py::test_mistral_defaults_are_tier_safe` · suite agent
OK · test de connexion live sur l'instance déployée : `{"ok":true,
"model":"mistral-small-latest","reply":"PONG","verified":true}`.
## v7.59.0 (2026-10-07) — Mobile : drawer réglable, side-nav settings, aide refondue
### Fixed
- **Scroll souris du drawer sidebar mobile** — `display:block` du breakpoint
≤768px cassait le `flex:1` de `.sidebar-scroll` (hauteur = contenu, jamais
de zone scrollable). Remis en `display:flex;flex-direction:column`.
- **Rate limiter du routeur de test** — fenêtre remise à zéro à chaque login
: suite e2e via un seul login `beforeAll` + cookies `storageState`.
### Changed
- **Settings mobile : le menu devient une side-navigation gauche** ouverte
par un hamburger (`.settings-menu-btn`) avec backdrop, au lieu des onglets
horizontaux ; fermeture auto au choix de section. Squelette panneau
(overlay/panel/nav) extrait de `settings.html` vers
`/static/css/settings.css` partagé avec l'aide.
- **Page `/help` refondue au complet** — ancien monologue HTML inline de la
route remplacé par `app/templates/help.html` : même panneau 2 colonnes que
settings, nav latérale en 13 sections couvrant toute l'app (démarrer,
éditeur, bases & vues, tâches/dépendances, workspaces/forges, collaboration,
bibliothèque/recherche, agent IA & automations, API v2/webhooks/clipper,
PWA offline, comptes & SSO, raccourcis, tips) ; `static/js/help.js`
(`Alpine.data helpInit`).
### Tests
- Gate e2e `mobile_regression.spec.js` : 6/6 (drawer + wheel scroll +
side-nav settings + aide mobile/desktop) · `test_app.py` +
`test_pwa_offline.py` : 230/230.
## v7.58.0 (2026-10-06) — Add plugins : catalogue on/off à effet réel (phase 8/8)
### Added
- **`app/services/plugins.py` + migration 35** — table `plugins` (`slug`,
`name`, `description`, `enabled`) pré-remplie avec **3 modules câblés** :
`web-tools`, `web-clipper`, `automations`. Ligne absente = activé (défaut
sûr : rien n'est coupé par accident).
- **Effet réel, jamais un simple drapeau** :
- `automations` OFF → dépendance FastAPI posée **à l'`include_router`**
(3 lignes dans `main.py`, aucun router touché) → toutes les routes
`/workspace/automations*` refusées, **et** le scheduler de fond passe en
veille (garde sur chaque tick) ;
- `web-clipper` OFF → `GET /extensions` + tout `/api/v2/web-clipper/*`
refusés ;
- `web-tools` OFF → `web_search` et `fetch_url` retirés du **schéma** de
l'agent et de `execute()` (`ToolRegistry._all()`) : le LLM ne les voit plus.
- **UI masquée côté serveur** — nouveau global Jinja `plugin_enabled(slug)` :
`settings.html` rend la nav « Extensions » / « Automations » sous
`{% if %}` (élément **absent** du DOM, pas seulement caché) et conditionne
les sections en `x-show`.
- **Menu +** — l'entrée « Add plugins » devient une vraie section : liste des
3 plugins (état + description) avec bascule ;
`GET /api/agent/plugins` + `PATCH /api/agent/plugins/{slug}` (slug inconnu →
404, 401 sans session).
- **Tests** — `tests/test_v758_plugins.py` : **10 tests** — catalogue,
persistance en base, routes refusées (302 hors `/api` + 404 JSON pour
`/api*`, handler unifié de `main.py`), outils retirés du registre, nav
disparue du `/settings` rendu, câblage du menu, 401.
## v7.57.0 (2026-10-06) — Connecteurs : Discord, Telegram, Teams, MCP (phase 7/8)
### Added
- **Presets Discord / Telegram** — champ **Type** dans le formulaire « Ajouter
un connecteur » (Personnalisé / Discord / Telegram / Serveur MCP) : URL et
schéma d'authentification pré-remplis. Colonnes `kind` + `auth`
(`bearer`/`bot`/`none`) ajoutées à `agent_connectors` (migration **34**) ;
Discord envoie son jeton préfixé dans l'en-tête d'autorisation, Telegram place
le jeton **dans l'URL** via le substitut `{secret}` — substitué à l'appel,
**jamais stocké en clair** (la colonne url ne contient que le motif).
- **Teams** — scope `ChannelMessage.Read.All` ajouté aux Graph scopes M365
(consentement admin requis à la reconnexion).
- **`app/services/mcp_client.py`** — client MCP (streamable HTTP) : handshake
`initialize` → `notifications/initialized` → `tools/list` → `tools/call`
(JSON-RPC 2.0, réponse JSON **ou** premier `data:` d'un `text/event-stream`),
garde SSRF conservée, erreurs JSON-RPC remontées telles quelles, sortie
bornée à 20 000 car. Les outils sont **cachés** en base (`tools_json`) — le
bouton « Tester le connecteur » rejoue le handshake et met à jour la liste.
- **Outils dynamiques au registre** — `ToolRegistry._all()` merge ce cache à
chaque run : chaque outil est exposé au LLM sous `mcp_<serveur>_<outil>` avec
son `inputSchema` d'origine, et `execute()` dispatche sur `tools/call`.
Serveur désactivé → outil **absent du schéma** ; échec réseau →
`ToolResult(error)` sans casser le run.
- **Menu +** — badge `· N outil(s)` sur la fiche d'un serveur MCP ;
`POST /api/agent/connectors` accepte `kind` + `auth` (400 sur valeurs
inconnues).
- **Tests** — `tests/test_v757_mcp_discord.py` : **12 tests**, 0 appel réseau
réel (`_rpc` / `_get` monkeypatchés) — presets, `{secret}` absent de la base,
handshake + séquence d'appels, cache, dispatch LLM, serveur désactivé,
erreurs RPC, parse SSE, scopes Teams, câblage menu.
## v7.56.0 (2026-10-06) — Connecteurs : Google + Microsoft 365 (phase 6/8)
### Added
+222 -15
View File
@@ -284,7 +284,7 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
---
## v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (phases 1-6 ✅ 2026-10-06 · phases 7-8 planifiées)
## 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é :
@@ -471,26 +471,60 @@ CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j.
(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
### Phase 7 — v7.57.0 — Connecteurs : Discord, Telegram, Teams, MCP · effort L ✅
- [ ] **Discord** (bot token : lecture canaux/DMs), **Telegram** (Bot API),
**Teams** (via Graph de la phase 6)
- [ ] **MCP complet** — handshake `initialize`, `tools/list`, `tools/call`
(streamable HTTP) → outils **dynamiques** ajoutés au registre à la connexion
- [ ] **Tests** — schémas MCP, mapping d'outils, échecs d'auth
- [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
### Phase 8 — v7.58.0 — Add plugins · effort M ✅
- [ ] **Registre `plugins`** (`slug`, `name`, `description`, `enabled`) — état persisté
- [ ] **Catalogue dans le menu +** — bascule on/off à effet **réel** : un plugin
désactivé retire vraiment sa route/UI (au minimum 3 plugins câblés : web tools de
l'agent, web clipper, automations)
- [ ] **Tests** — plugin OFF = route refusée + UI absente (pas seulement un drapeau lu)
- [x] **Registre `plugins`** (`slug`, `name`, `description`, `enabled`) —
migration **35**, table pré-remplie avec les **3 modules** ; ligne absente =
activé (défaut sûr)
- [x] **Catalogue dans le menu +** — l'entrée « Add plugins » devient vivante
(fini le `disabled:true`) : liste des 3 plugins + bascule via
`GET/PATCH /api/agent/plugins[/slug]`
- [x] **Bascule on/off à effet réel** :
- `automations` → **toutes** les routes `/workspace/automations*` refusées
(dépendance FastAPI posée **à l'`include_router`**, aucun router touché) +
scheduler de fond en veille (garde par tick) ;
- `web-clipper` → `GET /extensions` + `/api/v2/web-clipper/*` refusés ;
- `web-tools` → `web_search` / `fetch_url` retirés du **schéma** et de
`execute()` (`ToolRegistry._all()`) ;
- UI → nav « Extensions » / « Automations » rendues sous
`{% if plugin_enabled(...) %}` (global Jinja, élément **absent** du DOM),
sections conditionnées en `x-show`
- [x] **Tests** — `tests/test_v758_plugins.py` : **10 tests** — plugin OFF =
route refusée (302 hors `/api`, 404 JSON pour `/api*`) + UI absente + outil
retiré, persistance, 401 sans session
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.58.0**, OpenAPI régénéré
(525 chemins), CHANGELOG, ce ROADMAP
---
*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 6 livrées le
2026-10-06 (v7.51.0 → v7.56.0)**, phases 7-8 à valider une par une avant « go ».
*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 8 livrées le
2026-10-06 (v7.51.0 → v7.58.0) : le menu + est complet** (aucune section
« bientôt »). Toute extension ultérieure = nouvelle phase à valider.
Chaque phase se clôt par tests verts, bump de version, CHANGELOG et ROADMAP à jour.*
---
@@ -1374,6 +1408,179 @@ 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é.
---
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
+1 -1
View File
@@ -1 +1 @@
7.56.0
7.69.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.50.2 (peek de Library nu — iframe pleine, comme local-workspace) | **Statut**: EN COURS 🔄
> **Début**: 2026-07-08 | **Version**: v7.69.0 (sélection multi-blocs fonctionnelle : drag souris + Maj+↓, clic droit qui la préserve) | **Statut**: EN COURS 🔄
> **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
+11 -5
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
@@ -62,6 +62,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(
@@ -185,7 +186,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.56.0",
version="7.69.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
@@ -235,7 +236,9 @@ app.include_router(sharing.router)
app.include_router(sidebar_config.router)
app.include_router(export.router)
app.include_router(notifications_router)
app.include_router(automations_router)
# Plugin « automations » OFF → chaque route de ce router renvoie 404
app.include_router(automations_router,
dependencies=[Depends(plugins_service.plugin_required("automations"))])
app.include_router(collaboration_router)
app.include_router(emoji_router)
app.include_router(realtime_router)
@@ -247,8 +250,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)
+84
View File
@@ -1586,6 +1586,43 @@ def _migration_my_tasks_mapping(conn: sqlite3.Connection) -> None:
)
@register(35, "v7.58.0: registre des plugins (on/off à effet réel)")
def _migration_plugins(conn: sqlite3.Connection) -> None:
"""Catalogue de l'instance : 3 modules câblés (routes/UI/outils réels)."""
conn.execute(
"""CREATE TABLE IF NOT EXISTS plugins (
slug TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1
)"""
)
conn.executemany(
"INSERT OR IGNORE INTO plugins (slug, name, description, enabled) VALUES (?,?,?,1)",
[
("web-tools", "Outils web de l'agent",
"Retire web_search et fetch_url du registre d'outils de l'agent"),
("web-clipper", "Web Clipper",
"Coupe la page /extensions et l'API /api/v2/web-clipper/*"),
("automations", "Automations",
"Coupe /workspace/automations* et le scheduler en arrière-plan"),
],
)
@register(34, "v7.57.0: connecteurs — kind/auth/tools_json (Discord, Telegram, MCP)")
def _migration_connector_kinds(conn: sqlite3.Connection) -> None:
"""Discord (auth « Bot »), Telegram (jeton dans l'URL via `{secret}`) et
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."""
@@ -1641,3 +1678,50 @@ def _migration_agent_memory(conn: sqlite3.Connection) -> None:
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)"""
)
@register(37, "v7.64.0: réactions emoji sur un texte sélectionné")
def _migration_text_reactions(conn: sqlite3.Connection) -> None:
"""Réactions emoji ancrées sur une plage de texte (façon Notion) : miroir des
ancres de commentaires (block_id + offsets dans le texte rendu). Une ligne par
(plage, emoji, utilisateur) → le POST agit comme un toggle et le GET regroupe
par plage pour afficher la pastille d'émojis."""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS text_reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
block_id TEXT NOT NULL,
anchor_start INTEGER NOT NULL,
anchor_end INTEGER NOT NULL,
emoji TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(page_id, block_id, anchor_start, anchor_end, emoji, user_id)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_text_reactions_page ON text_reactions(page_id)"
)
@register(36, "v7.59.1: mistral — modèles par défaut hors plan basique")
def _migration_mistral_default_model(conn: sqlite3.Connection) -> None:
"""mistral-large-latest / pixtral-large-latest renvoient 403
« tier_not_allowed » sur les plans d'abonnement basiques : la clé est
valide mais le test de connexion et l'agent échouaient. Reset du
default_model stocké → vide = nouveau défaut du provider (small-latest,
servi par tous les plans).
"""
blocked = ("mistral-large-latest", "pixtral-large-latest")
conn.execute(
"UPDATE user_llm_keys SET default_model='' "
"WHERE provider='mistral' AND default_model IN (?,?)",
blocked,
)
conn.execute(
"UPDATE llm_config SET model='mistral-small-latest' "
"WHERE provider='mistral' AND model IN (?,?)",
blocked,
)
+21 -2
View File
@@ -17,7 +17,7 @@ from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse
from app.auth.session import get_current_user
from app.config import settings
from app.db import get_conn
from app.services import connectors, oauth_connectors, skill_gallery
from app.services import connectors, oauth_connectors, plugins, skill_gallery
from app.services.agent_engine import AgentEngine, undo_action
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient
from app.services.llm_config import (
@@ -647,13 +647,32 @@ def list_connectors_route(request: Request):
return {"connectors": connectors.list_connectors(user_id)}
@router.get("/plugins")
def list_plugins_route(request: Request):
"""Catalogue des plugins de l'instance (menu + → « Add plugins »)."""
_current_user_id(request)
return {"plugins": plugins.list_plugins()}
@router.patch("/plugins/{slug}")
def set_plugin_route(request: Request, slug: str, body: dict = Body(default={})):
"""Bascule un plugin : l'effet est réel (routes/UI/outils), pas un drapeau."""
_current_user_id(request)
try:
return plugins.set_enabled(slug, bool(body.get("enabled")))
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/connectors")
def create_connectors_route(request: Request, body: dict = Body(default={})):
"""Ajoute un connecteur personnalisé — l'URL est validée (garde SSRF)."""
_current_user_id(request)
try:
return connectors.create_connector(
body.get("name") or "", body.get("url") or "", str(body.get("secret") or "")
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
+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}
+2 -201
View File
@@ -251,209 +251,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)
+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"
+94 -34
View File
@@ -1,7 +1,10 @@
"""Connecteurs de l'agent (v7.55.0) — socle : catalogue, statut, fetch gardé SSRF.
3 connecteurs **natifs** (gitea, github, web) servis à la volée depuis la config,
plus des connecteurs **personnalisation** persistés (URL + clé) en base.
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
@@ -10,6 +13,7 @@ provider) — 3 probes dans un dict + 1 fetch. Les adapters lourds des phases 6-
from __future__ import annotations
import json
import logging
from app.config import settings
@@ -20,6 +24,18 @@ 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)
@@ -48,6 +64,21 @@ def native_state(kind: str, user_id: int | None = None) -> tuple[str, str]:
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] = []
@@ -62,46 +93,45 @@ def list_connectors(user_id: int | None = None) -> list[dict]:
})
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name, url, secret_encrypted, enabled, status, detail "
"FROM agent_connectors ORDER BY id"
"SELECT id, name, url, secret_encrypted, enabled, status, detail, "
"kind, auth, tools_json FROM agent_connectors ORDER BY id"
).fetchall()
for r in rows:
out.append({
"id": r["id"], "builtin": False, "kind": "custom", "name": r["name"],
"url": r["url"], "enabled": bool(r["enabled"]), "status": r["status"],
"detail": r["detail"] or "", "has_secret": bool(r["secret_encrypted"]),
})
return out
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 "
"FROM agent_connectors WHERE id=?", (connector_id,)
"SELECT id, name, url, secret_encrypted, enabled, status, detail, "
"kind, auth, tools_json FROM agent_connectors WHERE id=?",
(connector_id,),
).fetchone()
if not r:
return None
return {"id": r["id"], "builtin": False, "kind": "custom", "name": r["name"],
"url": r["url"], "enabled": bool(r["enabled"]), "status": r["status"],
"detail": r["detail"] or "", "has_secret": bool(r["secret_encrypted"])}
return _row(r) if r else None
# ── CRUD (personnels) ────────────────────────────────────────────────────────
def create_connector(name: str, url: str, secret: str = "") -> dict:
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")
url = _validate_url((url or "").strip()) # lève ValueError si hôte interne
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) "
"VALUES (?,?,?,?,?)",
(name, url, encrypt_secret(secret or ""), "unknown", ""),
"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
@@ -143,22 +173,44 @@ def delete_connector(connector_id: int) -> bool:
# ── 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."""
"""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 = ""
secret, auth = "", "bearer"
if connector_id:
with get_conn() as conn:
r = conn.execute(
"SELECT secret_encrypted FROM agent_connectors WHERE id=?",
"SELECT secret_encrypted, auth FROM agent_connectors WHERE id=?",
(connector_id,),
).fetchone()
secret = decrypt_secret((r["secret_encrypted"] if r else "") or "")
if secret:
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).
@@ -184,7 +236,7 @@ def _resolve(connector: str) -> tuple[str, str | int]:
row = get(int(token))
if row is None:
raise ValueError(f"Connecteur inconnu: {token}")
return ("custom", row["id"])
return (row["kind"], row["id"])
low = token.lower()
if low in NATIVE_KINDS:
return (low, low)
@@ -193,7 +245,7 @@ def _resolve(connector: str) -> tuple[str, str | int]:
"SELECT id FROM agent_connectors WHERE lower(name)=?", (low,)
).fetchone()
if row:
return ("custom", row["id"])
return (get(row["id"])["kind"], row["id"])
raise ValueError(f"Connecteur inconnu: {token}")
@@ -201,7 +253,11 @@ 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 in OAUTH_KINDS:
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]
@@ -216,12 +272,13 @@ async def probe(connector: str, user_id: int | None = None) -> dict:
status, detail = "ok", native_state("web")[1]
else:
row = get(int(ref))
code, _ = await _get(row["url"], _headers(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 == "custom":
if kind not in NATIVE_KINDS:
with get_conn() as conn:
conn.execute(
"UPDATE agent_connectors SET status=?, detail=? WHERE id=?",
@@ -246,6 +303,9 @@ async def connector_fetch(connector: str, path: str = "", query: str = "",
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"
@@ -267,7 +327,7 @@ async def connector_fetch(connector: str, path: str = "", query: str = "",
return {"status": "error", "text": "Connecteur supprimé"}
if not row["enabled"]:
return {"status": "error", "text": "Connecteur désactivé"}
url = row["url"].rstrip("/")
url = _with_secret(row["url"], int(ref)).rstrip("/")
if path:
url += "/" + path.lstrip("/")
code, text = await _get(url, _headers(int(ref)))
+4 -1
View File
@@ -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]
+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}
+1
View File
@@ -50,6 +50,7 @@ PROVIDERS: dict[str, dict] = {
"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",
+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
+59 -6
View File
@@ -1115,6 +1115,36 @@ class ConnectorFetch(Tool):
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,
@@ -1133,24 +1163,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)
+1 -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"/>
+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">
+97 -39
View File
@@ -578,74 +578,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 ═══════════ -->
+10 -3
View File
@@ -445,11 +445,18 @@ body.fd-ap-resizing *{cursor:col-resize!important}
</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">URL publique (hôtes privés refusés)</label>
<input id="fd-plus-cn-url" type="url" x-model="connectorForm.url" placeholder="https://api.exemple.com">
<label for="fd-plus-cn-secret">Clé API — stockée chiffrée, jamais renvoyée</label>
<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>
+23
View File
@@ -8,11 +8,23 @@
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#191919">
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<!-- v7.69 : identité visuelle (logo + bannière du projet) -->
<meta name="description" content="FlowDeck — espace de travail style Notion auto-hébergé, intégré à Gitea/GitHub.">
<meta property="og:type" content="website">
<meta property="og:site_name" content="FlowDeck">
<meta property="og:title" content="FlowDeck — votre espace Notion auto-hébergé">
<meta property="og:description" content="Éditeur de blocs, Kanban, bases de données et intégration Gitea/GitHub, sur votre propre serveur.">
<meta property="og:image" content="/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="/static/img/logo-512.jpg">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="/static/css/app.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/design-tokens.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/components.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/settings.css?v={{ asset_version }}">
<!-- My Tasks + page « /db » : lies dans le SHELL et non dans la zone
swappée. Un <link> à l'intérieur de `.main-wrapper` peut être retiré par
htmx lors d'une navigation partielle : la page revenait alors sans
@@ -1944,6 +1956,17 @@
this.sidebarCollapsed = !this.sidebarCollapsed;
},
// Hamburger: mobile opens the drawer (assignment expressions are illegal
// in the Alpine CSP build, hence this method), desktop toggles collapse.
fdHamburgerClick() {
if (window.matchMedia('(max-width: 768px)').matches) {
this.mobileSidebarOpen = true;
this.sidebarCollapsed = false;
} else {
this.toggleSidebar();
}
},
closeSidebar() {
if (this.sidebarPeek) { this.sidebarPeek = false; return; }
this.sidebarCollapsed = true;
+249
View File
@@ -0,0 +1,249 @@
{% 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>
<div class="settings-overlay" x-data="helpInit()" style="background:none;backdrop-filter:none;position:static;z-index:1;padding:0;">
<div class="settings-panel" style="width:100%;max-width:1050px;height:calc(100vh - 120px);margin:0 auto;" @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 %}
+36 -1
View File
@@ -11,6 +11,17 @@
<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="/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="/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>
+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>
+8
View File
@@ -77,3 +77,11 @@ except OSError: # pragma: no cover
ENV.globals["asset_version"] = ASSET_VERSION
ENV.globals["csp_nonce"] = lambda: CSP_NONCE.get()
ENV.globals["csrf_token"] = lambda: CSRF_TOKEN.get()
# Plugins activés : conditionne les blocs de UI rendus côté serveur.
ENV.globals["plugin_enabled"] = lambda slug: _plugin_enabled(slug)
def _plugin_enabled(slug: str) -> bool:
from app.services.plugins import is_enabled
return is_enabled(slug)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+43
View File
@@ -202,6 +202,49 @@ transaction par migration — A31).
Chaque phase se clôt par : `ruff` + suite verte, bump de version, CHANGELOG, ROADMAP
à jour, tag.
## 9bis. Avenant phase 7 (v7.57.0) — décisions livrées
- **D5 (`probe/list_sources/fetch` par adapter) écartée** : 1 switch sur
`agent_connectors.kind` dans `probe()` / `connector_fetch()` — une interface
à 3 méthodes pour 4 kinds tordrait plus qu'elle ne simplifie ; ajouter un
kind = 1 branche de plus dans le même switch.
- **Discord / Telegram = presets du socle**, pas 3 adapters dédiés : le champ
**Type** du formulaire pré-remplit l'URL + le schéma d'auth
(`bearer`/`bot`/`none`). La Bot API de Telegram impose le jeton **dans l'URL**
→ substitut `{secret}` remplacé à l'appel (`connectors._with_secret`),
stockage = motif seul, test « le jeton n'est jamais en base ».
- **MCP : outils dynamiques sans état** — `ToolRegistry._all()` relit le cache
`tools_json` à chaque run (rien à invalider, « Tester » suffit) ; nom LLM
`mcp_<serveur>_<outil>` (slug sans double soulignement) ; dispatch
`tools/call` dans `McpTool.execute()`. Serveur désactivé → outil **absent du
schéma**, échec réseau → `ToolResult(error)` : le run continue toujours.
- **Pas de client SSE à l'état** : `parse_body()` lit le JSON direct ou le
premier `data:` d'un `text/event-stream`, ce qui couvre
`initialize` / `tools/list` / `tools/call` des serveurs streamables courants.
Upgrade : client SSE persistant si un serveur ne répond qu'en flux.
## 9ter. Avenant phase 8 (v7.58.0) — décisions livrées
- **Garde de route = dépendance posée à l'`include_router`** (`main.py`), pas
de décorateur par route ni de middleware : 3 lignes, aucun router modifié.
⚠️ l'annotation `request: Request` doit être importée **au module** : avec
`from __future__ import annotations` les annotations sont des chaînes et
FastAPI les résout dans les globales du module — sinon il lit un query param
→ 422 (piège réel, corrigé en cours de phase).
- **Effet réel aux 4 niveaux** : routes (dépendance), arrière-plan (garde de
tick du scheduler), outils (`ToolRegistry._all()`), UI (rendu serveur).
- **UI : `{% if %}` pour la nav, `x-show` pour les sections** — la nav est
**absente du DOM** (test « UI absente » au sens propre) ; les grosses
sections ne sont pas restructurées, leur booléen est rendu au serveur.
- **Handler unifié des 404** (comportement historique conservé) : hors `/api`
une route refusée **redirige 302 → /workspaces**, `/api*` répond 404 JSON —
les tests assertent les deux formes au lieu de réécrire le handler.
- **SQLite** : `INSERT … ON CONFLICT` évalue NOT NULL **avant** l'upsert →
fournir toutes les colonnes NOT NULL, même pour un simple changement d'état.
- `ponytail:` plafond assumé — pas de classe-adapter par plugin ni de
« marketplace » : ajouter un plugin = 1 tuple dans `CATALOG` (+ 1 garde si
son effet n'est pas déjà couvert).
## 10. Hors périmètre
- Écriture dans les sources distantes (Google Docs, Slack…) — lecture seule au départ.
+166 -3
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.56.0"
"version": "7.69.0"
},
"paths": {
"/auth/register": {
@@ -1098,7 +1098,7 @@
"dashboard"
],
"summary": "Help Page",
"description": "Comprehensive help & documentation page.",
"description": "Help & documentation page — settings-style panel with side navigation.",
"operationId": "help_page_help_get",
"responses": {
"200": {
@@ -15368,6 +15368,97 @@
}
}
},
"/api/pages/{page_id}/reactions": {
"get": {
"tags": [
"collaboration"
],
"summary": "List Reactions",
"description": "Réactions regroupées par plage de texte : {block_id, start, end, emoji, count, mine}.",
"operationId": "list_reactions_api_pages__page_id__reactions_get",
"parameters": [
{
"name": "page_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Page Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"post": {
"tags": [
"collaboration"
],
"summary": "Toggle Reaction",
"description": "Ajoute ou retire la réaction de l'utilisateur sur une plage de texte (toggle).",
"operationId": "toggle_reaction_api_pages__page_id__reactions_post",
"parameters": [
{
"name": "page_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Page Id"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/custom-emojis": {
"get": {
"tags": [
@@ -16299,6 +16390,78 @@
}
}
},
"/api/agent/plugins": {
"get": {
"tags": [
"agent"
],
"summary": "List Plugins Route",
"description": "Catalogue des plugins de l'instance (menu + → « Add plugins »).",
"operationId": "list_plugins_route_api_agent_plugins_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/api/agent/plugins/{slug}": {
"patch": {
"tags": [
"agent"
],
"summary": "Set Plugin Route",
"description": "Bascule un plugin : l'effet est réel (routes/UI/outils), pas un drapeau.",
"operationId": "set_plugin_route_api_agent_plugins__slug__patch",
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Slug"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/agent/connectors/{connector_id}": {
"patch": {
"tags": [
@@ -30372,4 +30535,4 @@
}
}
}
}
}
+163
View File
@@ -0,0 +1,163 @@
// Mobile regression: hamburger drawer, wheel scroll, settings side-nav, help page.
const { test, expect } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-pass-123';
// One login for the whole file (app rate limiter: 60 req/min per IP) — reused
// as storageState by every test.
let STATE = null;
test.beforeAll(async ({ browser }) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page.waitForURL('**/workspaces', { timeout: 10000 }).then(() => true).catch(() => false);
if (!ok) {
const resp = await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E Mobile' } });
if (!resp.ok() && resp.status() !== 409) throw new Error(`register ${resp.status()}`);
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
await page.waitForURL('**/workspaces', { timeout: 10000 });
}
STATE = await ctx.storageState();
await ctx.close();
});
async function login(page) {
// Session reused from the single beforeAll login (rate limiter: 60 req/min/IP)
await page.context().addCookies(STATE.cookies);
}
test.describe('mobile', () => {
test.use({ viewport: { width: 390, height: 844 } });
test('hamburger opens mobile sidebar drawer', async ({ page }) => {
const errs = [];
page.on('pageerror', (e) => errs.push(e.message));
await login(page);
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
const burger = page.locator('.hamburger-btn');
await expect(burger).toBeVisible();
// Sidebar starts off-canvas
const sidebar = page.locator('#sidebar');
const boxBefore = await sidebar.boundingBox();
expect(boxBefore.x).toBeLessThan(0);
await burger.click();
await expect(sidebar).toHaveClass(/mobile-open/, { timeout: 5000 });
const boxAfter = await sidebar.boundingBox();
expect(boxAfter.x).toBeGreaterThanOrEqual(0);
expect(errs).toEqual([]);
// Overlay tap closes it
await page.locator('.sidebar-overlay').click({ position: { x: 350, y: 400 } });
await expect(sidebar).not.toHaveClass(/mobile-open/, { timeout: 5000 });
});
test('mobile sidebar drawer scrolls with mouse wheel', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
await page.locator('.hamburger-btn').click();
await page.waitForSelector('#sidebar.mobile-open');
const sc = page.locator('.sidebar-scroll');
await expect.poll(async () => sc.evaluate((el) => el.scrollHeight > el.clientHeight), { timeout: 3000 }).toBe(true);
const box = await sc.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + Math.min(box.height / 2, 400));
await page.mouse.wheel(0, 300);
await expect.poll(async () => sc.evaluate((el) => el.scrollTop), { timeout: 3000 }).toBeGreaterThan(0);
});
test('settings page fits mobile viewport', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
const panel = page.locator('.settings-panel');
await expect(panel).toBeVisible();
const box = await panel.boundingBox();
expect(box.width).toBeLessThanOrEqual(390);
expect(box.height).toBeLessThanOrEqual(844);
// No horizontal page overflow
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow).toBeLessThanOrEqual(0);
});
test('settings mobile nav is a hamburger side drawer', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
const burger = page.locator('.settings-menu-btn');
await expect(burger).toBeVisible();
const nav = page.locator('.settings-nav');
const before = await nav.boundingBox();
expect(before.x + before.width).toBeLessThanOrEqual(1);
await burger.click();
await expect(nav).toHaveClass(/nav-open/);
// Nav item switches section AND closes the drawer
await page.locator('.settings-nav-item', { hasText: 'Tags' }).click();
await expect(nav).not.toHaveClass(/nav-open/);
await expect(page.locator('h2', { hasText: 'Tags' }).first()).toBeVisible();
// Re-open and dismiss via backdrop
await burger.click();
await expect(nav).toHaveClass(/nav-open/);
await page.locator('.settings-nav-backdrop').click({ position: { x: 375, y: 500 } });
await expect(nav).not.toHaveClass(/nav-open/);
});
test('help page uses settings-style side navigation', async ({ page }) => {
const errs = [];
page.on('pageerror', (e) => errs.push(e.message));
await login(page);
await page.goto(`${FD_BASE}/help`, { waitUntil: 'domcontentloaded' });
// Same panel skeleton as settings
await expect(page.locator('.settings-panel')).toBeVisible();
await expect(page.locator('.settings-menu-btn')).toBeVisible();
// Mobile: nav drawer behind hamburger
const nav = page.locator('.settings-nav');
await expect(nav).not.toHaveClass(/nav-open/);
await page.locator('.settings-menu-btn').click();
await expect(nav).toHaveClass(/nav-open/);
await page.locator('.settings-nav-item', { hasText: 'Databases' }).click();
await expect(nav).not.toHaveClass(/nav-open/);
await expect(page.locator('h2', { hasText: 'Databases & Views' })).toBeVisible();
// App-wide coverage: all sections exist in the nav
const sections = ['Getting Started', 'Pages & Editor', 'Databases & Views', 'Tasks & Dependencies',
'Workspaces & Forges', 'Collaboration', 'Library, Trash & Search', 'AI Agent & Automations',
'Integrations & API', 'Offline & PWA', 'Accounts & SSO', 'Keyboard Shortcuts', 'Tips'];
const labels = (await nav.locator('.settings-nav-item').allInnerTexts()).join(' ');
for (const s of sections) expect(labels, s).toContain(s);
expect(errs).toEqual([]);
});
});
test.describe('desktop', () => {
test.use({ viewport: { width: 1440, height: 900 } });
test('settings and help keep the 2-column layout on desktop', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.settings-menu-btn')).toBeHidden();
const nav = page.locator('.settings-nav');
await expect(nav).toBeVisible();
const navBox = await nav.boundingBox();
expect(navBox.height).toBeGreaterThan(navBox.width);
await page.goto(`${FD_BASE}/help`, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.settings-menu-btn')).toBeHidden();
await page.locator('.settings-nav-item', { hasText: 'SSO' }).click();
await expect(page.locator('h2', { hasText: 'Accounts & SSO' })).toBeVisible();
await expect(page.locator('.settings-content')).toContainText('IndexedDB');
});
});
+37
View File
@@ -0,0 +1,37 @@
const { test } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
test.use({ viewport: { width: 390, height: 844 } });
test('diagnose mobile sidebar wheel scroll', async ({ page }) => {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', process.env.FD_USER || '[email protected]');
await page.fill('#password', process.env.FD_PASS || 'e2e-pass-123');
await page.click('.btn-primary');
await page.waitForURL('**/workspaces', { timeout: 10000 }).catch(() => {});
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.querySelector('.hamburger-btn'));
await page.locator('.hamburger-btn').click();
await page.waitForSelector('#sidebar.mobile-open');
const info = await page.evaluate(() => {
const sb = document.getElementById('sidebar');
const sc = document.querySelector('.sidebar-scroll');
const cs = getComputedStyle(sb);
const ccs = sc ? getComputedStyle(sc) : null;
return {
sidebar: { display: cs.display, height: cs.height, overflow: cs.overflow, sh: sb.scrollHeight, ch: sb.clientHeight },
scroll: sc ? { top: sc.scrollTop, sh: sc.scrollHeight, ch: sc.clientHeight, oy: ccs.overflowY, flex: ccs.flex, rect: JSON.stringify(sc.getBoundingClientRect()) } : null,
wheelTarget: (() => { const el = document.elementFromPoint(150, 400); return el ? el.className : 'none'; })(),
};
});
console.log('DIAG:', JSON.stringify(info, null, 1));
await page.mouse.move(150, 400);
await page.mouse.wheel(0, 300);
await page.waitForTimeout(300);
const after = await page.evaluate(() => {
const sc = document.querySelector('.sidebar-scroll');
return { scTop: sc.scrollTop, scSH: sc.scrollHeight, scCH: sc.clientHeight, bodyTop: document.scrollingElement.scrollTop };
});
console.log('AFTER WHEEL:', JSON.stringify(after));
});
+133
View File
@@ -0,0 +1,133 @@
const { test, expect } = require('@playwright/test');
/**
* v7.62 — menu contextuel au highlight de texte (toolbar façon Notion).
* Vérifie : les 4 lignes + Skills + pied, le menu « … », le sous-menu
* « Turn into », et surtout la PERSISTANCE du formatage après reload
* (sync() relit le DOM formaté via gtMd → source markdown).
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
*/
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
test.use({ serviceWorkers: 'block' });
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page
.waitForURL('**/workspaces', { timeout: 8000 })
.then(() => true)
.catch(() => false);
if (!ok) {
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
data: { email: USER, password: PASS, name: 'E2E' },
});
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
}
}
// Sélectionne tout le bloc et simule la fin du glisser-souris
async function selectBlock(page) {
await page.evaluate(() => {
const el = document.querySelector('#_blocksCt [data-bid]');
const r = document.createRange();
r.selectNodeContents(el);
const s = getSelection();
s.removeAllRanges();
s.addRange(r);
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
});
await expect(page.locator('.format-toolbar')).toBeVisible({ timeout: 5000 });
}
test('toolbar de sélection : structure, sous-menus, persistance', async ({ page }) => {
await login(page);
const cookies = await page.context().cookies();
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
const created = await page.request.post(
`${FD_BASE}/board/api/pages?title=Menu highlight ${Date.now()}`,
{ headers: { 'X-CSRF-Token': csrf } }
);
expect(created.status(), await created.text()).toBeLessThan(400);
const { id } = await created.json();
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
await page.click('#_blocksCt [data-bid]');
await page.keyboard.type('Menu lors d un highlight de texte');
// ── 1. structure : 4 lignes + Skills + pied ──
await selectBlock(page);
const bar = page.locator('.format-toolbar');
await expect(bar.locator('.ft-row')).toHaveCount(4);
await expect(bar.locator('.ft-style')).toContainText('Normal Text');
await expect(bar.locator('.ft-group')).toHaveText('Skills');
await expect(bar.locator('.ft-skill')).toHaveCount(4);
await expect(bar.locator('.ft-skill').first()).toHaveText('Improve writing');
await expect(bar.locator('.ft-foot')).toContainText('Edit with AI');
// refonte v7.67 : ligne 4 = Comment (libellé) à gauche, réaction à droite ;
// entête Skills avec ≡ ; plus de bouton ✨ Ask AI (doublon du pied)
await expect(bar.locator('.ft-comment')).toContainText('Comment');
await expect(bar.locator('.ft-row-split .ft-react')).toHaveCount(1);
await expect(bar.locator('.ft-skills-head')).toHaveText('Skills');
await expect(bar.locator('[data-tooltip^="Ask AI"]')).toHaveCount(0);
// géométrie : sous la sélection, entièrement dans le viewport
const barBox = await bar.boundingBox();
const selBox = await page.evaluate(() => {
const r = getSelection().getRangeAt(0).getBoundingClientRect();
return { bottom: r.bottom };
});
expect(barBox.y).toBeGreaterThanOrEqual(selBox.bottom);
expect(barBox.y + barBox.height).toBeLessThanOrEqual(900);
expect(barBox.x).toBeGreaterThanOrEqual(0);
expect(barBox.x + barBox.width).toBeLessThanOrEqual(1440);
await page.screenshot({ path: 'shots/v762-toolbar.png' });
// ── 2. ligne 1 → sous-menu « Turn into » ──
await bar.locator('.ft-style').click();
const sub = page.locator('#blockMenuSub');
await expect(sub).toBeVisible();
await expect(sub).toContainText('Heading 4');
await expect(sub.locator('.bm-check')).toHaveCount(1); // type courant coché
await page.screenshot({ path: 'shots/v762-turn-submenu.png' });
await page.keyboard.press('Escape');
// ── 3. « … » → menu contextuel de bloc (recherche + pied) ──
await selectBlock(page);
await bar.locator('.ft-more').click();
const menu = page.locator('#blockMenu');
await expect(menu).toBeVisible();
await expect(menu.locator('.bm-search')).toHaveAttribute('placeholder', 'Search actions...');
await expect(menu).toContainText('Copy link to block');
await expect(menu.locator('.bm-foot')).toContainText('Last edited by');
await page.screenshot({ path: 'shots/v762-more-menu.png' });
await page.keyboard.press('Escape');
await expect(menu).toBeHidden();
// ── 4. persistance : B (gras) survit au reload ──
await selectBlock(page);
await page.getByRole('button', { name: 'B', exact: true }).click();
await expect(page.locator('#_blocksCt [data-bid] b, #_blocksCt [data-bid] strong')).toHaveCount(1);
await page.waitForTimeout(2500); // autosave 1,5 s
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
await expect(page.locator('#_blocksCt [data-bid] strong')).toHaveCount(1); // rendu mdEsc
// ── 5. persistance : S (surlignage) survit au reload ──
await selectBlock(page);
await bar.locator('.ft-hl').click();
await expect(page.locator('#_blocksCt [data-bid] mark')).toHaveCount(1);
await page.waitForTimeout(2500);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
await expect(page.locator('#_blocksCt [data-bid] mark')).toHaveCount(1);
// ── 6. fermeture hors sélection ──
await page.mouse.click(10, 400);
await expect(page.locator('.format-toolbar')).toBeHidden();
});
+125
View File
@@ -0,0 +1,125 @@
const { test, expect } = require('@playwright/test');
/**
* v7.63 — menus mobiles façon Notion (viewport 390×844) :
* - « / » → barre horizontale défilante sous le curseur (15 boutons, ordre du doc)
* - « + » → feuille plein écran « Insert block » (titre centré + Cancel, groupes,
* raccourcis) → choix d'un type = applySlash
* - « Turn into » / « Color » → feuilles réutilisant les sous-menus de bloc
* - « More » → menu contextuel de bloc (desktop)
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
*/
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
test.use({ serviceWorkers: 'block', viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page
.waitForURL('**/workspaces', { timeout: 8000 })
.then(() => true)
.catch(() => false);
if (!ok) {
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
data: { email: USER, password: PASS, name: 'E2E' },
});
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
}
}
async function newPage(page) {
const cookies = await page.context().cookies();
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
const created = await page.request.post(
`${FD_BASE}/board/api/pages?title=Mobile menus ${Date.now()}`,
{ headers: { 'X-CSRF-Token': csrf } }
);
expect(created.status(), await created.text()).toBeLessThan(400);
const { id } = await created.json();
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
return id;
}
test('barre mobile au / + feuilles Insert block / Turn into / More', async ({ page }) => {
await login(page);
await newPage(page);
await page.click('#_blocksCt [data-bid]');
await page.keyboard.type('/');
// ── 1. la barre apparaît, 15 boutons dans l'ordre de la description ──
const bar = page.locator('#fdMobileBar');
await expect(bar).toBeVisible({ timeout: 5000 });
const keys = await page.evaluate(() =>
Array.from(document.querySelectorAll('#fdMobileBar [data-mtb]')).map((b) => b.dataset.mtb));
expect(keys).toEqual(['plus', 'turn', 'at', 'comment', 'image', 'trash',
'left', 'right', 'up', 'down', 'undo', 'redo', 'color', 'more', 'kbd']);
// défilement latéral
const overflowX = await bar.evaluate((el) => getComputedStyle(el).overflowX);
expect(overflowX).toBe('auto');
// pas de popup desktop en parallèle
await expect(page.locator('#_slashMenu')).toBeHidden();
await page.screenshot({ path: 'shots/v763-bar.png' });
// ── 2. « + » → feuille plein écran « Insert block » ──
await bar.locator('[data-mtb="plus"]').click();
const sheet = page.locator('.fd-msheet');
await expect(sheet).toBeVisible();
await expect(sheet.locator('.fd-msheet-title')).toHaveText('Insert block');
await expect(sheet.locator('.fd-msheet-cancel')).toHaveText('Cancel');
await expect(sheet.locator('.sm-group').first()).toHaveText('Basic blocks');
await expect(sheet.locator('.slash-item', { hasText: 'Heading 1' })).toHaveCount(1);
await page.screenshot({ path: 'shots/v763-insert.png' });
// ── 3. choix d'un type → applySlash (le bloc devient un titre) ──
await sheet.locator('.slash-item', { hasText: 'Heading 1' }).click();
await expect(sheet).toHaveCount(0);
await expect(bar).toBeHidden();
expect(await page.evaluate(() => window.E.blocks[0].type)).toBe('heading_1');
// ── 4. « Turn into » → feuille réutilisant les items du sous-menu de bloc ──
await page.keyboard.type('/');
await expect(bar).toBeVisible();
await bar.locator('[data-mtb="turn"]').click();
await expect(sheet.locator('.fd-msheet-title')).toHaveText('Turn into');
await expect(sheet.locator('.bm-item', { hasText: 'Bulleted list' })).toHaveCount(1);
await expect(sheet.locator('.bm-check')).toHaveCount(1); // type courant coché
await page.screenshot({ path: 'shots/v763-turn.png' });
await sheet.locator('.fd-msheet-cancel').click();
await expect(sheet).toHaveCount(0);
expect(await page.evaluate(() => window.E.blocks[0].type)).toBe('heading_1');
// ── 5. « Color » → feuille avec les 3 groupes ──
await page.evaluate(() => localStorage.setItem('fdLastBlockColor', 'color|#E5484D'));
await bar.locator('[data-mtb="color"]').click();
for (const g of ['Last used', 'Text color', 'Background color']) {
await expect(sheet.locator('.bm-group', { hasText: g })).toHaveCount(1);
}
await sheet.locator('.fd-msheet-cancel').click();
// ── 6. « More » → menu contextuel de bloc (recherche + pied) ──
await bar.locator('[data-mtb="more"]').click();
const menu = page.locator('#blockMenu');
await expect(menu).toBeVisible();
await expect(menu.locator('.bm-search')).toHaveAttribute('placeholder', 'Search actions...');
await expect(menu.locator('.bm-foot')).toContainText('Last edited by');
await page.keyboard.press('Escape');
await expect(menu).toBeHidden();
// ── 7. ↑ déplace réellement le bloc ── (bloc 0 = titre, bloc 1 = paragraphe)
await page.click('#_blocksCt [data-bid]'); // bloc 0 (contient '/')
await page.keyboard.press('Enter');
await page.waitForTimeout(150); // le focus du nouveau bloc est différé (60 ms)
await page.keyboard.type('/'); // bloc neuf et vide → slash exact = ouverture
await expect(bar).toBeVisible(); // SM._i = 1 (bloc du bas)
await bar.locator('[data-mtb="up"]').click();
expect(await page.evaluate(() => window.E.blocks.map((b) => b.type).join(',')))
.toBe('paragraph,heading_1');
await page.screenshot({ path: 'shots/v763-moved.png' });
});
+80
View File
@@ -0,0 +1,80 @@
const { test, expect } = require('@playwright/test');
/**
* v7.64.1 — Delete sur un bloc vide supprime le bloc (comme Backspace).
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
*/
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
test.use({ serviceWorkers: 'block' });
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page
.waitForURL('**/workspaces', { timeout: 8000 })
.then(() => true)
.catch(() => false);
if (!ok) {
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
data: { email: USER, password: PASS, name: 'E2E' },
});
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
}
}
test('Delete : vide le bloc puis supprime le bloc vide', async ({ page }) => {
await login(page);
const cookies = await page.context().cookies();
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
const created = await page.request.post(
`${FD_BASE}/board/api/pages?title=Delete key ${Date.now()}`,
{ headers: { 'X-CSRF-Token': csrf } }
);
expect(created.status(), await created.text()).toBeLessThan(400);
const { id } = await created.json();
const bs = await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, {
headers: { 'X-CSRF-Token': csrf },
data: { title: 'Delete key', blocks: [
{ id: 'b1', type: 'paragraph', content: 'hello' },
{ id: 'b2', type: 'paragraph', content: 'world' },
] },
});
expect(bs.status(), await bs.text()).toBeLessThan(400);
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
// ── 1. Delete avec du texte : suppression avant le curseur (comportement natif) ──
await page.locator('#_blocksCt [data-bid]').nth(1).click();
await page.keyboard.press('Control+a');
await page.keyboard.press('Delete');
await page.waitForTimeout(200);
const contents = await page.evaluate(() => {
window.E.sync();
return window.E.blocks.map((b) => b.content);
});
expect(contents[0]).toBe('hello');
expect(contents[1].trim()).toBe(''); // bloc vidé (un <br> résiduel), mais toujours là
// ── 2. Delete sur le bloc vide : le bloc est supprimé ──
await page.keyboard.press('Delete');
await page.waitForTimeout(300);
expect(await page.evaluate(() => window.E.blocks.length)).toBe(1);
expect(await page.evaluate(() => window.E.blocks[0].content)).toBe('hello');
await page.screenshot({ path: 'shots/v7641-delete.png' });
// ── 3. dernier bloc vide : jamais supprimé (l'éditeur garde un bloc) ──
await page.locator('#_blocksCt [data-bid]').first().click();
await page.keyboard.press('Control+a');
await page.keyboard.press('Delete');
await page.waitForTimeout(150);
await page.keyboard.press('Delete');
await page.waitForTimeout(300);
expect(await page.evaluate(() => window.E.blocks.length)).toBe(1);
expect(await page.evaluate(() => window.E.blocks[0].type)).toBe('paragraph');
});
+201
View File
@@ -0,0 +1,201 @@
const { test, expect } = require('@playwright/test');
/**
* v7.64 — commentaires & réactions façon Notion :
* - sélection → bouton 🙂+ (« React to selected text ») → sélecteur d'émoji avec
* le texte sélectionné en en-tête → pastille + plage jaune, contenu inchangé
* - sélection → 💬 → tiroir refondu (avatar, nom, ✓, ⋯, saisie avec @ et ↑) →
* fil ancré + plage jaune, clic sur l'ancre rouvre le fil, ✓ résout
* - reload : ancres et pastilles repeintes depuis la base
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
*/
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
test.use({ serviceWorkers: 'block' });
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page
.waitForURL('**/workspaces', { timeout: 8000 })
.then(() => true)
.catch(() => false);
if (!ok) {
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
data: { email: USER, password: PASS, name: 'E2E' },
});
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
}
}
async function newPage(page, body) {
const cookies = await page.context().cookies();
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
const created = await page.request.post(
`${FD_BASE}/board/api/pages?title=Comments ${Date.now()}`,
{ headers: { 'X-CSRF-Token': csrf } }
);
expect(created.status(), await created.text()).toBeLessThan(400);
const { id } = await created.json();
if (body) {
const bs = await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, {
headers: { 'X-CSRF-Token': csrf },
data: { title: 'Comments', blocks: body },
});
expect(bs.status(), await bs.text()).toBeLessThan(400);
}
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
return id;
}
// sélectionne une SOUS-chaîne du bloc (ancre = plage, pas le bloc entier)
async function selectSub(page, sub) {
await page.evaluate((sub) => {
const el = document.querySelector('#_blocksCt [data-bid]');
const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let n;
while ((n = w.nextNode())) {
if (n.parentElement && n.parentElement.closest('.fd-react-chip')) continue;
const i = n.nodeValue.indexOf(sub);
if (i < 0) continue;
const r = document.createRange();
r.setStart(n, i);
r.setEnd(n, i + sub.length);
const s = getSelection();
s.removeAllRanges();
s.addRange(r);
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
return;
}
throw new Error('sous-chaîne introuvable : ' + sub);
}, sub);
await expect(page.locator('.format-toolbar')).toBeVisible({ timeout: 5000 });
}
// sélectionne tout le bloc et simule la fin du glisser-souris
async function selectBlock(page) {
await page.evaluate(() => {
const el = document.querySelector('#_blocksCt [data-bid]');
const r = document.createRange();
r.selectNodeContents(el);
const s = getSelection();
s.removeAllRanges();
s.addRange(r);
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
});
await expect(page.locator('.format-toolbar')).toBeVisible({ timeout: 5000 });
}
test('réaction emoji + commentaires ancrés', async ({ page }) => {
await login(page);
await newPage(page, [{ id: 'b1', type: 'paragraph', content: 'Prise de reaction a un texte' }]);
await expect(page.locator('#_blocksCt [data-bid]')).toHaveText('Prise de reaction a un texte');
// ── 1. bouton réaction dans la toolbar ──
await selectBlock(page);
const react = page.locator('.format-toolbar .ft-react');
await expect(react).toHaveAttribute('data-tooltip', 'React to selected text');
await page.screenshot({ path: 'shots/v764-toolbar-react.png' });
await react.click();
// ── 2. sélecteur : en-tête = texte sélectionné, Filter…, Recent/People ──
const picker = page.locator('.fd-icon-picker');
await expect(picker).toBeVisible();
await expect(picker.locator('.fdip-title-text')).toHaveText('Prise de reaction a un texte');
await expect(picker.locator('.fdip-search')).toHaveAttribute('placeholder', 'Filter...');
await expect(picker.locator('.fdip-tabs')).toBeHidden(); // mode réaction : emoji only
await page.screenshot({ path: 'shots/v764-picker.png' });
await picker.locator('.fdip-emoji').first().click();
await expect(picker).toBeHidden();
// ── 3. pastille + plage jaune, contenu du bloc inchangé ──
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1);
await expect(page.locator('#_blocksCt .fd-anchor-react')).toHaveCount(1);
const content = await page.evaluate(() => {
window.E.sync();
return window.E.blocks[0].content;
});
expect(content).toBe('Prise de reaction a un texte'); // la pastille n'est pas du contenu
await page.screenshot({ path: 'shots/v764-chip.png' });
// ── 4. commentaire sur une SOUS-sélection : toolbar 💬 → tiroir refondu → envoi ──
await expect(page.locator('#_commentSelBtn')).toHaveCount(0); // bouton bleu retiré
await selectSub(page, 'reaction a un');
await page.locator('.format-toolbar .ft-item[data-tooltip="Comment — Ctrl+⇧+M"]').click();
const drawer = page.locator('.comments-drawer');
await expect(drawer).toBeVisible();
await drawer.locator('textarea').fill('Ceci est mon premier commentaire');
await expect(drawer.locator('.ct-send')).toBeEnabled();
await drawer.locator('.ct-send').click();
const thread = page.locator('.comment-thread');
await expect(thread).toHaveCount(1);
await expect(thread.locator('.ct-avatar')).toHaveCount(1);
await expect(thread.locator('.ct-name')).not.toHaveText('');
await expect(thread.locator('.ct-act')).toHaveCount(2); // ✓ + ⋯
await expect(thread.locator('.ct-body')).toHaveText('Ceci est mon premier commentaire');
// l'ancre jaune = UNIQUEMENT la plage sélectionnée, pas le bloc
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveCount(1);
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveText('reaction a un');
await page.screenshot({ path: 'shots/v764-drawer.png' });
// ── 5. menu ⋯ du fil (Copy link / Delete) ──
await thread.locator('.ct-act').nth(1).click();
await expect(thread.locator('.ct-menu')).toBeVisible();
await expect(thread.locator('.ct-menu-item').first()).toContainText('Copy link');
await page.keyboard.press('Escape');
await page.locator('.comments-drawer-header .topbar-btn').click();
await expect(drawer).toBeHidden();
// ── 6. clic sur l'ancre jaune → le fil se rouvre ──
await page.locator('#_blocksCt .fd-anchor[data-cid]').click();
await expect(drawer).toBeVisible();
await expect(page.locator('.comment-thread')).toHaveCount(1);
// ── 7. ✓ résout le fil (ancre en gris) ──
await page.locator('.comment-thread .ct-act').first().click();
await expect(page.locator('.comment-thread.is-resolved')).toHaveCount(1);
await expect(page.locator('#_blocksCt .fd-anchor.fd-anchor-done')).toHaveCount(1);
// ── 8. reload : ancres + pastilles rechargées depuis la base ──
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveCount(1, { timeout: 10000 });
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveText('reaction a un');
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1, { timeout: 10000 });
// ── 9. v7.67 : la réaction vit aussi dans le tiroir Comments ──
await page.locator('.topbar-btn[title="Comments"]').click();
const re = page.locator('.ct-reactions');
await expect(re).toBeVisible();
await expect(re.locator('.ct-re-emoji')).toHaveCount(1);
await expect(re.locator('.ct-re-text')).toHaveText('Prise de reaction a un texte');
await page.screenshot({ path: 'shots/v764-drawer-reactions.png' });
// ── 10. suppression A : ✕ de la section Réactions ──
await re.locator('.ct-reaction .ct-act').click();
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(0);
await expect(re).toBeHidden();
// ── 11. suppression B : la croix survolée de la pastille ──
await page.locator('.topbar-btn[title="Comments"]').click(); // referme le tiroir
await selectBlock(page);
await page.locator('.format-toolbar .ft-react').click();
const picker2 = page.locator('.fd-icon-picker');
await expect(picker2).toBeVisible();
await picker2.locator('.fdip-emoji').first().click();
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1);
const emo = page.locator('#_blocksCt .fd-react-emo.is-mine');
await expect(emo).toHaveCount(1);
// la croix n'apparaît qu'au survol (::after)
await expect.poll(() => emo.evaluate((el) => getComputedStyle(el, '::after').display)).toBe('none');
await page.locator('#_blocksCt .fd-react-chip').hover();
await expect.poll(() => emo.evaluate((el) => getComputedStyle(el, '::after').display)).toBe('flex');
await emo.click();
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(0);
});
+99
View File
@@ -0,0 +1,99 @@
const { test, expect } = require('@playwright/test');
/**
* v7.66 — deux ajustements éditeur :
* - « / » ouvre le menu slash → Escape ferme le menu ET retire le « / »
* - survol d'un bloc → bouton à droite (bulle + ) qui commente le bloc
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
*/
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
test.use({ serviceWorkers: 'block' });
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page
.waitForURL('**/workspaces', { timeout: 8000 })
.then(() => true)
.catch(() => false);
if (!ok) {
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
data: { email: USER, password: PASS, name: 'E2E' },
});
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
}
}
async function newPage(page, body) {
const cookies = await page.context().cookies();
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
const created = await page.request.post(
`${FD_BASE}/board/api/pages?title=v766 ${Date.now()}`,
{ headers: { 'X-CSRF-Token': csrf } }
);
expect(created.status(), await created.text()).toBeLessThan(400);
const { id } = await created.json();
if (body) {
const bs = await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, {
headers: { 'X-CSRF-Token': csrf },
data: { title: 'v766', blocks: body },
});
expect(bs.status(), await bs.text()).toBeLessThan(400);
}
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
return id;
}
test('Escape du menu slash retire le caractère /', async ({ page }) => {
await login(page);
await newPage(page, [{ id: 'b1', type: 'paragraph', content: '' }]);
const blk = page.locator('#_blocksCt [data-bid]').first();
await blk.click();
await page.keyboard.type('/');
await expect(page.locator('#_slashMenu')).toBeVisible({ timeout: 5000 });
await page.keyboard.press('Escape');
await expect(page.locator('#_slashMenu')).toBeHidden();
// le « / » a été retiré — pas de caractère résiduel
await expect(blk).toHaveText('');
await page.screenshot({ path: 'shots/v766-slash-esc.png' });
});
test('bouton commenter à droite du bloc', async ({ page }) => {
await login(page);
// 2 blocs : l'éditeur focalise le 1er au chargement (`:focus-within` le
// montre) → on mesure sur le 2e, ni focalisé ni survolé.
await newPage(page, [
{ id: 'b1', type: 'paragraph', content: 'Premier bloc' },
{ id: 'b2', type: 'paragraph', content: 'Un bloc a commenter' },
]);
await expect(page.locator('#_blocksCt .block-comment-btn')).toHaveCount(2);
const wrap = page.locator('#_blocksCt .block-wrapper').nth(1);
const btn = page.locator('#_blocksCt .block-comment-btn').nth(1);
// masqué tant qu'on ne survole pas / ne focalise pas le bloc
await expect.poll(() => btn.evaluate((el) => getComputedStyle(el).opacity)).toBe('0');
await wrap.hover();
await expect.poll(() => btn.evaluate((el) => getComputedStyle(el).opacity)).toBe('1');
// bien à droite du bloc (gouttière 64px)
const wb = await wrap.boundingBox();
const bb = await btn.boundingBox();
expect(bb.x).toBeGreaterThan(wb.x + wb.width - 1);
await page.screenshot({ path: 'shots/v766-comment-btn.png' });
// clic → tiroir → envoi → ancre = le bloc visé (et pas le 1er)
await btn.click();
const drawer = page.locator('.comments-drawer');
await expect(drawer).toBeVisible();
await drawer.locator('textarea').fill('Commentaire via le bouton de droite');
await drawer.locator('.ct-send').click();
await expect(page.locator('.comment-thread')).toHaveCount(1);
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveText('Un bloc a commenter');
});
+185
View File
@@ -0,0 +1,185 @@
const { test, expect } = require('@playwright/test');
/**
* v7.68 — colonnes rendues (slash + Turn into), tables IA sans pipe de tête,
* copier/couper/coller d'une sélection multi-blocs (Ctrl+C/X/V + clic droit).
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
*/
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
test.use({ serviceWorkers: 'block', permissions: ['clipboard-read', 'clipboard-write'] });
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page
.waitForURL('**/workspaces', { timeout: 8000 })
.then(() => true)
.catch(() => false);
if (!ok) {
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
data: { email: USER, password: PASS, name: 'E2E' },
});
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
}
}
async function newPage(page, body) {
const cookies = await page.context().cookies();
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
const created = await page.request.post(
`${FD_BASE}/board/api/pages?title=v768 ${Date.now()}`,
{ headers: { 'X-CSRF-Token': csrf } }
);
expect(created.status(), await created.text()).toBeLessThan(400);
const { id } = await created.json();
if (body) {
const bs = await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, {
headers: { 'X-CSRF-Token': csrf },
data: { title: 'v768', blocks: body },
});
expect(bs.status(), await bs.text()).toBeLessThan(400);
}
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
return id;
}
// VRAIE sélection multi-blocs : glisser la souris du 1er bloc jusqu'au 3e
// (le navigateur seul ne franchit jamais deux contenteditable)
async function dragAcross(page, last = 2) {
const b1 = await page.locator('#_blocksCt [data-bid]').first().boundingBox();
const b3 = await page.locator('#_blocksCt [data-bid]').nth(last).boundingBox();
// on part du tout début du 1er bloc et on va jusqu'à la fin du dernier
await page.mouse.move(b1.x + 2, b1.y + b1.height / 2);
await page.mouse.down();
for (let i = 1; i <= 8; i++) {
await page.mouse.move(b1.x + 2, b1.y + b1.height / 2 + ((b3.y - b1.y) * i) / 8);
}
await page.mouse.move(b3.x + b3.width - 2, b3.y + b3.height / 2);
await page.mouse.up();
}
// sélection multi-blocs au CLAVIER : caret en fin de bloc 1, Maj+↓ Maj+↓
async function shiftExtend(page) {
// VRAI geste : un clic met le caret dans le bloc, End va à sa fin,
// Maj+↓ l'étend ensuite dans les blocs suivants.
const el = page.locator('#_blocksCt [data-bid]').first();
const b = await el.boundingBox();
await page.mouse.click(b.x + 2, b.y + b.height / 2);
await page.keyboard.press('End');
await page.keyboard.press('Shift+ArrowDown');
await page.keyboard.press('Shift+ArrowDown');
await page.waitForTimeout(80);
}
// état de la sélection côté éditeur
const clipState = (page) => page.evaluate(() => window.E._clipState());
test('colonnes : slash + Turn into 2 colonnes', async ({ page }) => {
await login(page);
await newPage(page, [{ id: 'b1', type: 'paragraph', content: '' }]);
await page.click('#_blocksCt [data-bid]');
await page.keyboard.type('/');
await expect(page.locator('#_slashMenu')).toBeVisible({ timeout: 5000 });
await page.keyboard.type('columns');
await page.locator('#_slashMenu .slash-item[data-sid="columns"]').click();
// le bloc colonnes est RENDU (il tombait dans le rendu générique = vide)
await expect(page.locator('#_blocksCt .block-columns')).toHaveCount(1);
await expect(page.locator('#_blocksCt .block-column')).toHaveCount(1);
await page.locator('#_blocksCt .columns-add-btn').click();
await expect(page.locator('#_blocksCt .block-column')).toHaveCount(2);
await page.screenshot({ path: 'shots/v768-columns-slash.png' });
// Turn into → 2 colonnes
await newPage(page, [{ id: 'b1', type: 'paragraph', content: 'Pour le menu' }]);
await page.locator('#_blocksCt [data-bid]').first().click({ button: 'right' });
const menu = page.locator('#blockMenu');
await expect(menu).toBeVisible();
await menu.locator('[data-sub="turn"]').hover();
const sub = page.locator('#blockMenuSub');
await expect(sub).toBeVisible();
await sub.locator('[data-bm-act="turn-type"][data-cols="2"]').click();
await expect(page.locator('#_blocksCt .block-column')).toHaveCount(2);
});
test('table IA sans pipe de tête devient un vrai tableau', async ({ page }) => {
await login(page);
await newPage(page, [{ id: 'b1', type: 'paragraph', content: '' }]);
await page.evaluate(() => {
window.E.applyAIBlocks('Nom | Rôle\n--- | ---\nAlice | Dev\nBob | Test');
});
await expect(page.locator('#_blocksCt .ftable-editor')).toHaveCount(1);
await expect(page.locator('#_blocksCt .ftable-cell').first()).toHaveText('Nom');
await expect(page.locator('#_blocksCt .ftable-cell').last()).toHaveText('Test');
// la ligne « texte » suivie d'« --- » ne doit PAS devenir un tableau
await page.evaluate(() => {
window.E.applyAIBlocks('juste du texte\n---\nune phrase');
});
await expect(page.locator('#_blocksCt .ftable-editor')).toHaveCount(1);
await page.screenshot({ path: 'shots/v768-ai-table.png' });
});
test('Ctrl+C / Ctrl+X / Ctrl+V sur une sélection multi-blocs', async ({ page }) => {
await login(page);
await newPage(page, [
{ id: 'b1', type: 'paragraph', content: 'Premier bloc' },
{ id: 'b2', type: 'heading_2', content: 'Titre deux' },
{ id: 'b3', type: 'paragraph', content: 'Troisieme bloc' },
]);
// sélection multi-blocs au CLAVIER (Maj+↓) — geste réel
await shiftExtend(page);
const st = await clipState(page);
expect(st.si).toBe(0);
expect(st.ei).toBe(2);
// VRAI raccourci clavier (pas execCommand) : c'est lui qui était cassé
await page.keyboard.press('Control+c');
await page.waitForTimeout(250);
const clip = await page.evaluate(() => navigator.clipboard.readText());
expect(clip).toContain('## Titre deux'); // le titre sort en markdown
expect(clip).toContain('Troisieme bloc');
// coupe (raccourci Ctrl+X) : on recouvre les 3 blocs du début à la fin
await dragAcross(page);
await page.keyboard.press('Control+x');
await page.waitForTimeout(300);
await expect(page.locator('#_blocksCt [data-bid]')).toHaveCount(2);
await expect(page.locator('#_blocksCt [data-bid]').first()).toHaveText('');
// colle : les 3 blocs reviennent APRÈS le bloc du curseur (comme l'entrée
// Paste du menu) → 2 blocs restants + 3 collés = 5
await page.keyboard.press('Control+v');
await page.waitForTimeout(300);
await expect(page.locator('#_blocksCt [data-bid]')).toHaveCount(5, { timeout: 5000 });
await expect(page.locator('#_blocksCt [data-bid]').nth(1)).toContainText('Premier bloc');
await expect(page.locator('#_blocksCt [data-bid]').nth(2)).toContainText('Titre deux');
});
test('clic droit avec une sélection → groupe Clipboard', async ({ page }) => {
await login(page);
await newPage(page, [
{ id: 'b1', type: 'paragraph', content: 'Alpha bloc' },
{ id: 'b2', type: 'paragraph', content: 'Beta bloc' },
]);
await dragAcross(page, 1);
// la sélection multi-blocs doit SURVIVRE au clic droit (le navigateur
// déplacerait sinon le curseur dans le bloc cliqué)
expect((await clipState(page)).ei).toBe(1);
await page.locator('#_blocksCt [data-bid]').nth(1).click({ button: 'right', position: { x: 30, y: 6 } });
expect((await clipState(page)).ei).toBe(1);
const menu = page.locator('#blockMenu');
await expect(menu).toBeVisible();
await expect(menu.locator('[data-bm-act="clip-copy"]')).toBeVisible();
await expect(menu.locator('[data-bm-act="clip-cut"]')).toBeVisible();
await expect(menu.locator('[data-bm-act="clip-paste"]')).toBeVisible();
await page.screenshot({ path: 'shots/v768-ctx-clipboard.png' });
await menu.locator('[data-bm-act="clip-copy"]').click();
const clip = await page.evaluate(() => navigator.clipboard.readText());
expect(clip).toContain('Alpha bloc');
expect(clip).toContain('Beta bloc');
});
+164
View File
@@ -0,0 +1,164 @@
// v7.69.0 — la sélection multi-blocs SURVIT aux gestes qui l'effaçaient :
// 1) glisser la souris d'un bloc à l'autre
// 2) Ctrl+C sur cette sélection
// 3) clic droit → le menu n'efface plus le surlignage
const { test, expect } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
test.use({ serviceWorkers: 'block', permissions: ['clipboard-read', 'clipboard-write'] });
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page.waitForURL('**/workspaces', { timeout: 8000 }).then(() => true).catch(() => false);
if (!ok) {
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
}
}
async function newPage(page, body) {
const cookies = await page.context().cookies();
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
const created = await page.request.post(`${FD_BASE}/board/api/pages?title=sel ${Date.now()}`, {
headers: { 'X-CSRF-Token': csrf },
});
const { id } = await created.json();
if (body) {
await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, {
headers: { 'X-CSRF-Token': csrf },
data: { title: 'sel', blocks: body },
});
}
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
}
const BLOCKS = [
{ id: 'b1', type: 'paragraph', content: 'Premier bloc' },
{ id: 'b2', type: 'heading_2', content: 'Titre deux' },
{ id: 'b3', type: 'paragraph', content: 'Troisieme bloc' },
];
// état natif de la sélection
const nativeSel = (page) =>
page.evaluate(() => {
const s = window.getSelection();
return { rc: s.rangeCount, coll: s.isCollapsed, txt: s.toString().slice(0, 40) };
});
const clipState = (page) => page.evaluate(() => window.E._clipState());
// VRAI glisser de souris : le navigateur seul ne franchit jamais deux
// contenteditable — c'est _buildSelAt qui reconstruit la plage.
async function dragAcross(page, last = 2) {
const b1 = await page.locator('#_blocksCt [data-bid]').first().boundingBox();
const bEnd = await page.locator('#_blocksCt [data-bid]').nth(last).boundingBox();
// on part du tout début du 1er bloc et on va jusqu'à la fin du dernier
await page.mouse.move(b1.x + 2, b1.y + b1.height / 2);
await page.mouse.down();
for (let i = 1; i <= 8; i++) {
await page.mouse.move(b1.x + 2, b1.y + b1.height / 2 + ((bEnd.y - b1.y) * i) / 8);
}
await page.mouse.move(bEnd.x + bEnd.width - 2, bEnd.y + bEnd.height / 2);
await page.mouse.up();
await page.waitForTimeout(80);
}
test('glisser la souris sur 3 blocs construit une sélection multi-blocs qui survit', async ({ page }) => {
await login(page);
await newPage(page, BLOCKS);
await dragAcross(page);
// la plage reconstruite ne doit PAS être effacée par le « click » suivant
const s = await nativeSel(page);
expect(s.coll).toBe(false);
expect(s.rc).toBe(1);
const st = await clipState(page);
expect(st.si).toBe(0);
expect(st.ei).toBe(2);
await page.screenshot({ path: 'shots/v769-drag.png' });
});
// VRAI raccourci clavier (pas execCommand) : c'est ce qui était cassé.
// Maj+↓ depuis la fin du bloc 1 → la sélection traverse les 3 blocs.
async function keyboardExtend(page) {
const b1 = await page.locator('#_blocksCt [data-bid]').first().boundingBox();
await page.mouse.click(b1.x + 2, b1.y + b1.height / 2);
await page.keyboard.press('End');
await page.keyboard.press('Shift+ArrowDown');
await page.keyboard.press('Shift+ArrowDown');
await page.waitForTimeout(80);
}
test('Ctrl+C copie la sélection multi-blocs en markdown', async ({ page }) => {
await login(page);
await newPage(page, BLOCKS);
await keyboardExtend(page);
expect((await clipState(page)).ei).toBe(2);
await page.keyboard.press('Control+c'); // le vrai raccourci
await page.waitForTimeout(250);
const clip = await page.evaluate(() => navigator.clipboard.readText());
expect(clip).toContain('## Titre deux'); // le titre sort en markdown
expect(clip).toContain('Troisieme bloc');
// Ctrl+X retire les blocs traversés (tête + queue restent)
await page.keyboard.press('Control+x');
await page.waitForTimeout(300);
await expect(page.locator('#_blocksCt [data-bid]')).toHaveCount(2);
});
test("clic droit : le menu s'ouvre SANS effacer le surlignage", async ({ page }) => {
await login(page);
await newPage(page, BLOCKS);
await dragAcross(page);
expect((await nativeSel(page)).coll).toBe(false);
await page.locator('#_blocksCt [data-bid]').nth(1).click({ button: 'right', position: { x: 30, y: 6 } });
await page.waitForTimeout(150);
// le surlignage reste visible
const s = await nativeSel(page);
expect(s.coll).toBe(false);
expect(s.txt.length).toBeGreaterThan(0);
// …et le groupe Clipboard est bien proposé
const menu = page.locator('#blockMenu');
await expect(menu).toBeVisible();
await expect(menu).toContainText('Clipboard');
await expect(menu.locator('[data-bm-act="clip-copy"]')).toBeVisible();
await page.screenshot({ path: 'shots/v769-rightclick.png' });
});
test('clic droit → Copy via le menu colle bien le markdown figé', async ({ page }) => {
await login(page);
await newPage(page, BLOCKS);
await dragAcross(page);
await page.locator('#_blocksCt [data-bid]').nth(1).click({ button: 'right', position: { x: 30, y: 6 } });
const menu = page.locator('#blockMenu');
await expect(menu.locator('[data-bm-act="clip-copy"]')).toBeVisible();
await menu.locator('[data-bm-act="clip-copy"]').click();
await page.waitForTimeout(200);
const clip = await page.evaluate(() => navigator.clipboard.readText());
expect(clip).toContain('Premier bloc');
expect(clip).toContain('## Titre deux');
});
test('un clic simple dans un bloc place quand même le curseur (pas de sélection fantôme)', async ({ page }) => {
await login(page);
await newPage(page, BLOCKS);
await dragAcross(page);
// le clic suivant est un NOUVEL geste : il doit effacer la sélection
const b1 = await page.locator('#_blocksCt [data-bid]').first().boundingBox();
await page.mouse.click(b1.x + 15, b1.y + b1.height / 2);
await page.waitForTimeout(120);
const st = await clipState(page);
expect(st).toBeNull();
});
+491 -13
View File
@@ -2341,6 +2341,65 @@ button.breadcrumb-current { max-width: 260px; }
cursor: grabbing;
}
/* + « ajouter un bloc dessous » — juste à gauche du handle (gouttière 64px) */
.block-insert-btn {
position: absolute;
left: -64px;
top: 5px;
width: 22px;
height: 22px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: 3px;
color: var(--text-dim);
font-size: 17px;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease;
}
.block-wrapper:hover .block-insert-btn,
.block-wrapper:focus-within .block-insert-btn {
opacity: 1;
}
.block-insert-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* ── v7.66 : bouton « commenter ce bloc » — à droite du bloc (gouttière 64px),
bulle de conversation + , visible au survol comme le handle ── */
.block-comment-btn {
position: absolute;
right: -60px;
top: 4px;
width: 22px;
height: 22px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: 3px;
color: var(--text-dim);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease;
}
.block-wrapper:hover .block-comment-btn,
.block-wrapper:focus-within .block-comment-btn {
opacity: 1;
}
.block-comment-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* ── v5.10.0: selection multiple + menu contexte bloc ── */
.block-wrapper.selected {
background: rgba(35,131,226,0.06);
@@ -2376,6 +2435,30 @@ button.breadcrumb-current { max-width: 260px; }
.block-menu .bm-item.bm-danger .bm-ico { color: #e5484d; }
.block-menu .bm-item.bm-danger:hover { background: rgba(229,72,77,.12); }
.block-menu .bm-sep { height: 1px; background: var(--border, #2a2a2a); margin: 5px 4px; }
/* ── v7.61 : menu contextuel type Notion — recherche, sections, pied, sous-menus ── */
.block-menu .bm-group {
padding: 9px 9px 3px;
font-size: 10px;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: .5px;
}
.block-menu .bm-foot {
border-top: 1px solid var(--border, #2a2a2a);
margin-top: 6px;
padding: 8px 9px 6px;
font-size: 11.5px;
line-height: 1.55;
color: var(--text-tertiary);
}
.block-menu .bm-check { margin-left: auto; color: var(--accent, #2383e2); font-size: 12px; }
.block-menu .bm-item.bm-current { background: var(--bg-secondary); }
.block-menu .bm-dot {
display: inline-block; width: 12px; height: 12px; border-radius: 3px;
border: 1px solid var(--border, #333); vertical-align: -2px;
}
.block-menu .bm-search:focus { border-color: var(--accent, #2383e2); }
.block-menu .bm-colors { padding: 6px 9px; }
.block-menu .bm-colors-label { font-size: 10px; font-weight: 600; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: .4px; margin-bottom: 6px; }
.block-menu .bm-swatches { display: flex; flex-wrap: wrap; gap: 6px; }
@@ -2406,7 +2489,7 @@ button.breadcrumb-current { max-width: 260px; }
color: var(--text-secondary); cursor: pointer; font-size: 12px; border-radius: 6px;
}
.block-menu .bm-cancel:hover { background: var(--bg-hover); }
.block-menu-sub { min-width: 180px; max-height: 320px; overflow-y: auto; }
.block-menu-sub { min-width: 230px; max-height: 60vh; overflow-y: auto; }
.ftable-hdr-toggle { margin-left: 12px; font-size: 12px; color: var(--text-tertiary); display:inline-flex; align-items:center; gap:2px; }
.ftable-hdr-toggle label { display:inline-flex; align-items:center; gap:4px; cursor:pointer; }
@@ -2476,11 +2559,20 @@ button.breadcrumb-current { max-width: 260px; }
font-size: 16px;
}
/* Numbered list */
/* Numbered list — data-num est calculé au rendu (rangée contiguë) */
.block-numbered {
padding-left: 22px;
padding-left: 32px;
position: relative;
}
.block-numbered[data-num]::before {
content: attr(data-num) ". ";
position: absolute;
left: 0;
top: 0;
min-width: 24px;
color: var(--text-primary);
font-size: 16px;
}
/* To-do */
.block-todo {
@@ -2773,6 +2865,11 @@ button.breadcrumb-current { max-width: 260px; }
border-radius: 50%;
line-height: 1;
}
/* v7.68 : la × de suppression de colonne était en opacity:0 SANS règle de
survol → invisible et pourtant cliquable (même piège que .block-actions) */
.block-column-wrapper:hover .block-actions {
opacity: 1;
}
.block-column {
outline: none;
min-height: 24px;
@@ -2852,35 +2949,410 @@ button.breadcrumb-current { max-width: 260px; }
font-size: 14px;
}
/* FORMAT TOOLBAR (FLOATING) */
/* FORMAT TOOLBAR (FLOATING) — menu contextuel au highlight (v7.62, façon Notion) */
.format-toolbar {
position: fixed;
z-index: 998;
display: flex;
align-items: center;
gap: 2px;
flex-direction: column;
align-items: stretch;
gap: 1px;
min-width: 216px;
max-height: calc(100vh - 16px);
overflow-y: auto;
background: var(--bg-modal);
border: 1px solid var(--border-light);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
padding: 4px 6px;
padding: 6px;
}
.format-toolbar button {
width: 30px;
.format-toolbar .ft-row {
display: flex;
align-items: center;
gap: 2px;
}
.format-toolbar .ft-item {
height: 28px;
min-width: 28px;
padding: 0 7px;
border: none;
background: transparent;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
color: var(--text-primary);
font-size: 14px;
font-size: 13px;
cursor: pointer;
transition: background 0.1s;
}
.format-toolbar button:hover {
.format-toolbar .ft-item:hover {
background: var(--bg-hover);
}
.format-toolbar .ft-item svg {
display: block;
}
/* Ligne 1 : style de texte (libellé du type de bloc + sous-menu) */
.format-toolbar .ft-style {
width: 100%;
justify-content: flex-start;
}
.format-toolbar .ft-style-ico {
font-weight: 600;
color: var(--text-secondary);
}
.format-toolbar .ft-style-name {
flex: 1;
text-align: left;
}
.format-toolbar .ft-caret {
color: var(--text-dim);
font-size: 15px;
line-height: 1;
}
/* « A » : couleur du texte (barre colorée sous la lettre) */
.format-toolbar .ft-a-letter {
position: relative;
font-weight: 600;
}
.format-toolbar .ft-a-letter::after {
content: '';
position: absolute;
left: 1px;
right: 1px;
bottom: -4px;
height: 3px;
border-radius: 2px;
background: var(--accent);
}
.format-toolbar .ft-tx,
.format-toolbar .ft-code-ico {
font-size: 12px;
font-family: var(--font-mono, monospace);
}
.format-toolbar .ft-hl {
border-radius: 2px;
padding: 0 3px;
background: rgba(255,197,61,.28);
}
.format-toolbar .ft-more {
font-size: 16px;
letter-spacing: 1px;
}
/* ── v7.67 : refonte façon capture — ligne Comment | réaction aux 2 extrémités,
entête « Skills » avec ≡ à droite, liste Skills scrollable ── */
.format-toolbar .ft-row-split {
justify-content: space-between;
gap: 4px;
}
.format-toolbar .ft-comment {
justify-content: flex-start;
}
.format-toolbar .ft-skills-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.format-toolbar .ft-skills-head::after {
content: '≡';
font-size: 13px;
color: var(--text-dim);
}
.format-toolbar .ft-skills {
max-height: 132px;
overflow-y: auto;
}
/* Séparateurs, groupe « Skills », pied de page */
.format-toolbar .ft-sep {
height: 1px;
background: var(--border);
margin: 5px 2px;
}
.format-toolbar .ft-group {
font-size: 11px;
color: var(--text-dim);
padding: 3px 7px 2px;
cursor: default;
}
.format-toolbar .ft-skill {
height: 26px;
justify-content: flex-start;
font-size: 13px;
color: var(--text-secondary);
}
.format-toolbar .ft-foot {
height: 26px;
width: 100%;
justify-content: space-between;
font-size: 12px;
color: var(--text-secondary);
}
.format-toolbar .ft-foot kbd {
font-family: inherit;
font-size: 11px;
color: var(--text-dim);
}
/* Info-bulle des éléments du menu de sélection : bulle claire, à droite */
.tooltip.tooltip-light {
background: #ffffff;
border-color: #e3e3e3;
color: #191919;
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
white-space: normal;
max-width: 240px;
line-height: 1.35;
}
.tooltip.tooltip-light em {
color: #6b6b6b;
}
/* ══ v7.63 — Menus mobiles façon Notion : barre horizontale au « / » ══
(défilement latéral) + feuille plein écran Insert block / Turn into / Color ══ */
.fd-mtb {
position: fixed;
z-index: 1300;
display: none;
gap: 2px;
padding: 5px 6px;
max-width: calc(100vw - 16px);
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
background: var(--bg-modal);
border: 1px solid var(--border-light);
border-radius: 10px;
box-shadow: 0 6px 20px rgba(0,0,0,.45);
}
.fd-mtb::-webkit-scrollbar { display: none; }
.fd-mtb .fd-mtb-item {
flex: 0 0 auto;
min-width: 38px;
height: 38px;
padding: 0 8px;
border: none;
background: transparent;
border-radius: 6px;
color: var(--text-primary);
font-size: 16px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
cursor: pointer;
}
.fd-mtb .fd-mtb-item:active { background: var(--bg-hover); }
.fd-mtb .fd-mtb-wide { font-size: 13px; font-weight: 600; padding: 0 10px; }
.fd-mtb .fd-mtb-caret { font-size: 10px; color: var(--text-dim); }
/* Feuille plein écran (titre centré + Cancel bleu à droite, liste qui défile) */
.fd-msheet {
position: fixed;
inset: 0;
z-index: 1500;
display: flex;
flex-direction: column;
background: var(--bg-secondary, #191919);
}
.fd-msheet-head {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
}
.fd-msheet-title { font-size: 15px; font-weight: 600; color: var(--text-primary); }
.fd-msheet-cancel {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--accent, #2383E2);
font-size: 14px;
font-weight: 600;
padding: 6px;
cursor: pointer;
}
/* la liste porte la classe block-menu (hérite des styles .bm-item/.bm-group)
mais doit couvrir tout l'écran, sans la chrome du menu flottant */
.fd-msheet-list.block-menu {
max-width: none;
min-width: 0;
background: none;
border: none;
border-radius: 0;
box-shadow: none;
padding: 6px 8px 40px;
flex: 1 1 auto;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.fd-msheet .bm-item { padding: 12px 10px; font-size: 15px; }
.fd-msheet .bm-group { padding: 14px 10px 4px; }
.fd-msheet .sm-group {
padding: 14px 10px 4px;
font-size: 11px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: .4px;
}
.fd-msheet .slash-item { padding: 12px 10px; font-size: 15px; }
.fd-msheet .slash-item:active { background: var(--bg-hover); }
/* ══ v7.64 — Commentaires & réactions façon Notion ══
Ancres jaunes (texte commenté), pastilles de réaction, tiroir refondu ══ */
.fd-anchor {
background: rgba(255, 213, 0, .32);
border-radius: 2px;
cursor: pointer;
}
.fd-anchor-done { background: rgba(255, 255, 255, .10); }
.fd-anchor-react { background: rgba(255, 213, 0, .28); }
.fd-react-chip {
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: 4px;
padding: 0 5px;
height: 19px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-secondary);
cursor: pointer;
vertical-align: middle;
font-size: 11px;
line-height: 1;
user-select: none;
}
.fd-react-chip:hover { background: var(--bg-hover); }
.fd-react-chip img { width: 12px; height: 12px; object-fit: contain; }
.fd-react-n { font-size: 10px; color: var(--text-dim); }
/* v7.67 : suppression d'une réaction — au survol de la pastille, l'émoji que
J'ai posé devient une croix (un seul clic = retirer). Pas de reflow : c'est
un ::after, les offsets source ne bougent pas. */
.fd-react-emo { position: relative; display: inline-flex; align-items: center; }
.fd-react-emo.is-mine::after {
content: '✕';
position: absolute;
inset: -3px;
display: none;
align-items: center;
justify-content: center;
font-size: 9px;
line-height: 1;
color: var(--text-dim);
background: var(--bg-modal, #1f1f1f);
border: 1px solid var(--border, #333);
border-radius: 50%;
cursor: pointer;
}
.fd-react-chip:hover .fd-react-emo.is-mine::after { display: flex; }
.fd-react-emo.is-mine:hover::after { color: #e5484d; border-color: #e5484d; }
/* Tiroir de commentaires (fil + saisie) */
.comment-thread {
position: relative;
margin-bottom: 12px;
padding: 10px 12px;
background: var(--bg-secondary, #2a2a2a);
border: 1px solid var(--border, #333);
border-radius: 10px;
}
.comment-thread.has-anchor { border-left: 3px solid var(--accent, #2383E2); }
.comment-thread.is-resolved { opacity: .55; }
.thread-flash { outline: 2px solid var(--accent, #2383E2); outline-offset: 2px; }
.ct-head { display: flex; align-items: center; gap: 8px; position: relative; }
.ct-avatar {
width: 24px; height: 24px; border-radius: 50%; color: #fff; flex-shrink: 0;
display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700;
}
.ct-name { font-size: 12px; font-weight: 600; color: var(--text-primary); }
.ct-time { font-size: 11px; color: var(--text-dim); margin-left: auto; }
.ct-actions { display: flex; gap: 2px; margin-left: 2px; }
.ct-act {
width: 24px; height: 24px; border: none; background: transparent; border-radius: 5px;
color: var(--text-dim); font-size: 13px; line-height: 1; cursor: pointer;
}
.ct-act:hover { background: var(--bg-hover); color: var(--text-primary); }
.ct-act.is-on { color: var(--accent, #2383E2); }
.ct-menu {
position: absolute; right: 0; top: 28px; z-index: 6; min-width: 156px; padding: 4px;
background: var(--bg-modal); border: 1px solid var(--border); border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,.4);
}
.ct-menu-item {
display: block; width: 100%; text-align: left; padding: 7px 10px; font-size: 13px;
color: var(--text-primary); background: none; border: none; border-radius: 5px; cursor: pointer;
}
.ct-menu-item:hover { background: var(--bg-hover); }
.ct-menu-item.is-danger { color: #e5484d; }
.ct-body { font-size: 13px; white-space: pre-wrap; margin-top: 6px; color: var(--text-primary); }
.ct-anchor-label { font-size: 10px; color: var(--text-dim); margin-top: 6px; }
/* v7.67 : section Réactions du tiroir (liste des plages réagies + retirer) */
.ct-section-title {
font-size: 11px;
font-weight: 600;
letter-spacing: .5px;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 8px;
}
.ct-reaction {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
margin-bottom: 6px;
background: var(--bg-secondary, #2a2a2a);
border: 1px solid var(--border, #333);
border-radius: 8px;
font-size: 13px;
}
.ct-re-emoji { font-size: 14px; line-height: 1; flex-shrink: 0; }
.ct-re-count { font-size: 11px; color: var(--text-dim); }
.ct-re-text {
flex: 1;
min-width: 0;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.comments-drawer-input { border-top: 1px solid var(--border, #333); padding: 12px 16px; }
.ct-input-row { display: flex; gap: 6px; align-items: flex-end; }
.ct-input-row textarea {
flex: 1; min-width: 0; background: var(--bg-secondary, #2a2a2a);
border: 1px solid var(--border, #333); border-radius: 8px; color: var(--text-primary);
font-size: 13px; font-family: inherit; line-height: 1.4; padding: 8px 10px; resize: none; outline: none;
}
.ct-input-row textarea:focus { border-color: var(--accent, #2383E2); }
.ct-input-actions { display: flex; gap: 4px; align-items: center; padding-bottom: 2px; }
.ct-send {
width: 30px; height: 30px; border-radius: 50%; background: var(--accent, #2383E2); color: #fff;
border: none; font-size: 15px; line-height: 1; cursor: pointer; flex-shrink: 0;
}
.ct-send:disabled { opacity: .4; cursor: default; }
/* Bouton « React to selected text » de la toolbar de sélection */
.ft-react-ico { position: relative; font-size: 15px; line-height: 1; }
.ft-react-plus {
position: absolute; right: -5px; top: -4px; font-size: 9px; font-weight: 700; color: var(--text-dim);
}
/* EDITOR STATUSBAR */
.editor-statusbar {
@@ -2925,7 +3397,9 @@ button.breadcrumb-current { max-width: 260px; }
.page-title-input { font-size: 28px; }
.blocks-container { padding: 8px 16px 96px; }
.block-handle { display: none; }
.block-insert-btn { display: none; }
.block-actions { display: none; }
.block-comment-btn { display: none; }
.editor-statusbar { left: 0; }
}
@@ -3400,6 +3874,7 @@ button.breadcrumb-current { max-width: 260px; }
.page-cover-area { padding: 32px 32px 8px; }
.blocks-container { padding: 8px 32px 80px; }
.block-handle { left: -32px; }
.block-insert-btn { left: -56px; }
.page-title-block::before { left: -32px; }
.page-title-input { font-size: 32px; }
@@ -3424,7 +3899,7 @@ button.breadcrumb-current { max-width: 260px; }
/* ── Sidebar mobile ── */
.sidebar {
display: block;
display: flex; flex-direction: column; /* block cassait flex:1 de .sidebar-scroll → drawer impossible à scroller */
position: fixed !important;
top: 0; left: 0;
bottom: 0; width: 85vw; max-width: 320px;
@@ -3490,7 +3965,9 @@ button.breadcrumb-current { max-width: 260px; }
.page-title-input { font-size: 24px; }
.blocks-container { padding: 8px 16px 96px; }
.block-handle { display: none; }
.block-insert-btn { display: none; }
.block-actions { display: none; }
.block-comment-btn { display: none; }
.page-title-block::before { display: none; }
.page-title-block .page-icon-emoji { margin-left: 0; }
.page-title-block .page-title-input { padding-left: 0; }
@@ -3577,7 +4054,7 @@ button.breadcrumb-current { max-width: 260px; }
.slash-menu { width: 90vw; max-width: 90vw; left: 5vw !important; }
/* ── Format Toolbar ── */
.format-toolbar { left: 8px !important; right: 8px; flex-wrap: wrap; justify-content: center; }
.format-toolbar { left: 8px !important; right: 8px; }
}
/* ═══════════════════════════════════════════════════════════
@@ -4634,6 +5111,7 @@ a.fd-wiki-link:hover { border-bottom-color: var(--accent, #2383e2); }
font-weight: 500;
}
body.page-locked .block-handle,
body.page-locked .block-comment-btn,
body.page-locked .block-add-btn,
body.page-locked .format-toolbar,
body.page-locked .get-started-toolbar { display: none !important; }
+34
View File
@@ -0,0 +1,34 @@
/* ═══ Panel skeleton shared by /settings and /help (Notion-style modal) ═══ */
.settings-overlay{position:fixed;inset:0;z-index:500;display:flex;background:rgba(0,0,0,.4);backdrop-filter:blur(2px);}
.settings-panel{position:relative;display:flex;width:1050px;max-width:98vw;height:85vh;margin:auto;background:var(--bg-primary);border:1px solid var(--border-strong);border-radius:14px;box-shadow:var(--shadow-modal);overflow:hidden;}
.settings-nav{width:200px;min-width:200px;background:var(--bg-sidebar);border-right:1px solid var(--border);padding:8px 0;overflow-y:auto;}
.settings-nav-header{padding:12px 16px;font-size:13px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;}
.settings-nav-item{display:flex;align-items:center;gap:8px;padding:6px 16px;font-size:13px;color:var(--text-secondary);cursor:pointer;transition:background 100ms;border-radius:0;}
.settings-nav-item:hover{background:var(--bg-hover);}
.settings-nav-item.active{background:rgba(35,131,226,.12);color:var(--text);font-weight:500;}
.settings-content{flex:1;overflow-y:auto;padding:24px 32px;}
.settings-content h2{font-size:18px;font-weight:600;margin-bottom:4px;}
.settings-content .section-desc{font-size:12px;color:var(--text-dim);margin-bottom:24px;}
.settings-close{position:absolute;top:12px;right:12px;width:32px;height:32px;background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;border-radius:6px;display:flex;align-items:center;justify-content:center;text-decoration:none;}
.settings-close:hover{background:var(--bg-hover);color:var(--text);}
/* Mobile hamburger for the panel nav — hidden on desktop */
.settings-menu-btn{display:none;position:absolute;top:8px;left:8px;z-index:20;width:40px;height:40px;border:none;border-radius:8px;background:var(--bg-tertiary);color:var(--text);align-items:center;justify-content:center;cursor:pointer;}
.settings-nav-backdrop{display:none;}
/* ── Mobile: nav becomes a left side-navigation drawer (hamburger opens it) ── */
@media (max-width:768px){
.settings-overlay{background:var(--bg-primary);backdrop-filter:none;}
.settings-panel{flex-direction:column;width:100vw;max-width:100vw;height:100dvh;max-height:100dvh;margin:0;border:none;border-radius:0;box-shadow:none;}
.settings-menu-btn{display:flex;}
.settings-nav{position:fixed;top:0;left:0;bottom:0;width:260px;max-width:80vw;min-width:0;z-index:600;transform:translateX(-100%);transition:transform .2s cubic-bezier(0.4,0,0.2,1);border-right:1px solid var(--border);box-shadow:4px 0 24px rgba(0,0,0,.4);padding-top:8px;}
.settings-nav.nav-open{transform:translateX(0);}
.settings-nav-item{padding:10px 16px;}
.settings-nav-backdrop{display:block;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:599;}
.settings-content{padding:56px 16px 16px;}
.settings-close{top:8px;right:8px;width:44px;height:44px;background:var(--bg-tertiary);}
.setting-row{flex-wrap:wrap;}
.setting-control{margin-left:0;width:100%;}
.prov-grid{grid-template-columns:1fr;}
.avatar-section{gap:10px;}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 999 B

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 624 B

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 767 B

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

+5 -1
View File
@@ -40,7 +40,7 @@
if (window.Alpine) window.Alpine.__fdIconPicker = true;
Alpine.store('fdIconPicker', {
open: false, x: 0, y: 0, tab: 'emoji', query: '', category: 'people',
open: false, x: 0, y: 0, tab: 'emoji', query: '', category: 'people', title: '',
skinTone: 0, toneOpen: false, recent: [], custom: [], customLoaded: false,
customModal: false, customName: '', customPreview: '', customFile: null, customBusy: false,
onPick: null, onRemove: null, _cx: 0, _cy: 0,
@@ -50,6 +50,9 @@
opts = opts || {};
this.onPick = opts.onPick || null;
this.onRemove = opts.onRemove || null;
// v7.64 : mode « réaction » — en-tête = texte sélectionné, emoji only
this.title = opts.title || '';
if (this.title) { this.tab = 'emoji'; this.category = 'people'; }
this.query = '';
this.toneOpen = false;
this.customModal = false;
@@ -78,6 +81,7 @@
close() {
this.open = false;
this.title = '';
this.customModal = false;
this.toneOpen = false;
this.onPick = null;
+63 -6
View File
@@ -44,7 +44,7 @@
{key:'canvases', icon:'🎨', label:'Design System – Canevas',
sub:'Galerie de canevas : créer, insérer, enregistrer', action:'canvases', chev:true},
{key:'plugins', icon:'🧩', label:'Add plugins',
sub:'Modules on/off — bientôt (phase 8)', disabled:true},
sub:'Modules on/off — effet réel : routes, UI, outils', action:'plugins', chev:true},
{key:'memory', icon:'🧠', label:'Mémoire (on/off)',
sub:'Résumés des échanges injectés à chaque run', action:'memory'}
];
@@ -74,8 +74,9 @@
skillForm: {id: null, name: '', description: '', prompt_template: ''},
galleryFilter: '',
canvases: [], canvas: null,
connectors: [], connector: null,
connectorForm: {name: '', url: '', secret: ''},
connectors: [], connector: null, plugins: [],
connectorForm: {name: '', url: '', secret: '', kind: 'custom',
auth: 'bearer', hint: ''},
cmdFocus: -1, slashDismiss: false,
toastMsg: '', toastErr: false, _toastTimer: null,
_livePh: null,
@@ -1147,6 +1148,7 @@
if(s === 'connector-new') return 'Ajouter un connecteur';
if(s === 'canvases') return 'Design System – Canevas';
if(s === 'canvas') return this.canvas ? this.canvas.name : 'Canevas';
if(s === 'plugins') return 'Add plugins';
return 'Ajouter au contexte';
},
get plusItems(){
@@ -1162,6 +1164,7 @@
if(s === 'connector-new') return [];
if(s === 'canvases') return this._canvasItems();
if(s === 'canvas') return this._canvasActionItems();
if(s === 'plugins') return this._pluginItems();
if(s === 'root'){
// l'entrée Mémoire reflète l'état réel du toggle de la conversation
var on = this.memoryOn;
@@ -1270,7 +1273,41 @@
_connectorStatus(c){
var mark = c.status === 'ok' ? '✓ ' : (c.status === 'error' ? '✗ '
: (c.status === 'missing' ? '⚠ ' : '? '));
return mark + (c.detail || c.status) + (c.enabled ? '' : ' · désactivé');
var tools = (c.kind === 'mcp' && c.tools_count) ? ' · ' + c.tools_count + ' outil(s)' : '';
return mark + (c.detail || c.status) + tools + (c.enabled ? '' : ' · désactivé');
},
// ── Plugins de l'instance (v7.58.0) ──
fetchPlugins(){
var self = this;
fetch('/api/agent/plugins').then(function(r){ return r.json(); })
.then(function(d){ self.plugins = (d && d.plugins) || []; })
.catch(function(){ self.plugins = []; });
},
_pluginItems(){
var out = (this.plugins || []).map(function(pl){
return {key:'pl-' + pl.slug, icon: pl.enabled ? '✅' : '⛔',
label: pl.name,
sub: (pl.enabled ? 'Activé' : 'Désactivé') + ' — ' + pl.description,
action:'plugin-toggle', slug: pl.slug};
});
if(!out.length) out.push({key:'pl-none', _sep:'Aucun plugin'});
out.push({key:'pl-hint', _sep:
'OFF = routes refusées, UI masquée, outils retirés'});
return out;
},
pluginToggle(slug){
var self = this;
var pl = (this.plugins || []).filter(function(x){ return x.slug === slug; })[0];
if(!pl) return;
fetch('/api/agent/plugins/' + encodeURIComponent(slug), {
method:'PATCH', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({enabled: !pl.enabled})
}).then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); })
.then(function(res){
if(!res.ok){ self.toast((res.d && res.d.detail) || 'Bascule impossible.', true); return; }
self.toast(res.d.enabled ? 'Plugin activé.' : 'Plugin désactivé.');
self.fetchPlugins();
}).catch(function(){ self.toast('Bascule impossible (réseau).', true); });
},
_connectorItems(){
var self = this;
@@ -1377,6 +1414,20 @@
self.fetchConnectors();
}).catch(function(){ self.toast('Suppression impossible (réseau).', true); });
},
// Type choisi dans le formulaire → URL / schéma d'auth / aide pré-remplis.
connectorPreset(){
var f = this.connectorForm;
var presets = {
custom: {url:'', auth:'bearer', hint:'URL publique de votre API (hôtes privés refusés)'},
discord: {url:'https://discord.com/api/v10', auth:'bot',
hint:'Discord Developers → Bot Token (jeton en Authorization)'},
telegram:{url:'https://api.telegram.org/bot{secret}', auth:'none',
hint:'BotFather → Bot Token ({secret} complète l\u2019URL)'},
mcp: {url:'', auth:'bearer', hint:'URL du endpoint MCP (streamable HTTP)'}
};
var p = presets[f.kind] || presets.custom;
f.url = p.url; f.auth = p.auth; f.hint = p.hint;
},
connectorCreate(){
var self = this, f = this.connectorForm;
if(!(f.name || '').trim() || !(f.url || '').trim()){
@@ -1384,7 +1435,8 @@
}
fetch('/api/agent/connectors', {
method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({name: f.name, url: f.url, secret: f.secret || ''})
body: JSON.stringify({name: f.name, url: f.url, secret: f.secret || '',
kind: f.kind || 'custom', auth: f.auth || 'bearer'})
}).then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); })
.then(function(res){
if(!res.ok){ self.toast((res.d && res.d.detail) || 'Création impossible.', true); return; }
@@ -1558,9 +1610,14 @@
this.connector = it.connector; this.plusSection = 'connector'; this.plusFocus = 0; return;
}
if(it.action === 'connector-new'){
this.connectorForm = {name:'', url:'', secret:''};
this.connectorForm = {name:'', url:'', secret:'', kind:'custom',
auth:'bearer', hint:''};
this.plusSection = 'connector-new'; this.plusFocus = -1; return;
}
if(it.action === 'plugins'){
this.fetchPlugins(); this.plusSection = 'plugins'; this.plusFocus = 0; return;
}
if(it.action === 'plugin-toggle'){ this.pluginToggle(it.slug); return; }
if(it.action === 'connector-probe'){ this.connectorProbe(); return; }
if(it.action === 'connector-toggle'){ this.connectorToggle(); return; }
if(it.action === 'connector-delete'){ this.connectorDelete(); return; }
+18 -5
View File
@@ -84,19 +84,32 @@
// Tooltip system
document.addEventListener('mouseover', function(e) {
const target = e.target.closest('[data-tooltip]');
const target = e.target.closest('[data-tooltip], [data-tooltip-html]');
if (!target) return;
const tip = document.getElementById('tooltip');
if (!tip) return;
tip.textContent = target.getAttribute('data-tooltip');
const html = target.getAttribute('data-tooltip-html');
if (html) tip.innerHTML = html;
else tip.textContent = target.getAttribute('data-tooltip') || '';
// v7.62 : éléments du menu de sélection → info-bulle claire à droite (façon Notion)
const inToolbar = !!target.closest('.format-toolbar');
tip.classList.toggle('tooltip-light', inToolbar);
tip.style.display = 'block';
const rect = target.getBoundingClientRect();
tip.style.left = rect.left + 'px';
tip.style.top = (rect.bottom + 4) + 'px';
if (inToolbar) {
tip.style.left = (rect.right + 8) + 'px';
tip.style.top = Math.max(8, rect.top + rect.height / 2 - tip.offsetHeight / 2) + 'px';
if (rect.right + 8 + tip.offsetWidth > window.innerWidth) {
tip.style.left = Math.max(8, rect.left - tip.offsetWidth - 8) + 'px';
}
} else {
tip.style.left = rect.left + 'px';
tip.style.top = (rect.bottom + 4) + 'px';
}
});
document.addEventListener('mouseout', function(e) {
const target = e.target.closest('[data-tooltip]');
const target = e.target.closest('[data-tooltip], [data-tooltip-html]');
if (!target) return;
const tip = document.getElementById('tooltip');
if (tip) tip.style.display = 'none';
+23
View File
@@ -0,0 +1,23 @@
if (!window.__fdHelpScriptLoaded) {
window.__fdHelpScriptLoaded = true;
// Alpine.data register (CSP build ne résout pas les globales window)
if (window.Alpine) { Alpine.data('helpInit', helpInit); fdRefreshXData('helpInit'); } else document.addEventListener('alpine:init', function () { Alpine.data('helpInit', helpInit); });
function helpInit() {
return {
section: 'start',
navOpen: false,
closeNavOnMobile() { this.navOpen = false; },
// Bouton ✕ (haut droite, visible aussi en mobile) : ferme l'aide.
close() {
if (document.referrer && document.referrer !== location.href) { history.back(); }
else { location.href = '/workspaces'; }
},
go(sec) {
this.section = sec;
this.navOpen = false;
var c = document.querySelector('.settings-content');
if (c) c.scrollTop = 0;
},
};
}
}
File diff suppressed because it is too large Load Diff
+3
View File
@@ -18,6 +18,9 @@ function settingsInit() {
fmtAuditDate(e) { return new Date(e.at).toLocaleString(); },
activeSection: 'account',
// Mobile side-navigation drawer for the settings panel (hamburger in topbar)
navOpen: false,
closeNavOnMobile() { this.navOpen = false; },
allTags: [],
newTagName: '',
newTagColor: '#787774',
+15
View File
@@ -89,6 +89,21 @@ def test_llm_client_providers_listed(client):
assert name in PROVIDERS
def test_mistral_defaults_are_tier_safe(client):
"""La clé Mistral plan basique → 403 « tier_not_allowed » sur mistral-large/pixtral-large.
Le modèle par défaut et le premier de la liste du test de connexion doivent
être des modèles servis par tous les plans."""
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS
tier_blocked = {"mistral-large-latest", "pixtral-large-latest"}
assert PROVIDERS["mistral"][1] not in tier_blocked
assert PROVIDER_MODELS["mistral"][0] not in tier_blocked
# le fallback du modèle retiré (PROVIDERS default) est présent dans la liste
assert PROVIDERS["mistral"][1] in PROVIDER_MODELS["mistral"]
# et la liste des modèles Mistral est validée chat à chaque fetch (403 exclus)
from app.services.llm_config import _CHAT_VALIDATED_PROVIDERS
assert "mistral" in _CHAT_VALIDATED_PROVIDERS
def test_llm_client_openai_compatible_bases(client):
"""Providers that need an OpenAI-compatible surface must point at it.
+22
View File
@@ -10,8 +10,10 @@ Covers the five roadmap points:
from __future__ import annotations
import asyncio
import ipaddress
import json
import secrets
import socket
from pathlib import Path
import httpx
@@ -209,7 +211,27 @@ _OG_HTML = """<!doctype html><html><head>
</head><body>hi</body></html>"""
#: IP publique factice — TestOGParser ne doit dépendre ni du réseau HTTP
#: (MockTransport) ni du DNS : `_is_public_host` résout l'hôte pour décider si
#: l'IP est publique, et cette résolution échoue sous `pytest -n auto` (même
#: piège que test_agent_web_tools.stub_dns — d'où un flake récurrent ici).
PUBLIC_IP = "93.184.216.34"
class TestOGParser:
@pytest.fixture(autouse=True)
def stub_dns(self, monkeypatch):
real = socket.getaddrinfo
def fake_getaddrinfo(host, *args, **kwargs):
try:
ipaddress.ip_address(host)
except ValueError:
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (PUBLIC_IP, 0))]
return real(host, *args, **kwargs)
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
def test_parse_og_handles_attribute_order(self):
from app.services.og_fetcher import parse_og
+2 -2
View File
@@ -104,8 +104,8 @@ def test_plus_menu_sections_and_disabled_states():
for key in ("'files'", "'skills'", "'connectors'", "'canvases'", "'plugins'", "'memory'"):
assert f"key:{key}" in root, key
assert "action:'files'" in root and "action:'skills'" in root
# phases 1-5 livrées → il reste 1 section « bientôt » (plugins)
assert root.count("disabled:true") == 1
# v7.58.0 : menu complet — plus aucune section « bientôt » (plugins vivant)
assert root.count("disabled:true") == 0
files = src.split("var FD_PLUS_FILES = [", 1)[1].split("];", 1)[0]
assert "action:'search'" in files and "action:'browse'" in files
+1 -2
View File
@@ -64,8 +64,7 @@ def test_canvases_menu_wired(client):
src = JS_PATH.read_text(encoding="utf-8")
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert "action:'canvases'" in root
# phases 1-5 livrées → 1 section « bientôt » restante (plugins)
assert root.count("disabled:true") == 1
assert root.count("disabled:true") == 0 # v7.58.0 : menu complet
for action in ("'canvas'", "'canvas-create'", "'canvas-insert'", "'canvas-save'"):
assert action in src, action
# piège /use : la clé builtin part DANS LE BODY, pas en query string
+1 -2
View File
@@ -129,8 +129,7 @@ def test_memory_menu_toggle_wired():
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert "action:'memory'" in root
assert "bientôt (phase 4)" not in root
# phases 1-5 livrées → seul Plugins reste désactivé
assert root.count("disabled:true") == 1
assert root.count("disabled:true") == 0 # v7.58.0 : menu complet
assert "toggleMemory()" in src
assert "memory_enabled: next" in src
engine = ENGINE.read_text(encoding="utf-8")
+1 -2
View File
@@ -158,8 +158,7 @@ def test_connectors_menu_wired(client):
src = JS_PATH.read_text(encoding="utf-8")
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert "action:'connectors'" in root
# phases 1-5 livrées → seule la section Plugins reste « bientôt »
assert root.count("disabled:true") == 1
assert root.count("disabled:true") == 0 # v7.58.0 : menu complet
for action in ("'connector'", "'connector-new'", "'connector-probe'",
"'connector-toggle'", "'connector-delete'"):
assert action in src, action
+209
View File
@@ -0,0 +1,209 @@
"""v7.57.0 — Discord / Telegram (presets) + serveurs MCP (outils dynamiques)."""
import asyncio
from pathlib import Path
import pytest
from conftest import anon_csrf
from app.db import get_conn
from app.services import connectors, mcp_client
from app.services.tool_registry import ToolRegistry
ROOT = Path(__file__).resolve().parent.parent
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
PANEL_HTML = ROOT / "app" / "templates" / "agent_panel.html"
def _create(client, **kw):
body = {"name": "Conn", "url": "https://example.com/v1", "secret": "sk-1", **kw}
r = client.post("/api/agent/connectors", json=body)
assert r.status_code == 200, r.text
return r.json()
# ── Presets Discord / Telegram ──────────────────────────────────────────────
def test_discord_preset_uses_bot_auth(client):
row = _create(client, name="Discord", url="https://discord.com/api/v10",
auth="bot", kind="discord")
assert row["kind"] == "discord" and row["auth"] == "bot"
assert connectors._headers(row["id"])["Authorization"].startswith("Bot ")
def test_telegram_secret_lives_in_url_not_in_db(client, monkeypatch):
row = _create(client, name="Telegram", kind="telegram", auth="none",
url="https://api.telegram.org/bot{secret}", secret="123456:abc")
with get_conn() as conn:
stored = conn.execute("SELECT url FROM agent_connectors WHERE id=?",
(row["id"],)).fetchone()["url"]
assert "{secret}" in stored and "123456" not in stored # jamais en clair
seen = []
async def fake_get(url, headers=None):
seen.append((url, headers))
return 200, '{"ok": true}'
monkeypatch.setattr(connectors, "_get", fake_get)
asyncio.run(connectors.connector_fetch(str(row["id"]), path="getMe"))
assert seen[0][0] == "https://api.telegram.org/bot123456:abc/getMe"
assert "Authorization" not in seen[0][1] # auth=none
def test_secret_placeholder_requires_key_and_valid_kinds(client):
no_key = client.post("/api/agent/connectors",
json={"name": "TG", "url": "https://api.telegram.org/bot{secret}"})
assert no_key.status_code == 400 and "secret" in no_key.json()["detail"]
bad_kind = client.post("/api/agent/connectors",
json={"name": "X", "url": "https://example.com/x", "kind": "slack"})
assert bad_kind.status_code == 400 and "kind" in bad_kind.json()["detail"]
bad_auth = client.post("/api/agent/connectors",
json={"name": "X", "url": "https://example.com/x", "auth": "digest"})
assert bad_auth.status_code == 400 and "auth" in bad_auth.json()["detail"]
# ── MCP : handshake, cache, outils dynamiques ───────────────────────────────
def _mcp_server(client) -> int:
return _create(client, name="Demo Server", kind="mcp", url="https://example.com/mcp")["id"]
def _fake_rpc_factory(calls):
async def fake_rpc(url, payload, headers=None):
assert url.startswith("https://example.com/mcp") # SSRF: hôte public fixe
calls.append(payload.get("method"))
method = payload.get("method")
if method == "initialize":
return {"result": {"protocolVersion": "2024-11-05",
"serverInfo": {"name": "demo"}}}
if method == "tools/list":
return {"result": {"tools": [
{"name": "search_docs", "description": "Cherche dans les notes",
"inputSchema": {"type": "object", "properties": {"q": {"type": "string"}},
"required": ["q"]}},
{"name": "Echo", "description": "Répète"},
]}}
if method == "tools/call":
return {"result": {"content": [{"type": "text", "text": "résultat utile"}]}}
return {}
return fake_rpc
def test_mcp_probe_handshakes_and_caches_tools(client, monkeypatch):
sid = _mcp_server(client)
calls = []
monkeypatch.setattr(mcp_client, "_rpc", _fake_rpc_factory(calls))
res = asyncio.run(connectors.probe(str(sid)))
assert res["status"] == "ok", res
assert "2 outil(s)" in res["detail"]
assert calls == ["initialize", "notifications/initialized", "tools/list"]
with get_conn() as conn:
tools = conn.execute("SELECT tools_json FROM agent_connectors WHERE id=?",
(sid,)).fetchone()["tools_json"]
assert "mcp_demo_server_search_docs" in tools # nom LLM calculé
rows = client.get("/api/agent/connectors").json()["connectors"]
me = {r["id"]: r for r in rows if r["id"] == sid}[sid]
assert me["kind"] == "mcp" and me["tools_count"] == 2
def test_dynamic_tools_reach_the_llm_schema_and_execute(client, monkeypatch):
sid = _mcp_server(client)
monkeypatch.setattr(mcp_client, "_rpc", _fake_rpc_factory([]))
asyncio.run(connectors.probe(str(sid)))
reg = ToolRegistry()
schema = {t["name"]: t for t in reg.schema()}
assert "mcp_demo_server_search_docs" in schema
assert schema["mcp_demo_server_search_docs"]["parameters"]["required"] == ["q"]
assert "mcp_demo_server_echo" in schema
res = asyncio.run(reg.execute("mcp_demo_server_search_docs", {"q": "notes"}))
assert res.status == "success"
assert "résultat utile" in res.data["text"]
def test_disabled_mcp_server_hides_its_tools(client, monkeypatch):
sid = _mcp_server(client)
monkeypatch.setattr(mcp_client, "_rpc", _fake_rpc_factory([]))
asyncio.run(connectors.probe(str(sid)))
assert "mcp_demo_server_echo" in {t["name"] for t in ToolRegistry().schema()}
client.patch(f"/api/agent/connectors/{sid}", json={"enabled": False})
assert "mcp_demo_server_echo" not in {t["name"] for t in ToolRegistry().schema()}
# hors du registre → refus explicite plutôt qu'un appel réseau raté
res = asyncio.run(ToolRegistry().execute("mcp_demo_server_echo", {}))
assert res.status == "error" and "inconnu" in res.message.lower()
def test_mcp_rpc_errors_become_probe_errors(client, monkeypatch):
sid = _mcp_server(client)
async def bad_rpc(url, payload, headers=None):
if payload.get("method") == "initialize":
raise ValueError("MCP -32000 : handshake refusé")
return {}
monkeypatch.setattr(mcp_client, "_rpc", bad_rpc)
res = asyncio.run(connectors.probe(str(sid)))
assert res["status"] == "error" and "handshake" in res["detail"]
# les outils restent absents : pas de cache partiel
with get_conn() as conn:
tools = conn.execute("SELECT tools_json FROM agent_connectors WHERE id=?",
(sid,)).fetchone()["tools_json"]
assert tools == "[]"
def test_parse_body_handles_sse_and_rpc_errors():
sse = mcp_client.parse_body(
"text/event-stream; charset=utf-8",
": keep-alive\nevent: message\ndata: {\"result\": {\"ok\": 1}}\n\n", 200)
assert sse == {"result": {"ok": 1}}
assert mcp_client.parse_body("application/json", '{"result": {}}', 200) == {"result": {}}
with pytest.raises(ValueError, match="illisible"):
mcp_client.parse_body("text/html", "<html>gateway</html>", 502)
with pytest.raises(ValueError, match="-32601"):
mcp_client.parse_body("application/json",
'{"error": {"code": -32601, "message": "Method not found"}}', 200)
def test_tool_name_slug():
assert mcp_client.tool_name("Demo Server", "search-docs") == "mcp_demo_server_search_docs"
assert mcp_client.tool_name("!!", "Echo") == "mcp_echo"
# ── Teams + câblage ─────────────────────────────────────────────────────────
def test_ms365_scopes_include_teams_messages():
from app.services.oauth_connectors import PROVIDERS
assert "ChannelMessage.Read.All" in PROVIDERS["ms365"]["scopes"]
assert "Files.Read" in PROVIDERS["ms365"]["scopes"]
def test_connectors_menu_wired_for_presets(client):
src = JS_PATH.read_text(encoding="utf-8")
for token in ("connectorPreset()", "kind: f.kind || 'custom'",
"auth: f.auth || 'bearer'",
"https://discord.com/api/v10",
"https://api.telegram.org/bot{secret}", "presets[f.kind]"):
assert token in src, token
html = PANEL_HTML.read_text(encoding="utf-8")
assert "fd-plus-cn-kind" in html
for token in ('value="discord"', 'value="telegram"', 'value="mcp"',
'connectorForm.kind'):
assert token in html, token
# v7.58.0 : plus aucune section « bientôt » (plugins rendu vivant)
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert root.count("disabled:true") == 0
resp = client.get("/accounts")
assert resp.status_code == 200 and "connectorPreset" in resp.text
def test_mcp_routes_require_session(client):
anon_csrf(client)
assert client.get("/api/agent/connectors").status_code == 401
assert client.post("/api/agent/connectors",
json={"name": "x", "url": "https://example.com/x",
"kind": "mcp"}).status_code == 401
+142
View File
@@ -0,0 +1,142 @@
"""v7.58.0 — Add plugins : registre persistant + on/off à effet réel."""
import asyncio
from pathlib import Path
from conftest import anon_csrf
from app.services import plugins
from app.services.tool_registry import ToolRegistry
ROOT = Path(__file__).resolve().parent.parent
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
PANEL_HTML = ROOT / "app" / "templates" / "agent_panel.html"
SETTINGS_HTML = ROOT / "app" / "templates" / "settings.html"
AUTOMATIONS_PY = ROOT / "app" / "services" / "automations.py"
def _set(client, slug: str, enabled: bool) -> dict:
r = client.patch(f"/api/agent/plugins/{slug}", json={"enabled": enabled})
assert r.status_code == 200, r.text
return r.json()
# ── Catalogue ───────────────────────────────────────────────────────────────
def test_catalog_lists_the_three_wired_plugins(client):
data = client.get("/api/agent/plugins").json()["plugins"]
assert [p["slug"] for p in data] == ["web-tools", "web-clipper", "automations"]
assert all(p["enabled"] for p in data)
assert all(p["name"] and p["description"] for p in data)
def test_unknown_slug_is_404(client):
assert client.patch("/api/agent/plugins/nope",
json={"enabled": False}).status_code == 404
def test_toggle_persists_in_db(client):
_set(client, "web-clipper", False)
assert plugins.is_enabled("web-clipper") is False # relu depuis la base
_set(client, "web-clipper", True)
assert plugins.is_enabled("web-clipper") is True
# ── Effet réel : routes refusées ────────────────────────────────────────────
def test_automations_routes_refused_when_plugin_off(client):
# 404 hors /api = redirection vers /workspaces (handler unifié de main.py)
def get():
return client.get("/workspace/automations", follow_redirects=False)
assert get().status_code == 200
_set(client, "automations", False)
assert get().status_code == 302
assert get().headers["location"] == "/workspaces"
assert client.post("/workspace/automations", json={},
follow_redirects=False).status_code == 302
assert plugins.is_enabled("automations") is False
_set(client, "automations", True)
assert get().status_code == 200
def test_scheduler_tick_guarded_when_automations_off(client):
# le scheduler doit vérifier le plugin avant chaque tick ( effet réel, pas un
# drapeau lu nulle part )
src = AUTOMATIONS_PY.read_text(encoding="utf-8")
assert 'plugins.is_enabled("automations")' in src
_set(client, "automations", False)
assert plugins.is_enabled("automations") is False
def test_web_clipper_api_and_page_refused_when_plugin_off(client):
def page():
return client.get("/extensions", follow_redirects=False)
assert client.get("/api/v2/web-clipper/status").status_code == 200
assert page().status_code == 200
_set(client, "web-clipper", False)
assert client.get("/api/v2/web-clipper/status").status_code == 404
assert client.post("/api/v2/web-clipper/clip", json={}).status_code == 404
assert page().status_code == 302 and page().headers["location"] == "/workspaces"
_set(client, "web-clipper", True)
assert client.get("/api/v2/web-clipper/status").status_code == 200
assert page().status_code == 200
# ── Effet réel : outils de l'agent ──────────────────────────────────────────
def test_web_tools_leaves_the_llm_registry_when_plugin_off(client):
names = {t["name"] for t in ToolRegistry().schema()}
assert {"web_search", "fetch_url"} <= names
_set(client, "web-tools", False)
names = {t["name"] for t in ToolRegistry().schema()}
assert not ({"web_search", "fetch_url"} & names)
res = asyncio.run(ToolRegistry().execute("fetch_url",
{"url": "https://example.com"}))
assert res.status == "error" and "inconnu" in res.message.lower()
_set(client, "web-tools", True)
names = {t["name"] for t in ToolRegistry().schema()}
assert {"web_search", "fetch_url"} <= names
# ── UI : settings rendu côté serveur + menu + ──────────────────────────────
def test_settings_hides_disabled_plugins(client):
html = client.get("/settings").text
assert "navTo('extensions')" in html and "navTo('automations')" in html # ON
_set(client, "automations", False)
_set(client, "web-clipper", False)
html = client.get("/settings").text
assert "navTo('automations')" not in html # nav retirée
assert "navTo('extensions')" not in html
assert "x-show=\"false &amp;&amp; activeSection==='automations'\"" in html
_set(client, "automations", True)
_set(client, "web-clipper", True)
html = client.get("/settings").text
assert "navTo('automations')" in html and "navTo('extensions')" in html
def test_menu_catalogue_is_live(client):
src = JS_PATH.read_text(encoding="utf-8")
for token in ("action:'plugins'", "_pluginItems()", "pluginToggle(slug)",
"/api/agent/plugins", "Add plugins"):
assert token in src, token
# la section n'est plus une coquille « bientôt »
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert "disabled:true" not in root
panel = PANEL_HTML.read_text(encoding="utf-8")
assert "plusSection!=='root'" in panel # en-tête/rétour génériques
settings = SETTINGS_HTML.read_text(encoding="utf-8")
assert settings.count("plugin_enabled(") >= 4
def test_plugins_routes_require_session(client):
anon_csrf(client)
assert client.get("/api/agent/plugins").status_code == 401
assert client.patch("/api/agent/plugins/automations",
json={"enabled": False}).status_code == 401
+100
View File
@@ -0,0 +1,100 @@
"""7.59.2 — corrections : bouton ✕ de l'aide, « Insert below » qui perd le
format, listes numérotées invisibles (agent + /numbered list)."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
HELP_HTML = ROOT / "app" / "templates" / "help.html"
HELP_JS = ROOT / "static" / "js" / "help.js"
EDITOR_JS = ROOT / "static" / "js" / "page_editor_scripts.js"
APP_CSS = ROOT / "static" / "css" / "app.css"
SETTINGS_CSS = ROOT / "static" / "css" / "settings.css"
# ── 1. Aide : fermeture en haut à droite (mobile compris) ───────────────────
def test_help_page_has_close_button():
html = HELP_HTML.read_text(encoding="utf-8")
js = HELP_JS.read_text(encoding="utf-8")
css = SETTINGS_CSS.read_text(encoding="utf-8")
assert 'class="settings-close"' in html # bouton ✕ présent
assert '@click="close()"' in html
assert "close()" in js and "history.back()" in js # méthode réelle
assert ".settings-close{" in css # style partagé (32/44px)
assert "position:relative" in css.split(".settings-panel{")[1][:40]
# ── 2. Insert below : on insère le MARKDOWN, pas le texte du HTML rendu ─────
def test_ai_insert_below_keeps_source_markdown():
src = EDITOR_JS.read_text(encoding="utf-8")
assert "self._md=String(text||'')" in src # source conservée
assert "String(this._md||'').trim()||this._resultText()" in src # md d'abord
assert src.count("this._md='';") >= 2 # close() + _err()
# l'ancien chemin (texte extrait du DOM rendu) n'est plus que le repli
assert "_resultText(){ const el=this.ROOT&&this.ROOT.querySelector('.aici-result')" in src
# ── 3. Listes numérotées : numéros réellement affichés ──────────────────────
def test_numbered_list_renders_its_number():
src = EDITOR_JS.read_text(encoding="utf-8")
css = APP_CSS.read_text(encoding="utf-8")
assert "let numAttr=''" in src and "data-num=" in src # rangée contiguë → rang
assert ".block-numbered[data-num]::before" in css
assert 'content: attr(data-num) ". "' in css
# md2b accepte « 1. » et « 1) » (CommonMark) pour l'insertion agent
assert r"/^\d+[.)] /" in src
def test_slash_query_typed_in_block_is_synced_to_filter():
src = EDITOR_JS.read_text(encoding="utf-8")
# « /numbered list » tapé dans le bloc → répercuté dans l'entrée du menu
assert "SM._in.value=q" in src
assert "SM.flt();" in src
# ── md2b : test comportemental sur la source réelle (node) ──────────────────
def _md2b_source() -> str:
src = EDITOR_JS.read_text(encoding="utf-8")
i = src.index("md2b(md){")
j = src.index("return bl;},", i) + len("return bl;},")
snippet = src[i:j].rstrip().rstrip(",") # « md2b(md){…} »
return snippet
def test_md2b_converts_numbered_lists_for_real():
node = shutil.which("node")
if not node:
pytest.skip("node absent : test comportemental md2b ignoré")
md = ("# Titre\n"
"1. premier\n"
"2. second\n"
"3) troisième\n"
"- puce\n"
"- [ ] à faire\n"
"> citation\n")
script = (
"function _tblIsData(){return false;}\n"
"function _tblSplitLine(){return [];}\n"
"const editor={mkB(t,c,e){const o={type:t,content:c||''};"
"if(e)Object.assign(o,e);return o;},\n"
f"{_md2b_source()}}};\n"
f"console.log(JSON.stringify(editor.md2b({json.dumps(md)})));\n"
)
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
timeout=30, encoding="utf-8")
assert out.returncode == 0, out.stderr
blocks = json.loads(out.stdout)
assert [b["type"] for b in blocks] == [
"heading_1",
"numbered_list", "numbered_list", "numbered_list", # 1. 2. 3)
"bulleted_list", "to_do", "quote",
]
assert [b["content"] for b in blocks[1:4]] == ["premier", "second", "troisième"]
+82
View File
@@ -0,0 +1,82 @@
"""7.59.3 — « Turn into » du menu contextuel : garde cassée + texte conservé."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
EDITOR_JS = ROOT / "static" / "js" / "page_editor_scripts.js"
SRC = EDITOR_JS.read_text(encoding="utf-8")
def _slice(start: str, end: str) -> str:
i = SRC.index(start)
j = SRC.index(end, i)
return SRC[i:j].rstrip()
def test_turn_into_guard_matches_objects_not_strings():
# BUG : indexOf() sur un tableau d'objets → toujours -1 → retour immédiat.
assert "_BLOCK_TURN_TYPES.indexOf(type)" not in SRC
assert "_BLOCK_TURN_TYPES.some(t=>t.id===type)" in SRC
def test_turn_into_menu_wiring():
# menu ⋮⋮ → « Turn into » → sous-menu → choix de type
assert "_blockMenuItem('submenu','↩','Turn into',{sub:'turn'})" in SRC
assert "'turn-type'" in SRC # construit par _blockMenuItem
assert "act==='turn-type'" in SRC
assert "_blockTurnMenuOpen(" in SRC
def test_turn_into_keeps_text_for_to_do_and_toggle():
# Convertir un texte en To-do / Toggle ne doit plus effacer le contenu.
line = [ln for ln in SRC.splitlines()
if ln.strip().startswith("turnInto(idx,type")][0]
assert "type!=='to_do'&&type!=='toggle'" not in line
assert "if(keep&&['image','embed','meeting','divider'" in line
# ── Comportement réel : la méthode extraite de la source, exécutée sous node ─
def test_turn_into_converts_blocks_for_real():
node = shutil.which("node")
if not node:
pytest.skip("node absent : test comportemental turnInto ignoré")
types = _slice("const _BLOCK_TURN_TYPES=[", "];")
method = _slice("turnInto(idx,type", "setBlockColor(idx,key,val){")
script = f"""
function _rtSync(){{}}
function _selClear(){{}}
{types}];
const E = {{
blocks: [{{id:'b1',type:'paragraph',content:'Bonjour'}}],
_blockTargets(i){{return [i];}},
sync(){{}}, pushHistory(){{}}, dirty:false,
autoSave(){{}}, render(){{}}, getEl(){{return null;}},
{method}
}};
E.turnInto(0,'heading_2');
const heading = {{type:E.blocks[0].type, content:E.blocks[0].content}};
E.turnInto(0,'to_do');
const to_do = {{type:E.blocks[0].type, content:E.blocks[0].content, checked:E.blocks[0].checked}};
E.turnInto(0,'button'); // type sans texte → contenu vidé
const button = {{type:E.blocks[0].type, content:E.blocks[0].content}};
console.log(JSON.stringify({{heading:heading, to_do:to_do, button:button}}));
"""
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
timeout=30, encoding="utf-8")
assert out.returncode == 0, out.stderr
res = json.loads(out.stdout)
# 1) le changement de format a lieu (avant la correction : aucune conversion)
assert res["heading"] == {"type": "heading_2", "content": "Bonjour"}
# 2) To-do : texte conservé comme étiquette, case décochée
assert res["to_do"] == {"type": "to_do", "content": "Bonjour", "checked": False}
# 3) Button (type sans texte) → contenu vidé
assert res["button"] == {"type": "button", "content": ""}
+29
View File
@@ -0,0 +1,29 @@
"""7.60.0 — bouton + à gauche du handle : ajoute un bloc sous le courant."""
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
EDITOR_JS = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
APP_CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
def test_button_rendered_left_of_the_handle():
# le + est émis AVANT le handle dans le wrapper du bloc
i_btn = EDITOR_JS.index("class=\"block-insert-btn\"")
i_handle = EDITOR_JS.index("<div class=\"block-handle\">", i_btn)
assert i_btn < i_handle
# clic → insertion sous le bloc courant (mécanisme déjà existant)
assert 'class="block-insert-btn" title="Add block below" aria-label="Add block below" onclick="E.addAfter(${idx})"' in EDITOR_JS
# réservé aux blocs de premier niveau (idx valide), comme .block-actions
assert "const insertBtn=idx>=0?" in EDITOR_JS
assert "addAfter(idx,type){return this.addAt(idx+1,type);}" in EDITOR_JS
def test_button_style_and_visibility():
assert ".block-insert-btn {" in APP_CSS
assert "left: -64px;" in APP_CSS # gouttière .blocks-container (64px)
assert ".block-wrapper:hover .block-insert-btn," in APP_CSS
assert ".block-insert-btn:hover {" in APP_CSS
# suit le handle quand la gouttière passe à 32px, masqué partout où il l'est
assert ".block-insert-btn { left: -56px; }" in APP_CSS
assert APP_CSS.count(".block-insert-btn { display: none; }") == 2
+172
View File
@@ -0,0 +1,172 @@
"""7.61.0 — menu contextuel de bloc refondu façon Notion (actions + raccourcis)."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
SRC = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
def _slice(start: str, end: str) -> str:
i = SRC.index(start)
j = SRC.index(end, i)
return SRC[i:j].rstrip()
# ── Panneau de gauche ───────────────────────────────────────────────────────
def test_left_panel_matches_spec():
# recherche, groupe « Text », les 10 actions dans l'ordre, pied métadonnées
assert 'placeholder="Search actions..."' in SRC
assert "'<div class=\"bm-group\">Text</div>'" in SRC
order = ["'↩','Turn into'", "'🎨','Color'", "'🔗','Copy link to block'",
"'▣','Duplicate'", "'↪','Move to…'", "'✕','Delete'",
"'💬','Comment'", "'✎','Suggest edits'", "'✨','Ask AI'",
"'📚','Skills'"]
chunk = SRC[src_index("d.innerHTML='<input"):src_index("+_blockMenuMeta(b);")]
positions = [chunk.index(o) for o in order]
assert positions == sorted(positions), positions
# pied de page : auteur / heure / taille
meta = _slice("function _blockMenuMeta(", "\n function blockMenuOpen(")
for token in ("Last edited by ", "word", "character", "Today at ",
".um-name", "updatedAt"):
assert token in meta, token
def src_index(needle: str) -> int:
return SRC.index(needle)
def test_actions_are_all_dispatched():
dispatch = _slice("function _blockMenuClick(", "\n function _blockMenuItem(")
for act in ("submenu", "duplicate", "copy-link", "move-to", "delete",
"comment", "suggest", "ai", "skill", "color", "turn-type"):
assert f"act==='{act}'" in dispatch, act
# méthodes réelles côté éditeur
# v7.62 : « askAI(idx) » renommé askAIBlock(idx) — « askAI() » l'écrasait
for m in ("commentBlock(idx)", "askAIBlock(idx)", "suggestEdit(idx)",
"runSkill(idx,tpl)", "applyLastColor(idx)",
"copyBlockLink(bid)", "deleteSelected(idx)", "duplicateBlock("):
assert m in SRC, m
# menu : pas d'entrée « copier/couper/coller les blocs » (raccourcis conservés)
assert "act==='copy-blocks'" not in dispatch
# ── Sous-menus ──────────────────────────────────────────────────────────────
def test_turn_into_submenu():
# v7.63 : le générateur d'items a été extrait (_blockTurnItems) pour être
# réutilisé par la feuille mobile — le test vise le générateur.
turn = _slice("function _blockTurnItems(", "\n function _blockColorItems(")
assert "_BLOCK_TURN_MENU" in turn and "check:on" in turn # ✓ du type actuel
assert "'<span class=\"bm-check\">✓</span>'" in SRC
for name in ("Text", "Heading 1", "Heading 4", "Bulleted list",
"Numbered list", "To-do list", "Toggle list", "Code", "Quote",
"Callout", "Block equation", "Synced block",
"2 columns", "5 columns"):
assert f"'{name}'" in SRC, name
assert "Create '+t.cols+' columns of blocks" in SRC # info-bulle
assert "data-cols" in SRC and "cols>=2" in SRC # 2..5 colonnes
# Page / Page in / Toggle heading : types inexistants dans FlowDeck → absents
assert "'Page in'" not in SRC and "'Toggle heading 1'" not in SRC
def test_color_submenu():
color = _slice("function _blockColorItems(", "\n function _blockSkillsMenuOpen(")
for group in ("Last used", "Text color", "Background color"):
assert f">{group}</div>" in color, group
names = ["'Default'", "'Gray'", "'Brown'", "'Orange'", "'Yellow'",
"'Green'", "'Blue'", "'Purple'", "'Pink'", "'Red'"]
arr = _slice("const _BLOCK_COLOR_NAMES=", "];")
for n in names:
assert n in arr, n
assert "bm-dot" in color # carré de couleur (fond)
assert "_BM_SHORTCUTS.color" in color # raccourci « Last used »
assert "fdLastBlockColor" in SRC # mémoire de la dernière couleur
def test_skills_submenu_uses_real_skills():
skills = _slice("function _blockSkillsMenuOpen(", "\n function _rtMac(")
assert "fetch('/api/agent/skills'" in skills
assert "act==='skill'" in SRC or "'skill'" in skills
assert "prompt_template" in skills
assert "_aiWritingRun('write',idx" in SRC # exécution réelle via l'IA
# ── Survol / fermeture ──────────────────────────────────────────────────────
def test_hover_submenus_and_close():
for token in ("_blockSubOpen(kind,bid,anchor)", "data-sub",
"closest('[data-sub]')", "mouseleave", "_blockSubCloseLater",
"function _blockMenuClose()", "_blockDocDown"):
assert token in SRC, token
assert "si.focus()" in SRC # recherche focalisée à l'ouverture
# ── Raccourcis ──────────────────────────────────────────────────────────────
def test_shortcuts_implemented():
keys = _slice("// ── v7.61 : raccourcis du menu contextuel de bloc ──",
"document.addEventListener('keydown', function (e) {\n if (!(e.ctrlKey || e.metaKey)")
combos = {
"k === 'j'": "Ctrl+J (Ask AI)",
"k === 'h'": "Ctrl+⇧+H (dernière couleur)",
"k === 'm'": "Ctrl+⇧+M (commenter)",
"k === 'x'": "Ctrl+⇧+Alt+X (suggest edits)",
"k === 'l'": "Alt+⇧+L (copier le lien)",
"k === 'p'": "Ctrl+⇧+P (déplacer vers…)",
"k === 'd'": "Ctrl+D (dupliquer)",
"k === 'delete'": "Del (supprimer, menu ouvert uniquement)",
"k === 'escape'": "Échap (fermer)",
}
for token, label in combos.items():
assert token in keys, label
# Del n'agit jamais pendant la saisie
assert "tgt.isContentEditable" in keys
# le libellé affiché dans le menu correspond aux combinaisons réelles
shortcuts = _slice("const _BM_SHORTCUTS=", ";")
for hint in ("Alt+⇧+L", "Ctrl+D", "Ctrl+⇧+P", "Del", "Ctrl+⇧+M",
"Ctrl+⇧+Alt+X", "Ctrl+J", "Ctrl+⇧+H"):
assert hint in shortcuts, hint
# ── Comportement réel : conversion en colonnes (node) ───────────────────────
def test_turn_into_columns_creates_children_for_real():
node = shutil.which("node")
if not node:
pytest.skip("node absent : test comportemental ignoré")
types = _slice("const _BLOCK_TURN_TYPES=[", "];")
method = _slice("turnInto(idx,type", "setBlockColor(idx,key,val){")
script = f"""
function _rtSync(){{}}
function _selClear(){{}}
var _n=0; function genId(){{return 'g'+(++_n);}}
{types}];
const E = {{
blocks:[{{id:'b1',type:'paragraph',content:'Titre'}}],
_blockTargets(i){{return [i];}}, sync(){{}}, pushHistory(){{}}, dirty:false,
autoSave(){{}}, render(){{}}, getEl(){{return null;}}, _pickSyncedBlock(){{}},
{method}
}};
E.turnInto(0,'columns',3);
console.log(JSON.stringify({{type:E.blocks[0].type, kids:E.blocks[0].children.length}}));
"""
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
timeout=30, encoding="utf-8")
assert out.returncode == 0, out.stderr
assert json.loads(out.stdout) == {"type": "columns", "kids": 3}
# ── Styles ──────────────────────────────────────────────────────────────────
def test_menu_styles():
for sel in (".block-menu .bm-group {", ".block-menu .bm-foot {",
".block-menu .bm-check {", ".block-menu .bm-dot {",
".block-menu-sub { min-width: 230px; max-height: 60vh;"):
assert sel in CSS, sel
+171
View File
@@ -0,0 +1,171 @@
"""7.62.0 — menu contextuel au highlight de texte (toolbar façon Notion)."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
SRC = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
TPL = (ROOT / "app" / "templates" / "_page_editor_content.html").read_text(encoding="utf-8")
CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
APPJS = (ROOT / "static" / "js" / "app.js").read_text(encoding="utf-8")
def _slice(src: str, start: str, end: str) -> str:
i = src.index(start)
return src[i:src.index(end, i)]
# ── Markup : les 4 lignes + Skills + pied, dans l'ordre de la description ───
def test_toolbar_markup_matches_spec():
chunk = _slice(TPL, 'class="format-toolbar"', "Copy Link Toast")
order = [
"fmtStyle($event)", # ligne 1 : Normal Text ›
"fmtApply('bold')", "fmtApply('italic')", "fmtApply('underline')",
"fmtApply('strikeThrough')", "fmtCode()", # ligne 2 : A B I U T Tx
"fmtLink()", "fmtHighlight()", "fmtCodeBlock()", "fmtEquation()",
"fmtMore($event)", # ligne 3 : 🔗 S </> √x …
"fmtComment()", "fmtReact()", # ligne 4 : 💬 Comment | 🙂 réaction
"ft-group", "Improve writing", "Proofread", "Explain", "Reformat",
"Edit with AI", "Alt+⇧+E", # pied de page
]
positions = [chunk.index(o) for o in order]
assert positions == sorted(positions), positions
# ligne 1 dynamique (type du bloc) + chevron de sous-menu
assert "fmtStyleName()" in chunk and "ft-caret" in chunk
# info-bulle HTML (exemple en italique + description)
assert "data-tooltip-html=" in chunk
# icônes réellement définies dans le jeu d'icônes
assert 'fd_icon("message-square",15)' in chunk
assert 'fd_icon("link",15)' in chunk
def test_toolbar_row4_matches_reference_layout():
"""v7.67 — ligne Comment à gauche, réaction à droite, Skills avec ≡."""
chunk = _slice(TPL, 'class="format-toolbar"', "Copy Link Toast")
# ligne 4 = une seule rangée scannée, boutons aux 2 extrémités
assert 'ft-row-split' in chunk
assert '<span>Comment</span>' in chunk # libellé, pas juste l'icône
assert chunk.index("fmtComment()") < chunk.index("fmtReact()")
# l'✨ Ask AI a sauté : doublon exact du « Edit with AI » du pied
assert '"fmtAskAI()"' not in chunk
# entête Skills (≡ en CSS) + liste scrollable
assert 'ft-skills-head' in chunk and 'ft-skills' in chunk
for sel in (".format-toolbar .ft-row-split {", ".format-toolbar .ft-comment {",
".format-toolbar .ft-skills-head::after {", ".format-toolbar .ft-skills {"):
assert sel in CSS, sel
# ── Actions : câblées vers de vraies méthodes, sous-menus réutilisés ────────
def test_actions_are_wired_to_real_methods():
for m in ("fmtStyle(ev)", "fmtColor(ev)", "fmtApply(f)", "fmtCode()",
"fmtHighlight()", "fmtCodeBlock()", "fmtLink()", "fmtEquation()",
"fmtMore(ev)", "fmtComment()", "fmtAskAI()", "fmtSkill(tpl)",
"fmtEditAI()", "askAIBlock(idx)"):
assert m in SRC, m
# pas de doublon de menu : on rouvre les sous-menus/menus déjà existants
assert "_blockTurnMenuOpen(p.x,p.y,bid)" in SRC
assert "_blockColorMenuOpen(p.x,p.y,bid)" in SRC
assert "blockMenuOpen(" in SRC
# sous-menu ouvert depuis la toolbar : fermeture au survol sortant
assert "function _subHoverClose()" in SRC
# Ctrl+J et Alt+⇧+E ouvrent l'IA du BLOC (l'ancien askAI(idx) était écrasé
# par askAI() défini plus bas dans le même objet)
assert "E.askAIBlock(i1)" in SRC
assert "k === 'e'" in SRC
assert "askAI(idx)" not in SRC
# ── Persistance du formatage : sync() relit le DOM formaté en markdown ─────
def test_formatting_survives_sync():
assert "function gtMd(" in SRC
assert "gtMd(el)" in SRC and "gtMd(che)" in SRC # sync() + enfants toggle/columns
for tok in ("'**'+inner+'**'", "'*'+inner+'*'", "'__'+inner+'__'",
"'~~'+inner+'~~'", "'`'+inner+'`'", "'=='+inner+'=='"):
assert tok in SRC, tok
# mdEsc rend les nouveaux marqueurs (souligné, surlignage, équation inline)
for re_ in ("/__([^_]+)__/g", "/==([^=]+)==/g", "/\\$\\$([^$]+)\\$\\$/g"):
assert re_ in SRC, re_
# les chips wiki gardent leur token brut
assert "fd-wiki-chip" in SRC
# ── Placement & fermeture ──────────────────────────────────────────────────
def test_menu_position_and_close():
assert "r.bottom+8" in SRC # sous la sélection (bas de l'écran : au-dessus)
assert "d.fmt.open=false;d._sel=null" in SRC # clic ailleurs → fermeture
assert "closest('.format-toolbar')" in SRC # clic dans la toolbar ≠ clic ailleurs
assert "@mousedown.prevent" in TPL # la sélection survit au clic
# ── Styles ────────────────────────────────────────────────────────────────
def test_menu_styles():
for sel in (".format-toolbar .ft-row {", ".format-toolbar .ft-item {",
".format-toolbar .ft-style {", ".format-toolbar .ft-group {",
".format-toolbar .ft-sep {", ".format-toolbar .ft-foot {",
".tooltip.tooltip-light {"):
assert sel in CSS, sel
# l'info-bulle claire s'applique aux éléments du menu de sélection
assert "data-tooltip-html" in APPJS and "tooltip-light" in APPJS
# ── Comportement réel : gtMd (DOM → source markdown) + mdEsc, sous node ────
def test_gt_md_and_md_esc_roundtrip_for_real():
node = shutil.which("node")
if not node:
pytest.skip("node absent : test comportemental ignoré")
gtmd = _slice(SRC, "function gtMd(", " // Enveloppe/désenveloppe")
mdesc = _slice(SRC, "function mdEsc(", " // v5.11.0: labels of [[fdpage:ID]]")
script = f"""
var window = {{}}; // gtMd fait window.gtMd=…
function gtTok(e){{ return e.__raw || ''; }}
function fdDateLabel(iso){{ return iso; }}
{gtmd}
{mdesc}
// DOM minimal (gtMd ne lit que childNodes / tagName / classList / attributs)
function T(s){{ return {{nodeType:3, nodeValue:s}}; }}
function E(tag, attrs, kids){{
attrs = attrs || {{}};
const cls = String(attrs['class'] || '').split(/\\s+/);
return {{nodeType:1, tagName:tag, childNodes:kids || [],
classList:{{contains:c=>cls.indexOf(c) >= 0}},
getAttribute:k=>(k in attrs ? attrs[k] : null),
hasAttribute:k=>(k in attrs)}};
}}
// querySelector truthy → chemin « formaté » de gtMd (gtTok jamais appelé)
const root = E('DIV', {{'class':''}}, [
E('B', {{}}, [T('gras')]),
E('EM', {{}}, [T('italique')]),
E('U', {{}}, [T('souligne')]),
E('S', {{}}, [T('barre')]),
E('CODE', {{}}, [T('code')]),
E('MARK', {{}}, [T('surligne')]),
E('A', {{'href':'https://example.com/x'}}, [T('lien')]),
E('SPAN', {{'class':'fd-wiki-chip', 'data-token':'[[fdpage:42]]'}}),
E('SPAN', {{'class':'fd-math-inline', 'data-tex':'x^2'}}),
T(' fin')
]);
root.querySelector = () => ({{}}); // force le chemin formaté
const md = gtMd(root);
const html = mdEsc(md);
console.log(JSON.stringify({{md:md, html:html}}));
"""
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
timeout=30, encoding="utf-8")
assert out.returncode == 0, out.stderr
data = json.loads(out.stdout)
assert data["md"] == ("**gras***italique*__souligne__~~barre~~`code`==surligne=="
"[lien](https://example.com/x)[[fdpage:42]]$$x^2$$ fin"), data["md"]
for token in ("<strong>gras</strong>", "<u>souligne</u>", "<mark>surligne</mark>",
"<code>code</code>", 'href="https://example.com/x"',
'class="fd-wiki-chip"', 'data-tex="x^2"'):
assert token in data["html"], token
+136
View File
@@ -0,0 +1,136 @@
"""7.63.0 — menus mobiles façon Notion : barre horizontale au « / » + feuille plein écran."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
SRC = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
def _slice(src: str, start: str, end: str) -> str:
i = src.index(start)
return src[i:src.index(end, i)]
# ── Barre horizontale : les 15 boutons, dans l'ordre de la description ─────
def test_bar_items_in_order():
chunk = _slice(SRC, "const MTB={", "open(i,el){const d=this.el()")
order = ["['plus'", "['turn'", "['at'", "['comment'", "['image'", "['trash'",
"['left'", "['right'", "['up'", "['down'", "['undo'", "['redo'",
"['color'", "['more'", "['kbd'"]
positions = [chunk.index(o) for o in order]
assert positions == sorted(positions), positions
# les 4 entrées « texte + chevron » de la description
for w in ("'plus'", "'turn'", "'color'", "'more'"):
assert w in chunk, w
assert "fd-mtb-wide" in chunk and "fd-mtb-caret" in chunk
# ── Chaque bouton → une action réelle (pas de bouton mort) ─────────────────
def test_bar_actions_are_real():
act = _slice(SRC, "act(k,btn){", "\n };")
for token in ("sheet('Insert block'", "sheet('Turn into'", "sheet('Color'",
"blockMenuOpen(", "a.blur()", "E.openMention(el)",
"E.commentBlock(idx)", "E.applySlash('image')", "E.deleteSelected(idx)",
"E.moveBlock(bid,k)", "E.undo()", "E.redo()"):
assert token in act, token
# « More » ouvre bien le menu contextuel de bloc existant (desktop)
assert "this.close();blockMenuOpen(" in act
# ── Feuille plein écran : titre centré + Cancel + listes réutilisées ───────
def test_fullscreen_sheet():
sheet = _slice(SRC, "sheet(title,html,handler){", "_insertHtml(){")
for token in ("fd-msheet-head", "fd-msheet-title", "fd-msheet-cancel", "Cancel",
"fd-msheet-list block-menu"):
assert token in sheet, token
# contenu : items slash partagés + sous-menus de bloc partagés
ins = _slice(SRC, "_insertHtml(){", "_bmAct(")
assert "_GROUP_LABEL[g.name]" in ins and "_slashItemHtml" in ins
assert "function _blockTurnItems(bid)" in SRC and "function _blockColorItems(bid)" in SRC
# choix d'un item → applySlash via SM.sel (ferme tout)
assert "SM.sel(it.dataset.sid)" in SRC
# ── Branchement sur le slash : mobile = barre, desktop = popup intacte ─────
def test_mobile_hook():
for token in ("function _fdMobile()", "max-width: 768px",
"if(_fdMobile()){SM._o=true;MTB.open(i,el);return;}",
"MTB.close();", "!MTB.contains(ev.target)",
"else if(SM.isOpen)"):
assert token in SRC, token
# le popup desktop (positionnement + focus de la recherche) est toujours là
assert "SM._e.style.display='flex'" in SRC and "SM._in.focus()" in SRC
# la barre se referme quand le / disparaît
assert "if(t.charAt(0)!=='/')SM.close();" in SRC
# ── Déplacement de bloc (↑↓ ordre, → indent, ← outdent) ────────────────────
def test_move_block_logic():
m = _slice(SRC, "moveBlock(bid,dir){", "applyLastColor(idx){")
for token in ("dir==='up'", "dir==='down'", "dir==='right'", "parent.children",
"pushHistory()", "this.sync()", "_focusBid(bid)"):
assert token in m, token
# ── Styles ────────────────────────────────────────────────────────────────
def test_styles():
for sel in (".fd-mtb {", ".fd-mtb .fd-mtb-item {", ".fd-mtb .fd-mtb-wide {",
".fd-msheet {", ".fd-msheet-head {", ".fd-msheet-cancel {",
".fd-msheet-list.block-menu {", ".fd-msheet .bm-item {",
".fd-msheet .slash-item {"):
assert sel in CSS, sel
# ── Comportement réel : moveBlock sous node ───────────────────────────────
def test_move_block_for_real():
node = shutil.which("node")
if not node:
pytest.skip("node absent : test comportemental ignoré")
method = _slice(SRC, "moveBlock(bid,dir){", "applyLastColor(idx){")
script = f"""
function _rtSync(){{}}
const E = {{
blocks:[{{id:'a',type:'paragraph',content:'A'}},
{{id:'tg',type:'toggle',content:'T',expanded:true,children:[]}},
{{id:'c',type:'paragraph',content:'C'}}],
sync(){{}}, pushHistory(){{}}, dirty:false, autoSave(){{}}, render(){{}},
_focusBid(){{}}, showToast(){{}}, msgs:[],
{method}
}};
E.moveBlock('c','right'); // C s'indente dans le toggle
const indent = {{kids:E.blocks[1].children.map(b=>b.id).join(','), top:E.blocks.map(b=>b.id).join(',')}};
E.moveBlock('c','left'); // C ressort à la racine : a,tg,c
const top2 = E.blocks.map(b=>b.id).join(',');
E.moveBlock('a','down'); // A descend : tg,a,c
const ordre1 = E.blocks.map(b=>b.id).join(',');
E.moveBlock('c','up'); // C remonte : tg,c,a
const ordre2 = E.blocks.map(b=>b.id).join(',');
E.moveBlock('c','up'); // encore : c,tg,a
const ordre3 = E.blocks.map(b=>b.id).join(',');
E.moveBlock('c','up'); // déjà en haut → toast, inchangé
const ordre4 = E.blocks.map(b=>b.id).join(',');
console.log(JSON.stringify({{indent:indent, top2:top2, ordre1:ordre1, ordre2:ordre2, ordre3:ordre3, ordre4:ordre4}}));
"""
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
timeout=30, encoding="utf-8")
assert out.returncode == 0, out.stderr
d = json.loads(out.stdout)
assert d["indent"] == {"kids": "c", "top": "a,tg"}, d
assert d["top2"] == "a,tg,c", d
assert d["ordre1"] == "tg,a,c", d
assert d["ordre2"] == "tg,c,a", d
assert d["ordre3"] == "c,tg,a", d
assert d["ordre4"] == "c,tg,a", d # garde « déjà en haut »
+139
View File
@@ -0,0 +1,139 @@
"""7.64.0 — commentaires façon Notion (ancres jaunes + tiroir) & réactions sur texte."""
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SRC = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
TPL = (ROOT / "app" / "templates" / "_page_editor_content.html").read_text(encoding="utf-8")
CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
PICKER = (ROOT / "app" / "templates" / "_icon_picker.html").read_text(encoding="utf-8")
PSTORE = (ROOT / "static" / "js" / "_icon_picker_2.js").read_text(encoding="utf-8")
# ── Backend : migration + endpoints ────────────────────────────────────────
def test_text_reactions_table_exists(client): # le fixture client initialise le schéma
from app.db import get_conn
with get_conn() as conn:
cols = {r[1] for r in conn.execute("PRAGMA table_info(text_reactions)").fetchall()}
assert {"id", "page_id", "block_id", "anchor_start", "anchor_end", "emoji",
"user_id", "created_at"} <= cols, cols
def test_reactions_endpoints_registered():
from app.routers.collaboration import router
methods = {(r.path, m) for r in router.routes for m in r.methods}
assert ("/api/pages/{page_id}/reactions", "GET") in methods
assert ("/api/pages/{page_id}/reactions", "POST") in methods
def test_toggle_reaction_api(client):
"""Le POST bascule (added → removed → added) et le GET regroupe par plage."""
from app.auth.session import SessionManager
from app.db import get_conn
with get_conn() as conn:
conn.execute("INSERT INTO users (login, full_name, email) VALUES (?,?,?)",
("v764u", "V764 User", "[email protected]"))
uid = conn.execute("SELECT id FROM users WHERE login='v764u'").fetchone()["id"]
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format) VALUES (?,?,?,?)",
("u764", "React Page", "[]", "blocks"))
pid = cur.lastrowid
conn.commit()
cookies = {"flowdeck_session": SessionManager.create_session(
{"id": uid, "login": "v764u", "is_admin": 0})}
payload = {"block_id": "b1", "anchor_start": 0, "anchor_end": 5, "emoji": "👍"}
r = client.post(f"/api/pages/{pid}/reactions", json=payload, cookies=cookies)
assert r.status_code == 200 and r.json()["status"] == "added"
r = client.post(f"/api/pages/{pid}/reactions", json=payload, cookies=cookies)
assert r.status_code == 200 and r.json()["status"] == "removed"
r = client.post(f"/api/pages/{pid}/reactions", json=payload, cookies=cookies)
assert r.json()["status"] == "added"
r = client.get(f"/api/pages/{pid}/reactions", cookies=cookies)
assert r.status_code == 200
rows = r.json()["reactions"]
assert len(rows) == 1
assert rows[0]["emoji"] == "👍" and rows[0]["count"] == 1 and rows[0]["mine"] == 1
# plage vide → 400
bad = dict(payload, anchor_start=4, anchor_end=4)
r = client.post(f"/api/pages/{pid}/reactions", json=bad, cookies=cookies)
assert r.status_code == 400
# ── Toolbar : bouton réaction ──────────────────────────────────────────────
def test_toolbar_has_react_button():
assert 'fmtReact()' in TPL
assert 'data-tooltip="React to selected text"' in TPL
# position (v7.67) : 4ᵉ ligne, à droite de « Comment », après les outils
chunk = TPL[TPL.index('class="format-toolbar"'):TPL.index("Copy Link Toast")]
order = ["fmtMore($event)", "fmtComment()", "fmtReact()", "ft-group"]
positions = [chunk.index(o) for o in order]
assert positions == sorted(positions), positions
def test_fmt_react_opens_picker_with_selection():
js = SRC[SRC.index("fmtReact(){"):SRC.index("toggleComments(){")]
for token in ("FDIconPicker.openFor", "title:text", "onPick:",
"toggleReaction(bid,span.start,span.end,emoji)", "_selSpan(bel,this._sel.range)"):
assert token in js, token
# ── Ancres commentées + pastilles ──────────────────────────────────────────
def test_anchors_painting():
for token in ("function _wrapAnchored(", "_paintAnchors()", "fd-anchor",
"fd-react-chip", "data-react-bid", "data-react-start", "openThread(cid)",
"loadReactions()", "toggleReaction(bid,start,end,emoji)"):
assert token in SRC, token
# repeint après chaque reconstruction du bloc ET après le chargement
assert "this._paintAnchors();}catch(e){}" in SRC # fin de render()
assert "this._paintAnchors();\n" in SRC # fin de loadComments/loadReactions
# la pastille n'est pas du contenu lu/sauvé
assert "n.classList.contains('fd-react-chip')" in SRC # gtTok
assert "cl.contains('fd-react-chip')" in SRC # gtMd
# offsets source (les annotations ne décalent pas les ancres)
assert "function _srcOffset(" in SRC and "_srcNodeLen(" in SRC
# clic ancre → fil ; clic pastille → sélecteur
assert "t.closest('.fd-react-chip')" in SRC
assert "t.closest('.fd-anchor[data-cid]')" in SRC
# ── Tiroir de commentaires (image 1) ───────────────────────────────────────
def test_drawer_markup():
for token in ('class="ct-head"', 'class="ct-avatar"', 'class="ct-name"',
'class="ct-time"', 'class="ct-actions"', 'class="ct-menu"',
"copyCommentLink(c.id)", "deleteComment(c.id)", "resolveComment(c)",
':data-thread="c.id"', 'placeholder="Add a comment..."',
"commentAt()", "class=\"ct-send\"", "addPageComment()"):
assert token in TPL, token
# le bouton ✓ bascule resolve/reopen (l'objet, pas l'id seul)
assert "resolveComment(c)" in TPL and "async resolveComment(c)" in SRC
# hash #cmt- → centrage du fil
assert "#cmt-" in SRC and "scrollIntoView" in SRC
# styles du tiroir
for sel in (".comment-thread {", ".ct-head {", ".ct-avatar {", ".ct-menu {",
".ct-input-row {", ".ct-send {", ".fd-anchor {", ".fd-react-chip {"):
assert sel in CSS, sel
# ── Sélecteur d'émoji (image 3) : mode réaction ────────────────────────────
def test_picker_reaction_mode():
for token in ("fdip-title", "fdip-title-text", "fdip-title-grip",
"$store.fdIconPicker.title", "Filter...", "Recent"):
assert token in PICKER, token
for token in ("this.title = opts.title || ''", "this.category = 'people'",
"this.title = ''"):
assert token in PSTORE, token
# la ligne « Recent » au-dessus de la grille n'apparaît qu'en mode réaction
assert "$store.fdIconPicker.title && $store.fdIconPicker.category !== 'recent'" in PICKER
# onglets masqués en mode réaction (emoji only)
assert 'x-show="!$store.fdIconPicker.title"' in PICKER
+41
View File
@@ -0,0 +1,41 @@
"""7.66.0 — Escape du menu slash retire le « / » + bouton commenter à droite du bloc."""
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
EDITOR_JS = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
APP_CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
def test_slash_escape_removes_the_slash():
# une seule implémentation, appelée par les deux chemins (focus dans le bloc
# → onKd ; focus dans l'entrée du menu → listener document)
assert "escClose(){" in EDITOR_JS
assert EDITOR_JS.count("SM.escClose();") == 2
# ne retire que le « / » d'ouverture, remet le caret et sauvegarde
assert "n.nodeValue=v.slice(0,at)+v.slice(at+1);" in EDITOR_JS
assert "E.sync();E.dirty=true;E.autoSave();" in EDITOR_JS
def test_comment_button_on_the_right_of_the_block():
# émis APRÈS le contenu du bloc → à droite dans le flex du wrapper
i_tpl = EDITOR_JS.index("const cmtBtn=")
i_content = EDITOR_JS.index('class="${cls}">${inner}</${tag}>', i_tpl)
i_btn = EDITOR_JS.index("${cmtBtn}", i_tpl)
assert i_content < i_btn
# icône bulle de conversation +, ancre = commentaire du bloc
assert 'onclick="E.commentBlock(${idx})"' in EDITOR_JS
assert EDITOR_JS.count('<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5') == 1 # bulle (message-square)
assert '<path d="M12 8v5M9.5 10.5h5"/>' in EDITOR_JS # le + dans la bulle
# l'ancien .block-actions mort (opacity:0 sans survol) a disparu de renderBlock
assert "const actions=idx>=0?" not in EDITOR_JS
def test_comment_button_style_and_visibility():
assert ".block-comment-btn {" in APP_CSS
assert "right: -60px;" in APP_CSS # gouttière 64px, comme .block-actions
assert ".block-wrapper:hover .block-comment-btn," in APP_CSS
assert ".block-comment-btn:hover {" in APP_CSS
# masqué partout où le handle l'est : mobile (2 breakpoints) + page verrouillée
assert APP_CSS.count(".block-comment-btn { display: none; }") == 2
assert "body.page-locked .block-comment-btn," in APP_CSS
+44
View File
@@ -0,0 +1,44 @@
"""7.67.0 — réactions emoji visibles dans le tiroir Comments + suppression."""
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SRC = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
TPL = (ROOT / "app" / "templates" / "_page_editor_content.html").read_text(encoding="utf-8")
CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
def test_drawer_lists_reactions():
# section « Reactions » du tiroir : une ligne par plage × émoji
assert 'class="ct-reactions"' in TPL
assert 'x-for="r in reactionList()"' in TPL
assert '<span class="ct-re-emoji" x-text="r.emoji"></span>' in TPL
assert '<span class="ct-re-text" x-text="r.text"></span>' in TPL
# l'état vide ne s'affiche plus s'il reste des réactions
assert "comments.length === 0 && !reactionList().length" in TPL
# méthode : groupes déjà prêts côté API, texte source lu dans le DOM
assert "reactionList(){" in SRC
assert "_anchorText(bid,start,end)" in SRC
# le texte source ignore les pastilles (offsets identiques à sync())
assert "closest('.fd-react-chip'))continue;" in SRC
def test_reaction_removable_from_the_drawer():
assert 'title="Remove reaction"' in TPL
# le ✕ n'apparaît que sur MA réaction (les autres comptes sont en lecture)
assert 'x-show="r.mine" @click="removeReaction(r)"' in TPL
# suppression = POST toggle (le backend DELETE si ma ligne existe déjà)
assert "removeReaction(r){if(!r)return;this.toggleReaction(r.bid,r.start,r.end,r.emoji);}" in SRC
for sel in (".ct-section-title {", ".ct-reaction {", ".ct-re-emoji {", ".ct-re-text {"):
assert sel in CSS, sel
def test_reaction_removable_from_the_chip():
# chaque émoji de la pastille porte son data-emoji, `is-mine` = j'ai réagi
assert 'data-emoji="' in SRC
assert "parseInt(i.mine,10)?' is-mine':''" in SRC
assert ("d.removeReaction({bid:bid,start:start,end:end,"
"emoji:emo.getAttribute('data-emoji')})") in SRC
# la croix est un ::after (aucun nœud ajouté → offsets source inchangés)
assert ".fd-react-emo.is-mine::after {" in CSS
assert ".fd-react-chip:hover .fd-react-emo.is-mine::after { display: flex; }" in CSS
+66
View File
@@ -0,0 +1,66 @@
"""7.68.0 — colonnes/math/TOC rendus + copier-couper-coller multi-blocs."""
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SRC = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
def _fn(name: str, span: int = 1500) -> str:
i = SRC.index(name + "(")
return SRC[i:i + span]
def test_renderers_are_actually_dispatched():
# les trois renderers existaient mais n'étaient JAMAIS appelés →
# colonnes (slash + Turn into), équation et TOC rendaient un paragraphe vide
dispatch = _fn("renderBlock")
for call in ("renderColumnsBlock(b)", "renderMathBlock(b)", "renderTOC(b)"):
assert call in dispatch, call
assert SRC.count("function " + call.split("(")[0] + "(") == 1
# la suppression d'une colonne n'est plus un bouton invisible
assert ".block-column-wrapper:hover .block-actions {" in CSS
def test_gfm_tables_without_leading_pipe():
# l'IA rend souvent « A | B » / « --- | --- » — le pipe de tête est optionnel
assert "s.indexOf('|')>=0&&_tblSplitLine(s).length>=2" in SRC
# une ligne de séparation doit rester ≥2 cellules (sinon « texte » + « --- »)
assert "if(cells.length<2)return false;return cells.every" in SRC
# md2b ET paste2b passent par les mêmes helpers (alignés)
assert SRC.count("_tblIsSep(lines[i+1])") == 2
def test_clipboard_multi_block_selection():
# état : sélection vivante traversant ≥2 blocs ; sinon null (natif)
for tok in ("_clipState(){", "!s||!s.rangeCount||s.isCollapsed)return null;",
"_srcOffset(sblk,r.startContainer,r.startOffset)",
"_srcOffset(eblk,r.endContainer,r.endOffset)"):
assert tok in SRC, tok
# markdown : début partiel → blocs entiers → fin partielle
assert "_clipMarkdown(st){" in SRC
assert "blocksToMarkdown(b)" in SRC
# coupure : premier/trois restent (têtes/queues), milieu supprimé
assert "this.blocks.slice(0,st.si+1).concat([last],this.blocks.slice(st.ei+1))" in SRC
# copy/cut restent branchés (menu contextuel, execCommand)…
assert "ct.addEventListener('copy'," in SRC
assert "ct.addEventListener('cut'," in SRC
assert "ev.clipboardData.setData('text/plain',md)" in SRC
# …mais le VRAI raccourci clavier est intercepté au keydown : le navigateur
# n'émet aucun événement copy pour une sélection à travers deux
# contenteditable (v7.69).
assert "document.addEventListener('keydown'," in SRC
assert "if(k!=='c'&&k!=='x')return;" in SRC
# menu clic droit : groupe Clipboard Cut/Copy/Paste
assert "'<div class=\"bm-group\">Clipboard</div>'" in SRC
for act in ("clip-cut", "clip-copy", "clip-paste"):
assert f"data-bm-act=\"{act}\"" in SRC or f"'{act}'" in SRC, act
# « Copy link to block » : l'act du menu doit être celle du handler
assert "_blockMenuItem('copy-link'," in SRC
assert "act==='copy-link'" in SRC
def test_paste_single_structured_block_is_inserted():
# un seul bloc structuré collé (table, code, titre) ne retombe plus en texte brut
assert "if(parsed.length===1&&parsed[0].type==='paragraph')return fallback();" in SRC
+138
View File
@@ -0,0 +1,138 @@
"""v7.69.0 — sélection multi-blocs qui survit aux gestes + identité visuelle.
Trois bugs d'éditeur, une cause chacune :
1. Ctrl+C multi-blocs : le navigateur ne sérialise PAS une sélection qui
traverse deux contenteditable → il n'émet aucun événement copy → le
raccourci ne fait rien. On l'intercepte au keydown.
2. Glisser la souris : le « click » du contenteditable efface la sélection
juste après qu'on l'a reconstruite (mouseup → click → focusin → collapse).
3. Clic droit : la recherche du menu prenait le focus et effaçait le surlignage.
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
JS = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
def _norm(src: str) -> str:
return "".join(src.split())
# ── 1. Ctrl+C / Ctrl+X multi-blocs : interception clavier ──
class TestKeyboardShortcut:
def test_ctrl_shortcut_is_bound_on_document(self):
# capture sur document : le focus peut être n'importe où dans l'éditeur
assert _norm("document.addEventListener('keydown',function(ev){") in _norm(JS)
def test_shortcut_handles_both_c_and_x(self):
assert _norm("if(k!=='c'&&k!=='x')return;") in _norm(JS)
def test_shortcut_guards_outside_the_editor(self):
# ne doit JAMAIS voler Ctrl+C quand le focus est dans un champ du menu
assert "closest('#_blocksCt')" in JS
def test_shortcut_falls_back_to_native_for_single_block(self):
# sélection mono-bloc → on laisse le navigateur faire son travail
assert _norm("const st=E._clipState();if(!st||st.si>=st.ei)return;") in _norm(JS)
def test_shortcut_writes_to_clipboard_and_prevents_default(self):
assert _norm("ev.preventDefault();ev.stopPropagation();") in _norm(JS)
assert "navigator.clipboard.writeText(md)" in JS
def test_shortcut_is_registered_next_to_the_copy_listeners(self):
# le handler clavier vit au même endroit que copy/cut (bloc ct de v7.68)
cut = JS.index("ct.addEventListener('cut'")
shortcut = JS.index("closest('#_blocksCt')")
assert cut < shortcut < JS.index("ct.addEventListener('mousedown'")
# ── 2. La sélection reconstruite survit au collapse du focus ──
class TestSelectionPersistence:
def test_pending_range_variable_exists(self):
assert "var _mdSelRange=null;" in JS
def test_build_sel_at_stashes_the_range(self):
# sans cette copie, le « click » suivant détruit la sélection
assert _norm("s.removeAllRanges();s.addRange(a);") in _norm(JS)
assert _norm("_mdSelRange=a;") in _norm(JS)
def test_new_gesture_clears_the_pending_range(self):
# un clic ultérieur doit placer un caret normal, pas ressusciter l'ancien
assert _norm("_mdSelRange=null;") in _norm(JS)
def test_focusin_restores_after_the_synchronous_collapse(self):
# focusin se déclenche AVANT le collapse : la restauration doit être
# reportée au tour de boucle suivant (setTimeout 0)
assert "document.addEventListener('focusin'" in JS
restore = JS[JS.index("document.addEventListener('focusin'") :]
assert "setTimeout(function(){" in restore.split("document.addEventListener('mousedown'")[0]
assert "s.addRange(r)" in restore
def test_restore_is_a_noop_when_the_selection_survived(self):
assert _norm("if(s&&s.rangeCount&&!s.isCollapsed)return;") in _norm(JS)
# ── 3. Le menu contextuel n'efface plus le surlignage ──
class TestContextMenuKeepsHighlight:
def test_search_field_only_steals_focus_without_a_selection(self):
assert _norm("if(si&&!hasSel)setTimeout(function(){si.focus();},10);") in _norm(JS)
def test_right_click_prevents_default_to_freeze_the_selection(self):
# le preventDefault doit être sur mousedown (contextmenu arrive trop tard)
assert _norm("if(ev.button===2){const s=window.getSelection();if(s&&s.rangeCount&&!s.isCollapsed)ev.preventDefault();return;}") in _norm(JS)
def test_menu_freezes_the_clipboard_state_at_open_time(self):
# getSelection() n'est plus fiable une fois le menu ouvert
assert _norm("var clipSt=(E._clipState?E._clipState():null);") in _norm(JS)
assert "d._clipSt=clipSt;" in JS
# ── 4. Identité visuelle (logo + bannière du projet) ──
class TestBranding:
def test_banner_and_logo_assets_exist_and_are_optimised(self):
img = ROOT / "static" / "img"
banner, logo = img / "banner.webp", img / "logo.webp"
assert banner.exists() and logo.exists()
# les originaux pèsent des Mo : les assets servis doivent rester légers
assert banner.stat().st_size < 200_000
assert logo.stat().st_size < 100_000
# un JPEG de secours existe pour les crawlers qui n'aiment pas le WebP
assert (img / "banner.jpg").exists()
assert (img / "logo-512.jpg").exists()
def test_source_images_are_kept_for_regeneration(self):
src = ROOT / "assets"
assert (src / "banner.jpg").exists()
assert (src / "logo.jpg").exists()
def test_pwa_icons_generated_from_the_real_logo(self):
icons = ROOT / "static" / "icons"
for size in (72, 96, 128, 144, 152, 192, 384, 512):
f = icons / f"icon-{size}x{size}.png"
assert f.exists(), f"icône {size} manquante"
assert (icons / "apple-touch-icon.png").exists()
def test_base_head_declares_open_graph(self):
base = (ROOT / "app" / "templates" / "base.html").read_text(encoding="utf-8")
assert 'property="og:image" content="/static/img/logo-512.jpg"' in base
assert 'name="twitter:card" content="summary_large_image"' in base
assert '<meta name="description"' in base
def test_landing_shows_logo_and_banner(self):
landing = (ROOT / "app" / "templates" / "landing.html").read_text(encoding="utf-8")
# le logo remplace l'icône générique dans la barre de navigation
assert 'class="brand-mark" src="/static/img/logo.webp"' in landing
# la bannière est servie en WebP avec secours JPEG
assert '<source srcset="/static/img/banner.webp" type="image/webp">' in landing
assert 'src="/static/img/banner.jpg"' in landing
# l'image ne doit pas déclencher de refont : dimensions déclarées
assert 'width="2400" height="793"' in landing