Compare commits

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

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

Gates : probe_nav_perf + regression_partial_nav + regression_editor_mount +
regression_logout_dnd + smoke · pytest 1094 passed · ruff OK ·
OpenAPI 7.45.3.
2026-10-03 00:15:41 -04:00
bruno afbc236cc2 fix: navigation partielle Alpine sans course + scripts de page idempotents (v7.45.2)
FlowDeck CI / docker (push) Successful in 1m51s
FlowDeck CI / lint (push) Successful in 1m57s
FlowDeck CI / test (push) Successful in 15m16s
- app.js : x-ignore sur .main-wrapper au swap fdLoad (nœud remplacé
  uniquement), démontage unique quand tous les <script src> ont exécuté
  (load/error + filet 4s) via Alpine.initTree idempotent (_x_marker) —
  fini les cascades « Undefined variable » (éditeur) et « reading 'has' »
  ($store.fdCtx) : la zone s'initialisait avant composants et stores.
- 5 scripts de page gardés contre la ré-exécution (SyntaxError
  « Identifier 'LW' has already been declared » à la 2e visite
  partielle) : local_workspace, board, settings, database_table,
  page_editor_realtime.
- if (window.Alpine) → Alpine.data immédiat sinon listener alpine:init
  (déjà passé sur swap) : 8 scripts + 7 templates inline.
- app.css : purge des 7 blocs @font-face Inter orphelins (fichiers
  inexistants → 302 HTML → « Failed to decode downloaded font »).
- Gates : e2e/regression_editor_mount + e2e/regression_partial_nav
  (7 pages x complet/1er/2e passage) + regression_logout_dnd + smoke
  = 6/6 verts sur l'image rebuildée · pytest 1094 passed · ruff OK ·
  OpenAPI 7.45.2.
2026-10-02 21:29:46 -04:00
bruno c20aeaada1 fix: logout servi « hors ligne » par le SW + drag&drop upload en 403 CSRF (v7.45.1)
FlowDeck CI / docker (push) Successful in 1m51s
FlowDeck CI / lint (push) Successful in 1m56s
FlowDeck CI / test (push) Successful in 15m11s
- SW : le fetch event de navigation arrive en redirect:'manual' → toute 302
  du serveur (logout → /auth/login) se lisait opaqueredirect (status 0) →
  « bad status » → page hors ligne. networkFirst rejoue la requête en
  redirect:'follow' et sert une 302 synthétique vers l'URL finale (Chromium
  refuse une response 'redirected' servie à une navigation → ERR_FAILED).
  timeoutFetch annule désormais réellement (AbortController branchée).
- Local workspace : _doUpload (upload/upload-folder) et toggleFavorite
  n'envoyaient pas X-CSRF-Token → 403 systématique depuis A19 (derniers
  appels mutants du front, balayage complet refait).
- Porte : e2e/regression_logout_dnd.spec.js (2 tests verts, joués sur
  l'image rebuildée) · pytest 1094 passed · ruff OK · OpenAPI 7.45.1.
2026-10-02 17:41:07 -04:00
bruno 6d7af3fb64 feat: éditeur visuel d'automations (pipeline steps) + fix CSP multi-instructions (v7.45.0)
FlowDeck CI / docker (push) Successful in 1m51s
FlowDeck CI / lint (push) Successful in 1m56s
FlowDeck CI / test (push) Successful in 15m9s
Added — Settings → Automations, pipeline visuel (API steps v7.0) :
- Cartes ordinees (trigger/condition/delay/action) : resume + edition
  TYPÉE par kind/type (datalist evenements, 7 ops, 8 types d'action avec
  leurs champs reels), ajout/edition/suppression/haut-bas via
  POST/PUT/DELETE /workspace/automations[/steps]/...
- ✨ Convertir le JSON en pipeline (legacy → steps ordonnes) ; textareas
  JSON masques des qu'un step existe.
- Gate E2E « editeur visuel de steps » : creation → edition → ajout →
  carte « Action · webhook » sous CSP reel.

Fixed (trouve par le gate) — 51 expressions Alpine MULTI-INSTRUCTIONS
(`a=1; b()`) = interdites par le parseur CSP (une seule expression par
directive ; ';' = token inattendu) — INVISIBLE pour le scan par tokens :
- Conversion en methodes dans 11 fichiers : nav settings x8 (navTo),
  menu section base x7 (closeAndSetCount/Move/...), parts+editeur x13
  (setSharePerm, more*, markAndSave...), breadcrumb x5 (hover*/goClose),
  library/local x6 (menus popup), board x3 (pickStatus/...), ctx-menu x2
  (addTagAndClear), agent/card/gitea/workspaces x6.
- Scanner dedie scan_semi (inventaire ';' hors chaines) ajoute au lot.

Verifs : 39 templates Jinja parse OK · scan expressions = 0 incompatible
(4 faux positifs en chaines) · **E2E 8/8** · suite **1094/1094** ·
ruff OK · docs a jour
2026-10-02 16:56:29 -04:00
bruno b0af1bbfb6 feat: palette Ctrl+K — onglets Pages / ✨ Réponses IA (v7.44.0)
FlowDeck CI / lint (push) Successful in 1m57s
FlowDeck CI / test (push) Successful in 15m21s
FlowDeck CI / docker (push) Successful in 1m51s
Added :
- Onglets dans la palette (markup + CSS + wiring IIFE) :
  · Pages — comportement inchange (recherche /api/search + actions).
  · ✨ Réponses IA — POST /api/v2/search/ask (debounce 150 ms, CSRF via
    getCsrf(), gardes staleness onglet+requete) → answer_markdown rendu :
    echappement AVANT injection, [[fdpage:ID]] → lien citation (ids =
    chiffres, type = [a-z]+ — pas d'injection), **gras**, bloc « Sources »
    avec liens /pages/{id} · /db/{id} ; etats hint/chargement/erreur.
- Gate E2E etendu : ouverture → clic onglet IA → reponse non-echappee.
  (probe : ask = 200 en 1,6s, 8 citations, provider actif)

Reporte (backend absent) : onglet Fichiers — /api/search ne renvoie que
pages/collections → endpoint a creer d'abord (ROADMAP, entree mise a jour).

Notes : 401 transitoire sur le 1er ask d'un run E2E observe une fois
(session fraiche), non reproductible ensuite — signale CHANGELOG.

suite **1094/1094** · ruff OK · E2E **7/7** · docs a jour
2026-10-02 16:27:52 -04:00
bruno de751ffe35 feat: A20 TERMINÉ — Alpine en build CSP, unsafe-eval retiré de la CSP (v7.43.0)
FlowDeck CI / test (push) Successful in 15m24s
FlowDeck CI / lint (push) Successful in 2m1s
FlowDeck CI / docker (push) Successful in 1m52s
La bascule A20 phase 3 :
- static/js/alpine.csp.min.js (build officiel @alpinejs/csp, 0
  eval/new Function, parseur maison) servi partout : base.html,
  import.html, welcome.html + entree sw.js (cache bump v8).
- CSP : script-src 'self' 'nonce-…' — unsafe-eval SUPPRIMÉ (ne servait
  plus qu'Alpine standard). htmx allowEval:false deja pose (v7.37).
- Assertion test inversée : assert "'unsafe-eval'" not in script_src.
- Scan statique final sur TOUS les templates : 0 expression incompatible
  (4 residus = faux positifs dans des chaines de texte).

Pré-requis réunis par les lots 1-3 : 12 surfaces migrées + gateées
(csp_preview), registres Alpine.data, x-html → x-init+Alpine.effect,
délégués window.E, partage d'état lexical, bug topbar corrigé.

Verifs : suite **1094/1094** · ruff OK · eslint 0/0 · **E2E 7/7 sous
CSP reel** (script-src sans unsafe-eval verifie sur l'instance).

Hors gate (scan propre, gitea down) : board/table_view/teamload/
card_detail → à vérifier au premier usage avec gitea remonté (noté
ROADMAP/CHANGELOG).
2026-10-02 16:00:12 -04:00
bruno 48b5551b93 fix: BUG TOPBAR — boutons du header servis échappés sur toutes les pages (v7.42.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
Cause racine : {% set right_actions = '…' ~ fd_icon(…) ~ '…' %} —
fd_icon est une macro → Markup, et Markup.__radd__/__add__ ÉCHAPPE ses
arguments str → tous les segments littéraux sortent entité-és (&#34;/&lt;),
et le |safe de _header:141 est no-op sur un Markup déjà échappé.
Régression probable depuis A10 (activation d'autoescape).

Fix (5 templates, forme idiomatique) : conversion en block-set
{% set right_actions %}…{{ fd_icon(…) }}…{% endset %} — source brute,
interpolation Markup brute : gitea_workspace, page_editor,
page_editor_collection, workspace, workspaces. (Piège script : regex
greedy multi-lignes avalait le set suivant → matcher sur UNE ligne.)

Tests :
- NOUVEAU tests/test_topbar_right_actions.py (permanent) : /workspaces
  doit servir class="topbar-btn" parsé et ZÉRIE entité &#34;
- gate éditeur CSP : assertion .star-btn RÉTABLIE (les boutons rendent)
- debug temporaires (DBGCLS/DBGVAL) retirés

suite **1094/1094** (+1 nouveau test) · ruff OK · E2E **7/7** (5
csp_preview + 2 smoke) · docs à jour (CHANGELOG détail, ROADMAP bug →
CORRIGÉ)
2026-10-02 15:39:57 -04:00
bruno e4552c3763 chore: probe Playwright jetable retirée (e2e/_probe_edit.js)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
2026-10-02 15:15:58 -04:00
bruno 6914780f24 feat: A20 phase 3 LOT 3b — gitea + agent + éditeur verts en CSP (v7.41.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
Changed :
- gitea_workspace : x-data="giteaWorkspace" → appel giteaWorkspace(),
  new Date(…) → fmtGwDate(pp), x-html icône arbre → bindGwIcon (x-init +
  Alpine.effect).
- agent_panel : x-html markdown → bindMarkdown($el, m) (effet reactif).
- page_editor : les 12 sites window.E du topbar right_actions →
  délégués appState (edCall('…') x6, edTimeAgo, edCommentCount, edShared,
  bindStar — les 2 branches du ternaire favorited étaient identiques) ;
  + 3 sites dans _page_editor_content (edCall commentOnSelection,
  openBacklink, fmtImportSize, bindIconHtml). Garde Jinja : quotes \' dans
  le set délimité par ' (quote nue = 500).
- Gate éditeur (csp_preview) : création collection → /pages/{id},
  délégués + editorState liés, filet 0-erreur.

Fixed :
- x-html iconHtml() du contenu éditeur = directive INTERDITE sous build
  CSP (attrapé par le filet) → x-init + Alpine.effect.

⚠️ BUG pre-existant identifie (pas introduit ici) : les right_actions du
topbar sont servis ÉCHAPPÉS sur TOUTES les pages (entities &#34;/&lt; —
boutons Share/Star/Settings en texte brut). _header:141 a bien |safe,
ENV standard, rendu local = PARSED ; cause serveur à cerner → suivi
ROADMAP dédié. Le gate éditeur n'asserte donc pas la présence boutons.

suite **1093/1093** · ruff OK · E2E **7/7** (5 csp_preview + 2 smoke)
· docs a jour
2026-10-02 15:15:26 -04:00
bruno d7d9966edf feat: A20 phase 3 LOT 3a — 5 surfaces CSP vertes + fix bug /import (v7.40.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Ajout :
- Gate csp_preview « surfaces simples » : /welcome, /trash, /accounts,
  /workspace, /import — 0 modification necessaire sur les 4 premieres
  (scan statique 0 expression/x-html + registres Alpine.data du lot 1).
  8 surfaces couvertes au total.

Fixed (pre-existant, visible sous les DEUX builds) :
- /import : x-text "'🔗 '+report.relations…" evalue avec report=null
  (le x-show parent ne masque pas, il initialise quand meme) →
  pageerror « Cannot read property ... 'relations' » → garde
  report && report.relations.

Reste ph3 documente dans ROADMAP : page_editor (12 sites window.E dans
right_actions), gitea_workspace (new Date), agent_panel (x-text+x-html
markdown), board/table_view/teamload/card_detail (scan propre, gates lies
au contexte Gitea) → bascule reel ensuite.

suite **1093/1093** · ruff OK · eslint 0/0 · E2E **6/6** (4 csp_preview +
2 smoke) · docs a jour
2026-10-02 13:49:11 -04:00
bruno 3cab76fed5 feat: A20 phase 3 LOT 2 — settings + local workspace verts en CSP preview (v7.39.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Ajout :
- 2 gates csp_preview de plus : settings (composant lie, overlay visible)
  et local workspace (recherche focalisee via Alpine.nextTick, chips
  filtre en SVG via bindSvg, 0 erreur) → 3 surfaces vertes sous build
  CSP : library, settings, local workspace.

Changed :
- settings : window.history.back()/new Date(...) → methodes
  historyBack/fmtLastLogin/fmtAuditDate ; ?. → ternaires.
- local workspace : x-data="_wsInitData" → registre wsInitData() ;
  14 x-html → x-init + Alpine.effect (bindSvg/bindFileIcon/bindNodeIcon/
  bindChildren/bindPreview) ; $nextTick+$refs arrow → toggleSearch() ;
  window.FlowDeck.* → createPageAt/createFolderAt ; ?. → ternaires ;
  @contextmenu="_wsInitData.*" → appel de methode.

Piesges resolus (CHANGELOG en details) :
- snapshot ji du build CSP = valeurs globalThis au boot → l'objet mis sur
  window avant Alpine est banni (« Accessing global variables ») → objet
  porte par une CONST LEXICALE (non propriete globalThis) + factory
  Alpine.data → MEME objet partage, reactivite intacte.
- bloc preview hors div racine (structure pre-existante, masquee par le
  fallback window standard) → composant wsPreview DELEGUANT vers
  _wsInitData via Alpine.reactive (wrapper unique : les magics $nextTick
  ne sont redefinissables qu'une fois).
- .env local : RATE_LIMIT_REQUESTS=600 (rafales E2E vs 60/min par IP ;
  defaut produit inchange).

suite **1093/1093** · ruff OK · eslint 0/0 · E2E **5/5** (3 csp_preview + 2
smoke) · CSP preview ET standard = 0 erreur sur /local-workspace · docs
a jour
2026-10-02 13:24:46 -04:00
bruno 6ff88237fc feat: A20 phase 3 LOT 1 — shell + library migres, harnais csp_preview vert (v7.38.0)
FlowDeck CI / lint (push) Successful in 1m56s
FlowDeck CI / test (push) Successful in 15m32s
FlowDeck CI / docker (push) Successful in 1m51s
Ajout :
- e2e/csp_preview.spec.js — apercu CSP strict SANS deployer : le build
  officiel @alpinejs/csp (e2e/fixtures/alpine.csp.js, 0 eval) est servi a
  la place de alpine.min.js par interception Playwright ; toute expression
  que le parseur maison ne digere pas = pageerror (filet). Premiere
  surface VERTE : library (composant lie, icones SVG via Alpine.effect,
  recherche ouverte + focalisee, 0 erreur).

Changed :
- 16 composants x-data="fn()" enregistres via Alpine.data (registre =
  seule resolution du build CSP, probe « Undefined variable » ;
  scripts classiques executes pendant le parsing => alpine:init toujours
  joint) : appState, libraryPage, workspacesPage, editorState, board x4,
  settings/import/table_view/team_load/trash/workspace/welcome/accounts/
  card_detail.
- base.html (shell) migre : x-effect document.* -> syncSidebarClass(),
  $nextTick(arrow) -> initSidebarSort(), window.FlowDeck.* ->
  fdCreatePage/fdCreateFolder/fdGwRefresh, Object.keys/Math.min/
  window.innerWidth dans x-for et :style -> sidebarSections()/
  sectionMenuPos() — tout = simple appel de methode.
- x-html restants du shell -> x-init + Alpine.effect : icone agent,
  carte projet, library x3 ; recherche library -> toggleSearch()
  (Alpine.nextTick) ; openMoveSelected() pour Object.keys en expression.
- eslint : 70 warnings -> 0/0 (globals getCsrf depuis A38 ph1,
  /* exported openCardDetail */ + /* global owner, repo */, 3 ;; residuels).

suite **1093/1093** · ruff OK · eslint 0/0 · E2E 3/3 (csp_preview + smoke x2)
· docs a jour (ROADMAP ph3 LOT 1, CHANGELOG, WORKLOAD, VERSION)
2026-10-02 12:08:18 -04:00
bruno 840d2b2615 feat: A20 — htmx allowEval off + plan Alpine CSP phase 3 scopé par probes (v7.37.0)
FlowDeck CI / docker (push) Successful in 1m49s
FlowDeck CI / lint (push) Successful in 1m55s
FlowDeck CI / test (push) Successful in 15m4s
Changed :
- htmx `allowEval: false` dans le meta htmx-config (base.html) : plus
  d'évaluation JS côté htmx (hx-on/hx-vars/hx-vals = 0 usage grep → zéro
  régression possible) ; unsafe-eval reste UNIQUEMENT pour Alpine standard.
- Gate E20 renforcée : le smoke vérifie que `Alpine.$data()` lie un vrai
  composant [x-data] de la page (lien composant = cœur de toute bascule CSP).
- sw.js : cache bump flowdeck-v7 (purge + re-precache après Inter).

Probes (non conservés, retirés après mesure) — A20 phase 3 scopée :
- Build `@alpinejs/csp` téléchargé et TESTÉ : 72 Ko, 0 eval/new Function,
  parseur d'expressions maison, tourne sous CSP strict (meta sans
  unsafe-eval) — le lint sélectif fonctionne.
- Mais bloqué sur FlowDeck :
  (a) 13 expressions non parsables par la grammaire restreinte
      (arrows ×2, typeof ×1, new Date ×4, optional-chaining ×6 ;
       base, library, local_workspace, settings, gitea_workspace) —
      le gate E2E a attrapé la première : `CSP Parser Error: Unexpected
      token: PUNCTUATION ")"` ;
  (b) 24 `x-html` réactifs (icônes SVG + markdown agent + preview) =
      INTERDITS par le build CSP (innerHTML) → architecture d'icônes à
      reposer ;
  (c) scope des expressions CSP = données du composant uniquement
      (probe : `Undefined variable: fmtDate` / `document`) → chaque site
      devient une méthode Alpine.data enregistrée.
- Conséquence : build CSP reverté (alpine.min.js ×3 templates + sw),
  unsafe-eval maintenu, fichier alpine.csp.min.js retiré (re-téléchargeable),
  assert test CSP de nouveau `in`. Plan de migration composant par composant
  (library → settings → local_workspace → gitea → base) + gate E2E par
  surface documenté dans ROADMAP (A20 phase 3).

suite **1093/1093** · ruff OK · E2E **2/2** (dont assertion Alpine.$data)
· docs à jour (ROADMAP A20 phase 3, CHANGELOG, WORKLOAD, VERSION)
2026-10-02 11:14:47 -04:00
bruno ab6ac1e84c feat: fondations E2E + 2 bugs trouvés (onglets ?view=, Inter CSP) (v7.36.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 3m26s
Ajout — e2e/smoke.spec.js (2 gates verts contre l'instance de test) :
- gate A39 : bascule de vues d'une collection (clic onglet Calendar →
  ?view_type=calendar, grille .calendar + .cal-header rendue ; collection
  créée puis SUPPRIMÉE = répétable)
- gate A20 : palette Ctrl+K (ouverture Alpine .open, recherche GET rend
  .cmd-palette-item, fermeture Échap)
- filet console : 0 erreur JS/CSP (bruit Failed to load resource 401/403
  filtré)
- Service Workers bloqués : /sw.js sert sa page « hors ligne » sur les
  navigations redirigées (redirect:'manual') — pwa_offline.spec.js couvre
  le SW
- bootstrap autonome : login OU création du compte e2e documenté (jamais
  de mot de passe deviné), workspace si absent
- commande : cd e2e && node node_modules/@playwright/test/cli.js test smoke.spec.js

Fixed — trouvés par les gates :
1. Bascule de vues standalone JAMAIS fonctionnelle : les onglets
   émettaient ?view=… mais la route lit `view_type` (FastAPI) → l'onglet
   restait sur Table quel que soit le clic (bug pré-existant, A28 n'y est
   pour rien). Onglets → ?view_type= ; test_all_view_tabs_present adapté +
   assertion comportementale (GET ?view_type=calendar rend .calendar).
2. Inter bloqué par la CSP depuis v7.27 : app.css importait encore
   Google Fonts (@import raté par le grep de la passe v7.27) → violation
   style-src sur chaque page + police en fallback. Inter auto-hébergé :
   2 faces variables (100-900, latin + latin-ext) dans static/fonts/,
   @import supprimé (8 fichiers dupliqués dédupliqués → 2).

suite **1093/1093** · ruff OK · E2E **2/2** · docs à jour
2026-10-02 10:23:34 -04:00
bruno 3a74ea8bbd fix: A35 TERMINÉ — drift Python 3.12→3.13 aligné, rebuild validé (v7.35.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Dernier reliquat de l'A35 (docs/périmètre dérivés) :

- Dockerfile : python:3.12-slim → python:3.13-slim (builder + runtime)
- .gitea/workflows/ci.yml : python-version '3.12' → '3.13' (lint + test)
- README.md : « Python 3.12 » et « python:3.12-slim » → 3.13 (×2)
- pyproject.toml : ruff target-version py312 → py313 (0 nouvelle
  remarque ruff)
- zéro référence 3.12 résiduelle ; uv.lock (requires-python >=3.13) et
  le venv (3.13.14) étaient déjà bons

Validation (le point laissé « à faire par un rebuild d'image ») :
- docker build VERT sur python:3.13-slim → image flowdeck:a35-py313
- dans le conteneur : python -V = 3.13.16, `import app.main` OK
  (v7.35.0) → wheels requirements.txt construits + importables sur 3.13

A35 = TERMINÉ (OpenAPI/README/titre dupliqué faits en 7.3.9 + drift).

suite **1093/1093** · ruff OK (target py313) · docs à jour
2026-10-02 09:39:02 -04:00
bruno 13dc8fdaad fix: A38 phase 2 — 0 doublon de fonction globale + garde-fou (v7.34.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- Inventaire exhaustif des 13 noms `function NAME` définis 2+ fois
  (templates + static/js) avec scan de profondeur de brace (strings,
  comments, backticks gérés) : 12 sont déjà scopés dans des IIFEs
  depuis A27 (escHtml/flush/emit/setMeta/initials/up/esc/show/close…) —
  aucun conflit de page possible.
- Seul doublon GLOBALE = openCardDetail (corps byte-identiques ×2 dans
  board_fragment + detailed_board, fragments de vues mutuellement
  exclusifs) → dédupliquée vers static/js/app.js, 2 copies supprimées ;
  les onclick/@click des deux fragments appellent la même définition
  (owner/repo globaux fournis par board.js au moment du clic).
- test_no_duplicate_global_functions : garde-fou 0-doublon entre
  templates et static/js (scanner naïf, plafond ponytail commenté).

Reste A38 : méthodes jumelles library/local_workspace (~9-21 noms
communs, corps divergents) → fusion workspace-tree.js reportée
(réconciliation sans E2E, même logique que A39/A20).

suite **1093/1093** · ruff OK · node --check vert · docs à jour
2026-10-02 09:18:04 -04:00
bruno 770fdc2b68 fix: A43 TERMINÉ + A38 phase 1 — CSRF rendu côté serveur, helper unique (v7.33.0)
FlowDeck CI / lint (push) Successful in 1m57s
FlowDeck CI / test (push) Successful in 15m20s
FlowDeck CI / docker (push) Canceled after 0s
A43 (les 4 items sont clos) :
- `__CSRF_PLACEHOLDER__` supprimé : ContextVar CSRF_TOKEN posée par
  CSRFMiddleware avant call_next (même mécanique que le nonce CSP),
  global `{{ csrf_token() }}` dans templating, base.html rend
  `{"X-CSRF-Token":{{ csrf_token()|tojson }}}` — vide si cookie absent,
  `htmx:configRequest` re-lit le cookie à chaque appel → plus jamais de
  jeton factice servi.
- Palette : `fetch('/api/search…')` SANS header (GET ∈ SAFE_METHODS →
  CSRF inapplicable) — le JSON.parse du body-attr par frappe disparaît.
- utcnow déprécié = 0 dans app/**.py ; health loggé (A25) + booléens db/
  gitea = raison ; probe réseau = voulu (test de connectivité).

A38 phase 1 (CSRF unifié) :
- `window.getCsrf()` unique dans le <head> de base.html.
- 76 lectures brutes du cookie → getCsrf() dans 13 fichiers (47 formes
  `(…||[])[1]||''`, 25 déclarations `const X = match(…)` avec conversion
  de leurs usages `X?X[1]:''` → `X` (noms collectés PAR FICHIER, jamais
  de règle globale), 4 formes espacées).
- Définitions dupliquées supprimées : card_detail.html (multi-lignes),
  database_table.js (1 ligne dans IIFE → chute sur le global).
- Les 3 variantes de base.html (IIFE getCsrf + 2 getCsrfToken) →
  `return getCsrf()`.
- welcome.html garde sa lecture locale (page autonome sans base) ;
  reste exactement 1 raw dans base.html (la déf head) + 2 dans welcome.

Test : test_csrf_server_rendered_no_placeholder (pas de placeholder +
token du hx-headers == cookie csrf_token).

suite **1092/1092** · ruff OK · node --check vert · docs à jour
2026-10-02 08:45:27 -04:00
69 changed files with 2908 additions and 316 deletions
+2 -2
View File
@@ -13,7 +13,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
python-version: '3.13'
- name: Install lint tools
run: pip install -r requirements-dev.txt
- name: Ruff (Python)
@@ -31,7 +31,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
python-version: '3.13'
- name: Install system dependencies (WeasyPrint / emoji fonts)
run: |-
SUDO=""
+536
View File
@@ -1,5 +1,541 @@
# Changelog - FlowDeck
## v7.45.4 (2026-10-03) — Anti-FOUC au chargement complet + morts nettoyés
### Fixed
- **Flashs au chargement COMPLET** (F5, première visite, navigation servie par
le service worker) : le masquage `x-cloak`/`opacity` de v7.45.3 ne couvrait
que les swaps partiels fdLoad — un chargement de document peignait la page
tant qu'Alpine n'avait pas initialisé `.app-layout`, donc sidebar brute (toutes
sections ouvertes, menu utilisateur visible) **et** contenu de la zone dont la
bande rouge « You are offline. Changes will sync when connection is
restored. » (aucun `x-cloak` sur elle). Fix : `x-cloak` sur le nœud racine
Alpine `.app-layout` (base.html) — tout l'app (sidebar + zone + bandeau) reste
masqué tant que `appState()` n'est pas monté, puis Alpine retire l'attribut.
Vérifié par le probe (3 pages × plein chargement) : 0 frame de contenu brut,
`x-cloak` retiré et `display:flex` à la fin (donc pas de page blanche).
### Changed
- **`fd-navigating` supprimée** : classe posée/retirée à chaque `fdLoad` mais
sans aucune règle CSS dans le dépôt (grep css/js/html → seulement app.js) —
code mort.
- **`e2e/probe_nav_perf.spec.js` étendu à 4 scénarios avec assertions** :
navigations partielles (5 pages), **clic réel sur le bouton Home**, Home avec
réseau coupé (SW actif), et plein chargement. Asserte : 0 frame de contenu
brut peint, zone révélée (`opacity:1`) et montée (`0 [x-show]` non montés,
`x-ignore` absent), `.app-layout` sans `x-cloak` et visible. Capture
screenshot automatique si du contenu brut est peint + collecte console.
### Not reproduced
- Le symptôme rapporté (Home → rafale de menus/fenêtres + bande rouge) n'est
**pas rejouable** dans un navigateur frais : clic Home en ligne comme hors
ligne, nav partielle et plein chargement donnent 0 frame de contenu brut et 0
erreur console. Reste à confirmer côté navigateur signalé (recharge dure
après deploy, URL/instance testée).
## v7.45.3 (2026-10-03) — Navigation partielle : le contenu brut n'est plus peint
### Fixed
- **Flashs de fenêtres / frames à chaque changement de page ou de section** :
pendant la fenêtre `x-ignore` (swap → `Alpine.initTree`), la zone
`.main-wrapper` était peinte **non montée** — tous ses `[x-show]` visibles à
leur valeur brute (menus, panneaux, états loading/empty/table superposés), puis
masqués d'un coup au montage Alpine. Mesure (`e2e/probe_nav_perf.spec.js`,
instance locale) : **19 éléments bruts visibles sur 87 ms** en arrivant sur
/library (CLS 0.149, sources `lib-loading`/`lib-empty`/`lib-table`), **80
éléments sur 732 ms** sur /settings, 7 sur /workspaces, 0 sur /trash. Fix :
`opacity: 0` + `pointer-events: none` posés **dans** le handler
`htmx:afterSwap` (même task que le swap → aucun paint intermédiaire possible)
et retirés **après** `Alpine.initTree` sur les 3 chemins de démontage (scripts
chargés, zéro script, filet 4 s). Seul le contenu monté est peint.
### Added
- **`e2e/probe_nav_perf.spec.js`** — probe de fluidité de navigation : marques
htmx (`beforeRequest`/`beforeSwap`/`afterSwap`/`afterSettle`), fenêtre
`x-ignore`, échantillon par frame du nombre d'`[x-show]` bruts encore
visibles et d'`[x-cloak]` masqués, `layout-shift` (CLS) avec sources.
`node node_modules/@playwright/test/cli.js test probe_nav_perf` (npx est cassé
sur ce poste).
## v7.45.2 (2026-10-03) — Navigation partielle : montage Alpine sans course + scripts idempotents
### Fixed
- **Cascades d'erreurs Alpine en navigation partielle** (« Undefined
variable: … » puis « reading 'has' » sur `$store.fdCtx`) : sur un swap
fdLoad, les `<script src>` de page s'exécutent APRÈS le microtask
MutationObserver d'Alpine → toute la zone `.main-wrapper` s'initialisait
avant que composants ET stores (menu contextuel) existent, et le
démontage anticipé dès le premier script chargé perdait la course avec
les autres. Fix : `x-ignore` sur **toute** la zone swapée au
`htmx:afterSwap` (uniquement quand le nœud `.main-wrapper` a été
remplacé — swaps sidebar/vues inchangés), démontage **unique** quand
**tous** les `<script src>` ont exécuté (load/error + filet 4 s) via
`Alpine.initTree` (idempotent grâce au `_x_marker`). Vérifié : 7 pages ×
(complet / 1ʳᵉ / 2ᵉ visite partielle) + éditeur (3 phases) = 0 erreur.
- **`SyntaxError: Identifier 'LW' has already been declared`** : tout
script de page ré-exécuté à la 2ᵉ visite partielle plantait entièrement
(`const` de haut niveau re-déclaré) → panneaux Alpine non montés.
Garde de fichier posé sur `local_workspace.js`, `board.js`,
`settings.js`, `database_table.js`, `page_editor_realtime.js` (pattern
déjà utilisé par `page_editor_scripts.js`).
- **Enregistrement Alpine différé** : `if (window.Alpine) Alpine.data(…
sinon listener 'alpine:init'` (l'événement ne sera plus jamais émis sur
swap) dans 8 scripts de page et 7 templates inline (`accounts`,
`card_detail`, `table_view`, `team_load`, `trash`, `welcome`,
`workspace`).
- **Polices Inter orphelines** : `app.css` référençait 7 fichiers
inexistants (`inter-400-latin.woff2`…) → 302 → HTML → « Failed to
decode downloaded font » sur chaque page. Blocs legacy supprimés (les
faces variable Inter 100-900 restent).
### Added
- **Gates E2E** : `e2e/regression_editor_mount.spec.js` (éditeur en 3
phases : partielle / complet / 2ᵉ partielle) et
`e2e/regression_partial_nav.spec.js` (7 pages × 3 passages + état
fdCtx/appState) —0 erreur Alpine attendue.
## v7.45.1 (2026-10-02) — Fix logout « hors ligne » (SW) + drag & drop upload 403
### Fixed
- **Logout → page « Vous êtes hors ligne »** : le fetch event de navigation
arrive en `redirect:'manual'` → une 302 du serveur se lisait
`opaqueredirect` (status 0) → `bad status` → filet offline. Tout logout
(et toute navigation redirigée) affichait la page hors ligne alors que le
backend répondait en ~10 ms. `networkFirst` rejoue désormais la requête en
`redirect:'follow'` et sert une **302 synthétique vers l'URL finale**
(Chromium refuse une response `redirected` servie à une navigation →
`net::ERR_FAILED`). Bonus : `timeoutFetch` annule réellement la requête
(l'`AbortController` créée dans le `setTimeout` n'était pas branchée sur
`fetch`).
- **Drag & drop de fichiers/dossiers en échec silencieux (403 CSRF)** :
`_doUpload()` (POST `/api/local-workspace/upload[-folder]`) et
`toggleFavorite()` du local workspace n'envoyaient pas `X-CSRF-Token` —
les 2 derniers appels mutants du front oubliés par A19 (exemption CSRF
retirée, balayage complet des `fetch` mutants refait).
- **Gate E2E** : `e2e/regression_logout_dnd.spec.js` enregistre les 2 bugs
(logout sous SW → page login, drop de fichier → 200 + création, avec
nettoyage).
## v7.45.0 (2026-10-01) — Éditeur visuel d'automations + correction CSP (multi-instructions)
### Added
- **Pipeline visuel (steps) dans Settings → Automations** : cartes
ordonnées par étape avec badge + résumé (`📡 Déclencheur · event`,
`⚖ Condition · prop op val`, `⏳ Attente · Xs`, `⚡ Action · type → …`),
**éditeur typé par kind/type** (événements en datalist, 7 ops de
condition, 8 types d'action avec leurs champs réels : url, property/value,
collection/title, message, webhook_url/text, to/subject/body,
owner/repo/labels, agent_id/message), ajout/édition/suppression/**↑↓** via
`POST/PUT/DELETE /workspace/automations[/steps]/…`, conversion legacy
**✨ Convertir le JSON en pipeline** (trigger + conditions + actions
ordonnés) ; les textareas JSON disparaissent dès qu'un step existe.
- **Gate E2E** « éditeur visuel de steps » : création → édition → ajout
d'étape → carte `Action · webhook` visible (sous CSP réel).
### Fixed (trouvé par le gate)
- **51 expressions Alpine multi-instructions** (`activeSection='x';
loadX()` etc.) = **interdites par le parseur CSP** (une seule expression
par directive ; `;` = token inattendu) — invisible pour le scan par
tokens ! Conversion en **méthodes** : settings nav ×8 (`navTo`), menu de
section base ×7 (`closeAndSetCount/…`), parts/éditeur ×13
(`setSharePerm`, `more*`, `markAndSave`…), breadcrumb ×5
(`hoverEllipsis/goClose`), library/local ×6 (menus), board ×3
(`pickStatus/…`), ctx-menu ×2 (`addTagAndClear`), agent/card/gitea/
workspaces ×6. Scanner dédié `scan_semi` (inventaire `;` hors chaînes).
### Notes
- 39 templates Jinja parse OK, scan d'expressions = 0 incompatibilité
(4 faux positifs en chaînes), E2E **8/8**, suite **1094/1094**.
## v7.44.0 (2026-10-01) — Palette Ctrl+K : onglets Pages / ✨ Réponses IA
### Added
- **Palette `Ctrl+K` = 2 onglets** (markup + CSS + wiring dans l'IIFE) :
· **Pages** — comportement inchangé (recherche `/api/search` + actions).
· **✨ Réponses IA** — `POST /api/v2/search/ask` (debounce 150 ms,
CSRF via `getCsrf()`, gardes staleness onglet+requête) → rendu de
`answer_markdown` : **échappement AVANT injection**, `[[fdpage:ID]]` →
lien citation (ids = chiffres, type = `[a-z]+` — pas d'injection),
`**gras**`, bloc « Sources » avec liens `/pages/{id}` · `/db/{id}`.
États : hint / chargement / erreur.
- **Gate E2E étendu** (`gate A20 palette`) : ouverture → clic onglet IA →
réponse non-échaffée affichée (le backend tourne en extractif ou LLM —
probe : 200 en 1,6 s, 8 citations).
- **Reporté (backend absent)** : onglet `Fichiers` — `/api/search` ne
renvoie que `pages`/`collections` → endpoint à créer d'abord (ROADMAP).
### Notes
- Un 401 transitoire sur le 1er ask d'un run E2E a été observé (session
fraîche) — non reproductible ensuite ; à surveiller si ça revient.
## v7.43.0 (2026-10-01) — 🎉 A20 TERMINÉ : Alpine en build CSP, `unsafe-eval` retiré
### Changed (la bascule A20 phase 3)
- **`static/js/alpine.csp.min.js`** (build officiel `@alpinejs/csp`,
0 `eval`/`new Function`, parseur d'expressions maison) servi partout :
`base.html`, `import.html`, `welcome.html` + entrée `sw.js` (cache bump
v8).
- **CSP** : `script-src 'self' 'nonce-…'` — **`unsafe-eval` supprimé** (il
ne servait plus qu'Alpine standard). htmx : `allowEval: false` déjà posé
(v7.37).
- Assertion test inversée : `assert "'unsafe-eval'" not in script_src`.
- Scan statique final sur **tous** les templates : 0 expression incompatible
(4 résidus = faux positifs dans des chaînes de texte).
### Notes
- 12 surfaces migrées + gateées en amont (lots 1-3a/3b) ; **E2E 7/7 sous
CSP réel** (le route-swap du `csp_preview` sert désormais le même
fichier — les gates restent utiles contre une régression du build).
- board/table_view/teamload/card_detail : scan propre mais non gateés
(gitea down) → à vérifier au premier usage avec gitea remonté.
- Risque résiduel assumé : expressions n'ayant jamais tourné en runtime
sur ces 4 surfaces (les gates + le filet 0-erreur les attraperont).
## v7.42.0 (2026-10-01) — 🐛 BUG TOPBAR CORRIGÉ (boutons du header en texte brut)
### Fixed
- **Les `right_actions` du topbar étaient servis ÉCHAPPÉS sur TOUTES les
pages** (entities `&#34;`/`&lt;` → boutons Share/Star/Settings/Login en
texte brut). **Cause racine** : `{% set right_actions = '…' ~ fd_icon(…) ~ '…' %}`
— `fd_icon` est une **macro → `Markup`**, et `Markup.__radd__/__add__`
**échappe ses arguments `str`** → tous les segments littéraux sortent
entité-és ; le `|safe` de `_header:141` est then no-op sur un Markup déjà
échappé. Découvert en cherchant l'échec du gate éditeur CSP (v7.41.0),
reproductible partout (`curl /workspaces`).
- **Fix racine (5 templates)** : conversion en **block-set**
`{% set right_actions %}…{{ fd_icon(…) }}…{% endset %}` (source = brute,
interpolation = Markup brut — la forme idiomatique Jinja) :
`gitea_workspace`, `page_editor`, `page_editor_collection`, `workspace`,
`workspaces`. Piège du script : regex greedy multi-lignes = set avalé →
matcher **une ligne**.
- **Test permanent** `tests/test_topbar_right_actions.py` : `/workspaces`
doit contenir `class="topbar-btn"` parsé et ZÉRIE entité `&#34;`.
- Gate éditeur : l'assertion `.star-btn` **ré-ajoutée** (les boutons
rendent à nouveau).
### Notes
- Probable régression depuis A10 (activation d'autoescape) : les `~`
étaient des no-op avant, Markup.__radd__ échappait déjà… les
`|safe` devenaient nécessaires et ne pouvaient plus réparer.
## v7.41.0 (2026-10-01) — A20 phase 3 LOT 3b : gitea + agent + éditeur verts
### Changed
- **gitea_workspace** : `x-data="giteaWorkspace"` → appel `giteaWorkspace()`,
`new Date(…)` → `fmtGwDate(pp)`, x-html icône d'arbre → `bindGwIcon`
(x-init + `Alpine.effect`).
- **agent_panel** : x-html markdown → `bindMarkdown($el, m)` (effet réactif
sur `m.content`).
- **page_editor (les 12 sites `window.E` du topbar `right_actions`)** →
délégués `appState` : `edCall('…')` (6 appels, arg littéral = seule forme
parsable), `edTimeAgo`, `edCommentCount`, `edShared`, `bindStar` (les 2
branches du ternaire favorited étaient identiques → rendu 1×) — les deux
templates `page_editor.html` + `page_editor_collection.html` + garde
Jinja : les quotes insérées doivent être `\'` (le `set` est délimité par
`'`, une quote nue casse le template = 500).
- **_page_editor_content** : +3 sites (`window.E.commentOnSelection` →
`edCall`, `backlinksOpen…location.href` → `openBacklink(b)`,
`Math.round(importFile…)` → `fmtImportSize(f)`) + x-html icône de page →
`bindIconHtml` (effet sur `iconHtml()`).
- **Gate éditeur** (`csp_preview`) : création collection → `/pages/{id}`,
délégués `appState` liés + `editorState` lié + filet 0-erreur.
### Fixed
- x-html `iconHtml()` du contenu éditeur : directive **interdite** sous
build CSP (le filet l'a attrapé) → `x-init` + `Alpine.effect`.
### ⚠️ Nouveau bug pré-existant identifié (PAS introdui par ce lot)
- **Les `right_actions` du topbar sont servi ÉCHAPPÉS sur TOUTES les
pages** (entities `&#34;`/`&lt;` — les boutons Share/Star/Settings/…
s'affichent en texte brut). Reproductible : `curl /workspaces` →
`&#34;topbar-btn&#34;`. Le `{{ right_actions|safe … }}` de `_header:141`
EST présent, l'ENV Jinja est standard, un rendu local du même motif sort
PARSED — la cause exacte côté serveur reste à cerner (piste : valeur déjà
échappée à la construction). Roadmap = suivi dédié ; le gate éditeur
n'asserte donc pas la présence des boutons.
## v7.40.0 (2026-10-01) — A20 phase 3 LOT 3a : 5 surfaces de plus vertes
### Added
- **Gate `csp_preview` « surfaces simples »** : `/welcome`, `/trash`,
`/accounts`, `/workspace`, `/import` — **0 modification de code
nécessaire** sur les 4 premières (scan statique 0 expression/x-html +
registres Alpine.data posés au lot 1). **8 surfaces couvertes** au total
(base shell, library, settings, local workspace + celles-ci).
### Fixed
- **Bug pré-existant sur `/import` (visible sous les DEUX builds)** :
`x-text="'🔗 '+report.relations…"` évalué alors que `report = null`
(le `x-show` parent ne masque pas, il initialise quand même) → pageerror
« Cannot read property … 'relations' » — garde `report && report.relations`.
(Le probe montrait aussi un 401 console pré-existant sur la page — hors
périmètre, non touché.)
### Notes
- Reste ph3 : page_editor (12 sites `window.E` dans `right_actions`),
gitea_workspace (`new Date`), agent_panel (site `x-text` markdown +
1 x-html), board + table_view/teamload/card_detail (gabarits liés au
contexte Gitea, scan statique propre) → bascule réelle ensuite.
## v7.39.0 (2026-10-01) — A20 phase 3 LOT 2 : settings + local workspace verts
### Added
- **2 gates `csp_preview` de plus** : `settings` (composant lié, overlay
visible, 0 erreur) et `local workspace` (recherche focalisée via
`Alpine.nextTick`, chips filtre en SVG via `bindSvg`, 0 erreur).
**3 surfaces vertes** sous build CSP : library, settings, local workspace.
### Changed
- **settings** : `window.history.back()`/`new Date(…)` → méthodes
`historyBack`/`fmtLastLogin`/`fmtAuditDate` ; `?.` → ternaires.
- **local workspace** : `x-data="_wsInitData"` → registre `wsInitData()`
(le build CSP ne résout que le registre) ; 14 `x-html` → `x-init` +
`Alpine.effect` (`bindSvg`/`bindFileIcon`/`bindNodeIcon`/`bindChildren`/
`bindPreview`) ; `$nextTick`+`$refs` arrow → `toggleSearch()` ;
`window.FlowDeck.*` → `createPageAt`/`createFolderAt` ; `?.` → ternaires ;
`@contextmenu="_wsInitData.…"` → appel de méthode.
- **Partage d'état JS↔Alpine (piège du build CSP)** : `ji` = snapshot des
**valeurs** de toutes les propriétés `globalThis` au boot → l'objet mis
sur `window` avant Alpine est **banni** (« Accessing global variables is
prohibited »). Fix : objet porté par une **const lexicale** (non propriété
`globalThis`) + factory Alpine.data qui le retourne → **même objet**
partagé, réactivité intacte (une copie `Object.assign` aurait coupé les
mises à jour JS : preview, uploads, isDragging).
- **Bloc preview hors div racine** (structure pré-existante : le parseur
referme la racine avant, masquée par le fallback window d'Alpine
standard) → composant `wsPreview` **déléguant** vers `_wsInitData`
(`Alpine.reactive` pour la réactivité ; wrapper = objet unique, les
magics `$nextTick` ne sont redéfinissables qu'une fois — deuxième
montage du même objet = « Cannot redefine property »).
- **`.env` local : `RATE_LIMIT_REQUESTS=600`** — les rafales E2E
Playwright (5 tests × ~25 requêtes) butaient sur le 60/min par IP ;
défaut produit inchangé.
### Notes
- Reste ph3 : page_editor, board, agent_panel, import, gitea_workspace,
welcome/accounts/trash/team_load/workspace/table_view/card_detail →
puis bascule réelle (retrait `unsafe-eval`).
## v7.38.0 (2026-10-01) — A20 phase 3 LOT 1 : shell + library migres
### Added
- **`e2e/csp_preview.spec.js`** — aperçu CSP strict SANS déployer : le
build `@alpinejs/csp` (fichier officiel, `e2e/fixtures/alpine.csp.js`)
est servi **à la place** de `alpine.min.js` par interception Playwright ;
tout échec du parseur maison = `pageerror` (filet). **Première surface
verte : library** (composant lié, icônes SVG rendues via `Alpine.effect`,
recherche ouverte + focalisée, 0 erreur console/page).
### Changed
- **Composants `x-data="fn()"` → registre `Alpine.data(...)`** (15 +
`appState` + `libraryPage`) : le build CSP ne résout que le registre
(probe : globale window → `Undefined variable`) — scripts/classiques
chargés pendant le parsing = `alpine:init` toujours joint à temps.
- **base.html (shell) migré** : `x-effect document.*` → `syncSidebarClass()`,
`$nextTick(arrow)` → `initSidebarSort()`, `window.FlowDeck.*` →
`fdCreatePage/fdCreateFolder/fdGwRefresh`, `Object.keys`/`Math.min`/
`window.innerWidth` dans `x-for`/`:style` → `sidebarSections()`/
`sectionMenuPos()` — toutes les formes = simple appel de méthode.
- **x-html restants du shell** → `x-init` + `Alpine.effect` :
icône agent (`bindAgentIcon`), carte projet (`bindProjectIcon`),
library ×3 (`bindHtmlIcon`/`bindHtmlItem`), recherche library
(`toggleSearch` avec `Alpine.nextTick`), `openMoveSelected` (library).
- **eslint : 70 warnings → 0/0** : `getCsrf` (helper A38 ph1) déclaré dans
les globals du config, `/* exported openCardDetail */` +
`/* global owner, repo */` (app.js), 3 `;;` résiduels de la conversion
A38 supprimés.
### Notes
- Portes A20 ph3 : surfaces restantes = settings, local_workspace,
gitea_workspace, page_editor, board (partiels), agent_panel, import,
welcome, accounts, trash, team_load, workspace, table_view, card_detail ;
**bascule réelle** (retrait `unsafe-eval`) = quand csp_preview est vert
sur toutes les pages principales.
## v7.37.0 (2026-10-01) — A20 : htmx allowEval off + plan Alpine CSP (phase 3)
### Changed
- **htmx `allowEval: false`** dans le `<meta name="htmx-config">` : htmx
ne peut plus évaluer de JS (`hx-on`/`hx-vars`/`hx-vals`) — grep = **0
usage** dans les templates, donc zéro régression possible. `unsafe-eval`
reste **uniquement** pour Alpine standard.
- **Gate E20 renforcé** : le smoke vérifie désormais que `Alpine.$data()`
lie un vrai composant `[x-data]` de la page (le lien composant = le cœur
de tout basculement CSP).
### Notes — A20 phase 3 (unsafe-eval, scopé par probes)
Le build `@alpinejs/csp` a été **testé empiriquement** (fichier 72 Ko,
**0 `eval`/`new Function`**, parseur d'expressions maison) : il tourne sous
CSP strict, mais **bloqué sur FlowDeck** par deux familles d'usages :
- **13 expressions non parsables** par la grammaire restreinte :
arrows (`$nextTick(() => …)` ×2), `typeof` ×1, `new Date(…)` ×4,
optional-chaining `?.` ×6 (base, library, local_workspace, settings,
gitea_workspace) ;
- **24 `x-html` réactifs** (icônes SVG `getSvgIcon`/`_fileIcon`,
markdown agent, preview) — **interdits par le build CSP** (innerHTML) :
nécessitent de reposer les icônes sur des composants `Alpine.data`.
- Portée : 0 variable globale/`document` accessible dans les expressions
du build CSP (scope = données du composant + magics) → chaque site
devient une méthode de composant enregistrée via `Alpine.data`.
**Plan phase 3** : migrer composant par composant (library → settings →
local_workspace → gitea → base) avec gate E2E dédiée, puis retirer
`unsafe-eval`. En attendant : `unsafe-eval` conservé (Alpine standard).
## v7.36.0 (2026-10-01) — Fondations E2E + 2 bugs trouvés au passage
### Added
- **`e2e/smoke.spec.js`** — 2 gates vert contre l'instance de test :
· **gate A39** : bascule de vues d'une collection (clic onglet Calendar →
`?view_type=calendar`, grille `.calendar` + `.cal-header` rendue,
collection créée puis **supprimée** = répétable)
· **gate A20** : palette Ctrl+K (ouverture Alpine `.open`, recherche GET
rend `.cmd-palette-item`, fermeture Échap)
· **filet console** : 0 erreur JS/CSP (les violations atterrissent ici ;
le bruit `Failed to load resource` 401/403 est filtré)
· **Service Workers bloqués** dans le smoke : `/sw.js` sert sa page
« hors ligne » sur les navigations redirigées (`redirect:'manual'`) —
bruit PWA hors sujet, `pwa_offline.spec.js` couvre le SW
· bootstrap autonome : login OU création du compte e2e documenté,
workspace si absent — lecture seule sur les données existantes
- Commande : `cd e2e && node node_modules/@playwright/test/cli.js test smoke.spec.js`
### Fixed (trouvés par les gates)
- **Bascule de vues standalone jamais fonctionnelle** : les onglets
émettaient `?view=…` mais la route lit `view_type` (FastAPI) → l'onglet
restait sur Table quel que soit le clic (bug pré-existant, non introduit
par A28). Onglets → `?view_type=` + assertion comportementale ajoutée à
`test_all_view_tabs_present`
- **Inter bloqué par la CSP depuis v7.27** : `app.css` importait encore
Google Fonts (`@import` raté par le grep de v7.27) → violation
`style-src` sur chaque page + police tombée en fallback. Inter
**auto-hébergé** : 2 faces variables (100-900, latin + latin-ext) dans
`static/fonts/`, `@import` supprimé
### Notes
- Suite complète : **1093/1093** · ruff OK · E2E **2/2**
- Portes : A20 (unsafe-eval) attaquable avec ce filet ; A39 couvre la
bascule collection (pas la bascule htmx board → décision « rien »
maintenue) ; A38 twins reste conditionné à une couverture élargie
## v7.35.0 (2026-10-01) — Audit : A35 TERMINÉ (Python 3.13 aligné + rebuild)
### Changed
- **Drift Python résolu** (dernier reliquat A35) : tout le projet est en
**3.13** — `Dockerfile` (`python:3.13-slim` builder + runtime), CI Gitea
(`python-version: '3.13'` ×2), `README.md` ×2, ruff
`target-version = "py313"` (0 nouvelle remarque ruff). Restait 3.12 aux
6 endroits alors que `uv.lock` = `requires-python >=3.13` et le venv =
3.13.14. Zéro référence 3.12 résiduelle.
### Validation (le point que l'audit laissait « à faire par rebuild »)
- `docker build` **vert** sur `python:3.13-slim` (image `flowdeck:a35-py313`)
- dans le conteneur : `python -V` = **3.13.16**, `import app.main` OK
(version 7.35.0) → wheels `requirements.txt` construits et importables
sur 3.13
- Suite locale complète : **1093/1093** · ruff OK (target py313)
### Notes
- A35 = **TERMINÉ** (OpenAPI/README/titre faits en 7.3.9 + drift aujourd'hui)
## v7.34.0 (2026-10-01) — Audit : A38 phase 2 (0 doublon de fonction globale)
### Changed
- **Inventaire exhaustif de la duplication de fonctions** : scan de
profondeur de brace sur les 13 noms définis 2+ fois (templates +
static/js, strings/comments/backticks gérés) → **12 sont déjà scopés**
dans des IIFEs depuis A27 (aucun conflit de page possible)
- **Seul doublon global = `openCardDetail`** (corps byte-identiques ×2 dans
`board_fragment` + `detailed_board`, fragments de vues mutuellement
exclusifs) → dédupliquée vers `static/js/app.js`, les 2 copies
supprimées (les onclick/@click des deux fragments appellent la même
définition)
### Tests
- `test_no_duplicate_global_functions` : garde-fou — 0 `function NAME`
globale définie 2+ fois entre templates et static/js (scanner naïf,
plafond `ponytail:` commenté : un faux positif se lit au nom signalé)
- Suite complète : **1092/1092** · ruff OK · node --check vert
### Notes
- Reste A38 : méthodes jumelles library/local_workspace (~9-21 noms
communs, corps divergents) → fusion `workspace-tree.js` reportée
(réconciliation sans E2E — même logique que A39/A20)
## v7.33.0 (2026-10-01) — Audit : A43 TERMINÉ + A38 phase 1 (CSRF unifié)
### Fixed
- **A43-1 — plus de `__CSRF_PLACEHOLDER__` servi** : ContextVar
`CSRF_TOKEN` posée par `CSRFMiddleware` **avant** `call_next` (même
mécanique que le nonce CSP), global `{{ csrf_token() }}` ajouté à
templating ; `base.html` rend `{"X-CSRF-Token":{{ csrf_token()|tojson }}}`
— vide si le cookie est absent sur cette 1ʳᵉ requête, mais
`htmx:configRequest` re-lit le cookie à chaque appel → jamais de jeton
factice. Test `test_csrf_server_rendered_no_placeholder` (pas de
placeholder + token == cookie)
- **A43-2 — palette : plus de re-parse par frappe** : `fetch('/api/search…')`
sans header (GET ∈ `SAFE_METHODS` → le CSRF ne s'applique pas) — le
`JSON.parse(document.body.getAttribute('hx-headers'))` par frappe disparaît
### Changed
- **A38 phase 1 — helper CSRF unique** : `window.getCsrf()` défini dans le
`<head>` de `base.html` (le plus tôt possible) ; **76 lectures brutes du
cookie → `getCsrf()`** dans 13 fichiers (47 formes `(…||[])[1]||''`,
25 déclarations `const X = match(…)` + leurs usages `X?X[1]:''` → `X`,
4 formes espacées) ; définitions dupliquées supprimées (`card_detail`,
`database_table`) ; les 3 variantes de `base.html` (IIFE + 2
`getCsrfToken`) unifiées sur `return getCsrf()` ; `welcome.html` garde sa
lecture locale (page autonome documentée)
### Notes
- A43 = **TERMINÉ** (4/4 : utcnow déprécié = 0 dans app/**.py, health
loggé, placeholder, palette)
- A38 reste : 12 fonctions dupliquées entre templates (wrappeur) + 21
méthodes jumelles library/local_workspace
- Suite complète : **1092/1092** · ruff OK · node --check vert
## v7.32.0 (2026-10-01) — Audit : A28 TERMINÉ (board, dernier lot)
### Changed
+2 -2
View File
@@ -3,7 +3,7 @@
# Stage 1 "builder": build Python wheels once.
# Stage 2 "runtime": minimal image with WeasyPrint system libs.
# ═══════════════════════════════════════════════════════════
FROM python:3.12-slim AS builder
FROM python:3.13-slim AS builder
WORKDIR /app
@@ -11,7 +11,7 @@ COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
# ── runtime stage ───────────────────────────────────────────
FROM python:3.12-slim AS runtime
FROM python:3.13-slim AS runtime
WORKDIR /app
+2 -2
View File
@@ -73,9 +73,9 @@ docker compose up -d
| Couche | Techno |
|--------|--------|
| Frontend | Jinja2 + HTMX + Alpine.js + SortableJS + CSS |
| Backend | Python 3.12 + FastAPI + httpx |
| Backend | Python 3.13 + FastAPI + httpx |
| BDD | SQLite (WAL, 21 tables) — `/data/flowdeck.db` |
| Déploiement | Docker (python:3.12-slim), docker-compose |
| Déploiement | Docker (python:3.13-slim), docker-compose |
## Configuration
+34 -7
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
7.32.0
7.45.4
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.32.0 (audit — A28 TERMINÉ : les 4 god files découpés, board lot 4) | **Statut**: EN COURS 🔄
> **Début**: 2026-07-08 | **Version**: v7.45.4 (anti-FOUC : `x-cloak` sur `.app-layout` = plus de sidebar brute ni de bande rouge hors ligne au chargement complet, classe morte `fd-navigating` supprimée, probe étendu à 4 scénarios avec assertions) | **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
+1 -1
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.32.0",
version="7.45.4",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+5
View File
@@ -7,6 +7,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
from app.templating import CSRF_TOKEN
class CSRFMiddleware(BaseHTTPMiddleware):
"""Lightweight CSRF protection for state-changing requests.
@@ -35,6 +37,9 @@ class CSRFMiddleware(BaseHTTPMiddleware):
}
async def dispatch(self, request: Request, call_next):
# A43 : jeton expose aux templates (base.html hx-headers) — posé AVANT
# call_next, comme le nonce CSP (meme mecanisme ContextVar).
CSRF_TOKEN.set(request.cookies.get("csrf_token", ""))
# Webhook receiver, OAuth callback, and internal API are exempt
if any(request.url.path.startswith(p) for p in self.EXCLUDED_PATHS):
return await call_next(request)
+5 -5
View File
@@ -71,15 +71,15 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
# requête — un XSS injecté dans une page ne tourne plus). Les 74 handlers
# inline `onclick=` restent couverts par `script-src-attr`, explicitement
# détaché de script-src (sinon le nonce les désactiverait aussi).
# `unsafe-eval` reste : Alpine (x-data en string) et htmx (hx-vars) en ont
# besoin → à retirer avec le build CSP d'Alpine (voir reste d'A20).
# A20 TERMINÉ : `unsafe-eval` retiré — Alpine tourne en build CSP
# (static/js/alpine.csp.min.js, 0 eval) ; htmx allowEval=false.
# 15 surfaces en csp_preview vert + scan statique 0 (board/gitea/cards
# = props propres, gitea down empêche un gate dédié).
CSP_VALUE = (
"default-src 'self'; "
# A20 phase 2 : chart.js/leaflet vendorisés dans /static/js/vendor
# (test_csp_no_cdn_and_vendor), plus aucun hôte CDN tiers.
# `unsafe-eval` reste : Alpine (x-data) + htmx (hx-on/hx-vars) en
# ont besoin → retrait avec le build CSP d'Alpine (reste d'A20).
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}'; "
"script-src 'self' 'nonce-{nonce}'; "
"script-src-attr 'unsafe-inline'; "
# ponytail: aucun @font-face Google (grep négatif) → les deux
# hôtes fonts étaient morts, supprimés.
+13 -13
View File
@@ -55,17 +55,17 @@ h1{{font-size:24px;margin:0 0 8px}} p.desc{{color:#A0A0A0;margin:0 0 20px}}
</style></head><body>
<h1>{icon} {title}</h1>
<div class="view-tabs">
<a class="tab{' active' if view_type=='table' else ''}" href="?view=table">📊 Table</a>
<a class="tab{' active' if view_type=='board' else ''}" href="?view=board">📋 Board</a>
<a class="tab{' active' if view_type=='calendar' else ''}" href="?view=calendar">📅 Calendar</a>
<a class="tab{' active' if view_type=='gallery' else ''}" href="?view=gallery">🖼️ Gallery</a>
<a class="tab{' active' if view_type=='list' else ''}" href="?view=list">📝 List</a>
<a class="tab{' active' if view_type=='timeline' else ''}" href="?view=timeline">📈 Timeline</a>
<a class="tab{' active' if view_type=='gantt' else ''}" href="?view=gantt">📊 Gantt</a>
<a class="tab{' active' if view_type=='chart' else ''}" href="?view=chart">📉 Chart</a>
<a class="tab{' active' if view_type=='form' else ''}" href="?view=form">📋 Form</a>
<a class="tab{' active' if view_type=='map' else ''}" href="?view=map">🗺️ Map</a>
<a class="tab{' active' if view_type=='feed' else ''}" href="?view=feed">📰 Feed</a>
<a class="tab{' active' if view_type=='table' else ''}" href="?view_type=table">📊 Table</a>
<a class="tab{' active' if view_type=='board' else ''}" href="?view_type=board">📋 Board</a>
<a class="tab{' active' if view_type=='calendar' else ''}" href="?view_type=calendar">📅 Calendar</a>
<a class="tab{' active' if view_type=='gallery' else ''}" href="?view_type=gallery">🖼️ Gallery</a>
<a class="tab{' active' if view_type=='list' else ''}" href="?view_type=list">📝 List</a>
<a class="tab{' active' if view_type=='timeline' else ''}" href="?view_type=timeline">📈 Timeline</a>
<a class="tab{' active' if view_type=='gantt' else ''}" href="?view_type=gantt">📊 Gantt</a>
<a class="tab{' active' if view_type=='chart' else ''}" href="?view_type=chart">📉 Chart</a>
<a class="tab{' active' if view_type=='form' else ''}" href="?view_type=form">📋 Form</a>
<a class="tab{' active' if view_type=='map' else ''}" href="?view_type=map">🗺️ Map</a>
<a class="tab{' active' if view_type=='feed' else ''}" href="?view_type=feed">📰 Feed</a>
</div>
{body}
</body></html>"""
@@ -130,9 +130,9 @@ def _render_calendar(view_type: str, collection: dict, pages: list[dict], config
.cal-nav span{{font-size:16px;font-weight:600}}
</style>
<div class="cal-nav">
<a href="?view=calendar&year={prev.year}&month={prev.month}">← {prev.strftime('%B')}</a>
<a href="?view_type=calendar&year={prev.year}&month={prev.month}">← {prev.strftime('%B')}</a>
<span>{first.strftime('%B %Y')}</span>
<a href="?view=calendar&year={next_month.year}&month={next_month.month}">{next_month.strftime('%B')} →</a>
<a href="?view_type=calendar&year={next_month.year}&month={next_month.month}">{next_month.strftime('%B')} →</a>
</div>
<div class="calendar">
<div class="cal-header">Mon</div><div class="cal-header">Tue</div><div class="cal-header">Wed</div>
+2 -2
View File
@@ -163,9 +163,9 @@
</div>
<div style="display:flex;gap:4px;padding:0 4px;">
<input class="ctx-input" x-ref="ctxTagInput" placeholder="Tag name…" @click.stop
@keydown.enter.prevent="$store.fdCtx.addNewTag($event.target.value, $store.fdCtx.newTagColor); $event.target.value = ''"
@keydown.enter.prevent="$store.fdCtx.addTagAndClear($event.target.value, $store.fdCtx.newTagColor, $event.target)"
@keydown.escape.stop="$store.fdCtx.tagAdding = false">
<button type="button" class="ctx-btn" @click.stop="$store.fdCtx.addNewTag($refs.ctxTagInput.value, $store.fdCtx.newTagColor); $refs.ctxTagInput.value = ''">Add</button>
<button type="button" class="ctx-btn" @click.stop="$store.fdCtx.addTagAndClear($refs.ctxTagInput.value, $store.fdCtx.newTagColor, $refs.ctxTagInput)">Add</button>
</div>
</div>
</div>
+8 -5
View File
@@ -60,14 +60,14 @@
{# ── Collapsed "…" segment ── #}
<template x-if="crumb.ellipsis">
<span class="crumb-anchor"
@mouseenter="cancelCloseTimer(); ellipsisOpen = true"
@mouseenter="hoverEllipsis()"
@mouseleave="dragCloseTimer()">
<button type="button" class="breadcrumb-link crumb-ellipsis">&hellip;</button>
<div class="fd-nav-menu" x-show="ellipsisOpen" x-cloak x-transition.opacity
@mouseenter="cancelCloseTimer()"
@mouseleave="dragCloseTimer()">
<template x-for="h in crumb.hidden" :key="h.id">
<div class="fd-nav-item" @click.stop="go(h.url); closeAll()">
<div class="fd-nav-item" @click.stop="goClose(h.url)">
<span class="fd-nav-ico" :title="h.icon || 'file'"></span>
<span class="fd-nav-label" x-text="h.label"></span>
</div>
@@ -91,7 +91,7 @@
{# ── Interactive crumb with navigation dropdown (Notion-style hover) ── #}
<template x-if="!crumb.ellipsis && crumb.menu">
<span class="crumb-anchor"
@mouseenter="cancelCloseTimer(); openMenu(crumb.origIndex)"
@mouseenter="hoverMenu(crumb.origIndex)"
@mouseleave="dragCloseTimer()">
<button type="button" class="breadcrumb-link"
:class="{ 'breadcrumb-current': crumb.origIndex === crumbs.length - 1 }"
@@ -110,7 +110,7 @@
<template x-for="item in activeItems" :key="item.id">
<div class="fd-nav-item"
@mouseenter="openSub(item)"
@click.stop="go(item.url); closeAll()">
@click.stop="goClose(item.url)">
<span class="fd-nav-ico" :title="item.icon || 'file'"></span>
<span class="fd-nav-label" x-text="item.name"></span>
<span class="fd-nav-arrow" x-show="item.has_children">&rsaquo;</span>
@@ -118,7 +118,7 @@
@mouseenter="cancelCloseTimer()"
@mouseleave="dragCloseTimer()">
<template x-for="s in subItems" :key="s.id">
<div class="fd-nav-item" @click.stop="go(s.url); closeAll()">
<div class="fd-nav-item" @click.stop="goClose(s.url)">
<span class="fd-nav-ico" :title="s.icon || 'file'"></span>
<span class="fd-nav-label" x-text="s.name"></span>
<span class="fd-nav-arrow" x-show="s.has_children">&rsaquo;</span>
@@ -152,6 +152,9 @@ document.addEventListener('alpine:init', function () {
wsId: 0,
openIdx: null,
ellipsisOpen: false,
hoverEllipsis(){ this.cancelCloseTimer(); this.ellipsisOpen = true; },
hoverMenu(i){ this.cancelCloseTimer(); this.openMenu(i); },
goClose(u){ this.go(u); this.closeAll(); },
loading: false,
cache: {},
activeItems: [],
+1 -1
View File
@@ -88,7 +88,7 @@ document.addEventListener('alpine:init', function () {
return Math.floor(diff / 86400) + 'd ago';
},
csrf() {
return (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
return getCsrf();
},
refreshCount() {
var self = this;
+17 -17
View File
@@ -50,7 +50,7 @@
@keydown.enter.prevent="inviteEnter()"
@keydown.down.prevent="inviteMove(1)"
@keydown.up.prevent="inviteMove(-1)"
@keydown.escape="inviteSuggestOpen = false; inviteUsers = []"
@keydown.escape="closeInviteSuggest()"
autocomplete="off"
/>
<button class="sd-btn-primary" @click="shareInvite()">
@@ -169,7 +169,7 @@
<div
class="sd-perm-option"
:class="{ active: a.permission === 'edit' }"
@click="updateShare(a.id, 'edit'); a.permOpen=false"
@click="setSharePerm(a, 'edit')"
>
<span>Can edit</span>
<span class="sd-perm-desc"
@@ -179,7 +179,7 @@
<div
class="sd-perm-option"
:class="{ active: a.permission === 'comment' }"
@click="updateShare(a.id, 'comment'); a.permOpen=false"
@click="setSharePerm(a, 'comment')"
>
<span>Can comment</span>
<span class="sd-perm-desc"
@@ -189,7 +189,7 @@
<div
class="sd-perm-option"
:class="{ active: a.permission === 'view' }"
@click="updateShare(a.id, 'view'); a.permOpen=false"
@click="setSharePerm(a, 'view')"
>
<span>Can view</span>
<span class="sd-perm-desc"
@@ -199,7 +199,7 @@
<div class="sd-perm-sep"></div>
<div
class="sd-perm-option danger"
@click="removeShare(a.id); a.permOpen=false"
@click="removeShareAndClose(a)"
>
Remove
</div>
@@ -233,7 +233,7 @@
<div
class="sd-perm-option"
:class="{ active: generalAccess === 'invited' }"
@click="generalAccess='invited'; accessMenuOpen=false"
@click="pickGeneralAccess('invited')"
>
<span>{{ fd_icon('lock',14) }} Only people invited</span>
<span class="sd-perm-desc"
@@ -243,7 +243,7 @@
<div
class="sd-perm-option"
:class="{ active: generalAccess === 'anyone' }"
@click="generalAccess='anyone'; accessMenuOpen=false"
@click="pickGeneralAccess('anyone')"
>
<span>{{ fd_icon('globe',14) }} Anyone with the link</span>
<span class="sd-perm-desc"
@@ -312,7 +312,7 @@
<template x-for="(ic, ici) in ['📄','📝','📋','📊','🗂️','📁','📂','🗒️','🗓️','📌','🔖','⭐','🚀','💡','🎯','🔑','📚','🧪','🌍','💰','📝','🧩','🎨','🔧','⚙️','🗃️','📦','🏷️','📍','🏠','👤']" :key="ici">
<button type="button" class="sd-icon-btn" @click="setIcon(ic)" :class="{ active: iconValue === ic }" style="width:36px;height:36px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);font-size:18px;display:flex;align-items:center;justify-content:center;cursor:pointer;" x-text="ic"></button>
</template>
<input type="text" class="sd-input" x-model="iconValue" placeholder="Custom emoji" style="max-width:80px;" @keydown.enter.prevent="setIcon(iconValue);iconOpen=false;">
<input type="text" class="sd-input" x-model="iconValue" placeholder="Custom emoji" style="max-width:80px;" @keydown.enter.prevent="applyIcon()">
</div>
</div>
</div>
@@ -498,16 +498,16 @@
</div>
<div class="more-menu-item" @click="movePage">↗ Move to</div>
<div class="more-menu-sep"></div>
<div class="more-menu-item" @click="moreOpen=false; toggleLock()">
<div class="more-menu-item" @click="moreToggleLock()">
<span x-text="isLocked ? '🔓 Unlock page' : '🔒 Lock page'"></span>
</div>
<div class="more-menu-item" @click="moreOpen=false; setPageOption('full_width', !fullWidth)">
<div class="more-menu-item" @click="moreFullWidth()">
↔ Full width <span x-show="fullWidth" style="margin-left:auto;font-size:11px">✓</span>
</div>
<div class="more-menu-item" @click="moreOpen=false; setPageOption('font_small', !fontSmall)">
<div class="more-menu-item" @click="moreFontSmall()">
Aa Small text <span x-show="fontSmall" style="margin-left:auto;font-size:11px">✓</span>
</div>
<div class="more-menu-item" @click="moreOpen=false; saveAsTemplate()">
<div class="more-menu-item" @click="moreSaveTemplate()">
📑 Save as template
</div>
<div class="more-menu-sep"></div>
@@ -546,13 +546,13 @@
<div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 50%,rgba(0,0,0,.6) 100%);z-index:1;"></div>
</div>
<div class="page-title-block">
<span class="page-icon-emoji" @click="openIconPicker()" title="Change icon" x-html="iconHtml()"></span>
<span class="page-icon-emoji" @click="openIconPicker()" title="Change icon" x-init="bindIconHtml($el)"></span>
<div
class="page-title-input"
contenteditable="true"
id="_titleEl"
data-placeholder="New Page"
@input="dirty=true;save()"
@input="markAndSave()"
@keydown.enter.prevent="focusBlock()"
@paste.prevent="pastePlain($event)"
spellcheck="false"
@@ -583,7 +583,7 @@
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="window.E && window.E.commentOnSelection()">
@click="edCall('commentOnSelection')">
💬 Comment
</button>
@@ -820,7 +820,7 @@
<div style="padding:24px;text-align:center;color:var(--text-dim);">No pages link here</div>
</template>
<template x-for="b in backlinksList" :key="b.id">
<a :href="'/pages/' + b.id" style="display:block;padding:12px 16px;border-bottom:1px solid var(--border);text-decoration:none;color:inherit;" @click.prevent="backlinksOpen=false;window.location.href='/pages/'+b.id">
<a :href="'/pages/' + b.id" style="display:block;padding:12px 16px;border-bottom:1px solid var(--border);text-decoration:none;color:inherit;" @click.prevent="openBacklink(b)">
<div style="font-weight:500;color:var(--text-primary);" x-text="b.title"></div>
<div style="font-size:12px;color:var(--text-dim);margin-top:2px;" x-text="b.workspace || ''"></div>
</a>
@@ -856,7 +856,7 @@
<template x-if="importFile">
<div style="margin-top:8px;display:flex;align-items:center;gap:12px;color:var(--text-dim);font-size:13px;">
<span x-text="importFile.name"></span>
<span x-text="Math.round(importFile.size/1024)+' KB'"></span>
<span x-text="fmtImportSize(importFile)"></span>
</div>
</template>
<div style="display:flex;gap:8px;margin-top:10px;">
+8 -2
View File
@@ -105,6 +105,12 @@
{% block scripts %}
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: accountsData »).
if (window.Alpine) { Alpine.data('accountsData', accountsData); }
else document.addEventListener('alpine:init', function () { Alpine.data('accountsData', accountsData); });
function accountsData() {
return {
profile: { full_name: '', email: '' },
@@ -117,8 +123,8 @@
} catch(e) {}
},
saveProfile() {
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
const token = csrf ? csrf[1] : '';
const csrf = getCsrf();;
const token = csrf;
fetch(`/api/users/me?full_name=${encodeURIComponent(this.profile.full_name)}&email=${encodeURIComponent(this.profile.email)}`, {
method: 'PUT', headers: { 'X-CSRF-Token': token }
}).then(() => {
+2 -2
View File
@@ -231,7 +231,7 @@
<div class="fd-ap-tabs">
<button class="fd-ap-tab" :class="{active: tab==='chat'}" @click="tab='chat'">Conversation</button>
<button class="fd-ap-tab" :class="{active: tab==='history'}" @click="tab='history'; loadConversations()">Historique</button>
<button class="fd-ap-tab" :class="{active: tab==='history'}" @click="showHistory()">Historique</button>
</div>
<div class="fd-agent-body">
@@ -305,7 +305,7 @@
</div>
</div>
<div class="fd-agent-msg-content" x-show="m.generating" x-cloak>Génération…</div>
<div class="fd-agent-msg-content fd-agent-msg-markdown" x-show="!m.generating && m.role==='assistant' && m.content" x-html="renderMarkdown(m.content)"></div>
<div class="fd-agent-msg-content fd-agent-msg-markdown" x-show="!m.generating && m.role==='assistant' && m.content" x-init="bindMarkdown($el, m)"></div>
<div class="fd-agent-msg-content" x-show="!m.generating && m.role!=='assistant' && m.content" x-text="m.content"></div>
<template x-if="m.proposal">
<div class="fd-proposal-bar">
+161 -36
View File
@@ -115,13 +115,22 @@
</style>
{# A20 : htmx copie les <script nonce="{{ csp_nonce() }}"> des réponses boostées — il remet leur nonce
depuis cette config (le nonce de la réponse courante, pas celui du fetch). #}
<meta name="htmx-config" content='{"inlineScriptNonce": "{{ csp_nonce() }}"}'>
<meta name="htmx-config" content='{"inlineScriptNonce": "{{ csp_nonce() }}", "allowEval": false}'>
<script nonce="{{ csp_nonce() }}">
// A38 : helper CSRF unique — défini le plus tôt possible (head) pour
// tous les scripts inline/externes de la page (welcome.html, isolé de
// base, garde sa propre lecture du cookie).
window.getCsrf = function() {
var m = document.cookie.match(/csrf_token=([^;]+)/);
return m ? m[1] : '';
};
</script>
<script src="/static/js/htmx.min.js" data-cfasync="false"></script>
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
</head>
<body hx-headers='{"X-CSRF-Token":"__CSRF_PLACEHOLDER__"}'{% if embed_mode %} class="embed-mode"{% endif %}>
<div class="app-layout" x-data="appState()">
<body hx-headers='{"X-CSRF-Token":{{ csrf_token() | tojson }}}'{% if embed_mode %} class="embed-mode"{% endif %}>
<div class="app-layout" x-data="appState()" x-cloak>
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
<div class="sidebar-overlay"
@@ -179,8 +188,8 @@
:class="{ collapsed: sidebarCollapsed && !sidebarPeek, 'mobile-open': mobileSidebarOpen, peeking: sidebarPeek }"
id="sidebar"
@mouseleave="!sidebarResizing && (sidebarPeek = false)"
x-effect="document.documentElement.classList.toggle('fd-sidebar-collapsed', sidebarCollapsed && !sidebarPeek)"
x-init="$nextTick(() => { if(typeof initTreeSortable==='function') initTreeSortable(); })">
x-effect="syncSidebarClass()"
x-init="initSidebarSort()"
<!-- Header + dropdown wrapper -->
<div style="position:relative;">
<div class="sidebar-workspace-header" @click="workspaceMenuOpen = !workspaceMenuOpen" @mouseenter="wsHeaderHover=true" @mouseleave="wsHeaderHover=false"
@@ -263,8 +272,8 @@
</div>
{% if has_active_workspace %}
<div class="sidebar-section-actions">
<button class="section-action-btn" title="New Page" @click.stop="window.FlowDeck.createPage()">{{ fd_icon("file",16) }}</button>
<button class="section-action-btn" title="New Folder" @click.stop="window.FlowDeck.showCreateFolderModal()">{{ fd_icon("folder",16) }}</button>
<button class="section-action-btn" title="New Page" @click.stop="fdCreatePage()">{{ fd_icon("file",16) }}</button>
<button class="section-action-btn" title="New Folder" @click.stop="fdCreateFolder()">{{ fd_icon("folder",16) }}</button>
<a class="section-action-btn" href="/local-workspace" title="Open workspace page" style="text-decoration:none;">{{ fd_icon("external-link",16) }}</a>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'workspace')" @contextmenu.stop="openSectionMenu($event, 'workspace')">⋮</button>
</div>
@@ -299,7 +308,7 @@
<span class="section-label">Repository (Gitea)</span>
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="Refresh" @click.stop="if(window._gwData)window._gwData.refreshTree()">{{ fd_icon("refresh",16) }}</button>
<button class="section-action-btn" title="Refresh" @click.stop="fdGwRefresh()">{{ fd_icon("refresh",16) }}</button>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'gitea')">⋮</button>
</div>
</div>
@@ -321,7 +330,7 @@
</div>
<div class="sidebar-section-actions">
{% if has_active_workspace %}
<button class="section-action-btn" title="Add" @click.stop="window.FlowDeck.createPage()">+</button>
<button class="section-action-btn" title="Add" @click.stop="fdCreatePage()">+</button>
{% endif %}
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'meetings')">⋮</button>
</div>
@@ -398,7 +407,7 @@
<ul class="sidebar-items" data-section="agents">
<template x-for="a in agentList" :key="a.id">
<li class="sidebar-item" @click="agentOpen(a.id)">
<span class="page-icon page-icon-svg" x-html="a.icon || '🤖'"></span>
<span class="page-icon page-icon-svg" x-init="bindAgentIcon($el, a)"></span>
<span class="page-name" x-text="a.name"></span>
</li>
</template>
@@ -532,12 +541,7 @@
<button class="scp-done" @click="toggleCustomize()">Done</button>
</div>
<div class="scp-list">
<template x-for="(cfg, key) in (Object.keys(sidebarConfig).length ? sidebarConfig : {
workspace:{visible:true,order:0},teamspaces:{visible:true,order:1},
meetings:{visible:true,order:2},recents:{visible:true,order:3},
favorites:{visible:true,order:4},agents:{visible:true,order:5},
shared:{visible:true,order:6},published:{visible:true,order:7}
})" :key="key">
<template x-for="(cfg, key) in sidebarSections()" :key="key">
<div class="scp-item" @click="toggleSectionVisibility(key)">
<div class="scp-item-left">
<span class="scp-item-icon" x-text="getSectionIcon(key)"></span>
@@ -586,40 +590,40 @@
<!-- Section options menu (⋮ dropdown) -->
<div class="section-menu-overlay" x-show="sectionMenu.visible" @click="closeSectionMenu()" @contextmenu.prevent="closeSectionMenu()"></div>
<div class="section-menu" x-show="sectionMenu.visible" x-cloak
:style="{ top: sectionMenu.y + 'px', left: Math.min(sectionMenu.x, window.innerWidth - 220) + 'px' }"
:style="sectionMenuPos()"
@click.outside="closeSectionMenu()">
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 5)">
<div class="section-menu-item" @click="closeAndSetCount(5)">
<span class="smi-label">Show 5 items</span>
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 5">✓</span>
</div>
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 10)">
<div class="section-menu-item" @click="closeAndSetCount(10)">
<span class="smi-label">Show 10 items</span>
<span class="smi-check" x-show="!sidebarConfig[sectionMenu.section] || sidebarConfig[sectionMenu.section].show_count === 10 || sidebarConfig[sectionMenu.section].show_count == null">✓</span>
</div>
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 15)">
<div class="section-menu-item" @click="closeAndSetCount(15)">
<span class="smi-label">Show 15 items</span>
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 15">✓</span>
</div>
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 20)">
<div class="section-menu-item" @click="closeAndSetCount(20)">
<span class="smi-label">Show 20 items</span>
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 20">✓</span>
</div>
<div class="section-menu-sep"></div>
<div class="section-menu-item" @click="closeSectionMenu(); moveSection(sectionMenu.section, 'up')">
<div class="section-menu-item" @click="closeAndMove('up')">
<span class="smi-icon">↑</span>
<span class="smi-label">Move up</span>
</div>
<div class="section-menu-item" @click="closeSectionMenu(); moveSection(sectionMenu.section, 'down')">
<div class="section-menu-item" @click="closeAndMove('down')">
<span class="smi-icon">↓</span>
<span class="smi-label">Move down</span>
</div>
<div class="section-menu-sep"></div>
<div class="section-menu-item" @click="closeSectionMenu(); toggleSectionVisibility(sectionMenu.section)">
<div class="section-menu-item" @click="closeAndToggleVisibility()">
<span class="smi-icon">👁</span>
<span class="smi-label" x-text="isSectionVisible(sectionMenu.section) ? 'Hide section' : 'Show section'"></span>
</div>
<div class="section-menu-sep"></div>
<div class="section-menu-item section-menu-customize" @click="closeSectionMenu(); toggleCustomize()">
<div class="section-menu-item section-menu-customize" @click="menuCustomize()">
<span class="smi-icon">⚙️</span>
<span class="smi-label">Customize sidebar</span>
</div>
@@ -796,10 +800,6 @@
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
// Inject CSRF token into HTMX headers
(function() {
const getCsrf = () => {
const m = document.cookie.match(/csrf_token=([^;]+)/);
return m ? m[1] : '';
};
document.body.setAttribute('hx-headers', JSON.stringify({'X-CSRF-Token': getCsrf()}));
document.addEventListener('htmx:configRequest', function(evt) {
evt.detail.headers['X-CSRF-Token'] = getCsrf();
@@ -853,8 +853,7 @@
// these functions for consistent behaviour.
window.FlowDeck = {
getCsrfToken: function() {
var m = document.cookie.match(/csrf_token=([^;]+)/);
return m ? m[1] : '';
return getCsrf();
},
/** Reflète le rename d'une page/dossier dans toute l'UI : sidebar gauche
@@ -1123,6 +1122,12 @@
}
};
// A20 phase 3 : le build CSP ne résout que le registre Alpine.data
// (probe : globale window → « Undefined variable: appState »).
document.addEventListener('alpine:init', function () {
Alpine.data('appState', appState);
});
function appState() {
return {
init() {
@@ -1458,6 +1463,10 @@
this.sectionMenu = { section: section, visible: true, x: ev.clientX, y: ev.clientY };
},
menuCustomize() { this.closeSectionMenu(); this.toggleCustomize(); },
closeAndSetCount(n) { this.closeSectionMenu(); this.setShowCount(this.sectionMenu.section, n); },
closeAndMove(dir) { this.closeSectionMenu(); this.moveSection(this.sectionMenu.section, dir); },
closeAndToggleVisibility() { this.closeSectionMenu(); this.toggleSectionVisibility(this.sectionMenu.section); },
closeSectionMenu() {
this.sectionMenu.visible = false;
},
@@ -1564,6 +1573,55 @@
else window.location.href = url;
},
// ── A20 phase 3 : expressions → méthode (build CSP : appel seul ;
// document/Math/window/FlowsDeck = JS réel, hors évaluateur) ──
bindProjectIcon(el, p) {
Alpine.effect(() => { el.innerHTML = getSvgIcon(p.icon || 'folder', 20); });
},
// ── A20 ph3 : délégués du topbar éditeur (window.E hors portée CSP,
// scope du topbar = appState) — voir right_actions de page_editor ──
edCall(name) {
if (window.E && typeof window.E[name] === 'function') window.E[name]();
},
edTimeAgo() { return (window.E && window.E.timeAgo) || ''; },
edCommentCount() {
return (window.E && window.E.commentCount > 0) ? window.E.commentCount : '';
},
edShared() { return !!(window.E && window.E.pageIsShared); },
// les 2 branches du ternaire favorited étaient identiques → rendu 1×
bindStar(el) { Alpine.effect(() => { el.innerHTML = getSvgIcon('star', 14); }); },
bindAgentIcon(el, a) {
Alpine.effect(() => { el.innerHTML = a.icon || '🤖'; });
},
syncSidebarClass() {
document.documentElement.classList.toggle(
'fd-sidebar-collapsed', this.sidebarCollapsed && !this.sidebarPeek);
},
initSidebarSort() {
Alpine.nextTick(() => {
if (typeof initTreeSortable === 'function') initTreeSortable();
});
},
fdCreatePage() { window.FlowDeck.createPage(); },
fdCreateFolder() { window.FlowDeck.showCreateFolderModal(); },
fdGwRefresh() { if (window._gwData) window._gwData.refreshTree(); },
sidebarSections() {
if (Object.keys(this.sidebarConfig).length) return this.sidebarConfig;
return {
workspace: { visible: true, order: 0 }, teamspaces: { visible: true, order: 1 },
meetings: { visible: true, order: 2 }, recents: { visible: true, order: 3 },
favorites: { visible: true, order: 4 }, agents: { visible: true, order: 5 },
shared: { visible: true, order: 6 }, published: { visible: true, order: 7 },
};
},
sectionMenuPos() {
return {
top: this.sectionMenu.y + 'px',
left: Math.min(this.sectionMenu.x, window.innerWidth - 220) + 'px',
};
},
toggleSidebar() {
this.sidebarPeek = false;
this.sidebarCollapsed = !this.sidebarCollapsed;
@@ -1733,8 +1791,7 @@
addPage(section) { this.newPage(section); },
addSubPage(id) { this.newSubPage(id); },
getCsrfToken() {
const m = document.cookie.match(/csrf_token=([^;]+)/);
return m ? m[1] : '';
return getCsrf();
},
newPage(section) {
const project = this.workspaceKey || '';
@@ -2181,6 +2238,14 @@
.cmd-palette-overlay.open .cmd-palette{transform:translateY(0)}
.cmd-palette-input{width:100%;box-sizing:border-box;padding:16px 18px;background:transparent;border:none;outline:none;color:var(--text,#fff);font-size:16px}
.cmd-palette-input::placeholder{color:var(--text-dim,rgba(255,255,255,.4))}
.cmd-palette-tabs{display:flex;gap:6px;padding:10px 16px 0}
.cp-tab{background:none;border:1px solid var(--border,rgba(255,255,255,.12));color:var(--text-dim,rgba(255,255,255,.55));border-radius:999px;padding:4px 12px;font-size:12px;cursor:pointer}
.cp-tab.active{background:var(--bg-hover,#2a2a2a);color:var(--text,#eee);border-color:var(--accent,#2383e2)}
.cmd-palette-ai{padding:14px 18px;font-size:13.5px;line-height:1.55;white-space:pre-wrap;color:var(--text,#eee)}
.cmd-palette-ai .cp-cites{margin-top:10px;display:flex;flex-direction:column;gap:4px;font-size:12.5px;white-space:normal}
a.cp-cite{color:var(--accent,#529ffa);text-decoration:none}
a.cp-cite:hover{text-decoration:underline}
.cp-loading{color:var(--text-dim,rgba(255,255,255,.5))}
.cmd-palette-list{overflow-y:auto;border-top:1px solid var(--border,rgba(255,255,255,.06))}
.cmd-palette-group{display:flex;align-items:center;gap:8px;padding:10px 18px 4px;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-dim,rgba(255,255,255,.4))}
.cmd-palette-item{display:flex;align-items:center;gap:10px;padding:9px 18px;cursor:pointer;font-size:14px;color:var(--text,#fff)}
@@ -2202,6 +2267,10 @@
<input id="fd-cp-input" class="cmd-palette-input" type="text"
placeholder="Search pages, databases, or type a command…"
autocomplete="off" spellcheck="false">
<div class="cmd-palette-tabs" id="fd-cp-tabs" role="tablist">
<button type="button" class="cp-tab active" data-tab="pages">Pages</button>
<button type="button" class="cp-tab" data-tab="ai">✨ Réponses IA</button>
</div>
<div id="fd-cp-list" class="cmd-palette-list"></div>
<div class="cmd-palette-footer">
<span class="cpf-kbd"><b>↑</b> / <b>↓</b> navigate</span>
@@ -2222,7 +2291,7 @@
];
var overlay, input, list;
var state = { open:false, items:[], index:0, query:'', timer:null, actions:false };
var state = { open:false, items:[], index:0, query:'', timer:null, actions:false, tab:'pages', ai:null };
function esc(s){ return String(s==null?'':s).replace(/[&<>"']/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]; }); }
function highlight(text, q){
@@ -2246,6 +2315,7 @@
}
function render(){
if(state.tab==='ai'){ renderAi(); return; }
if(!state.open) return;
if(!state.items.length){
list.innerHTML = '<div class="cmd-palette-empty">No results for “'+esc(state.query)+'”</div>';
@@ -2282,6 +2352,7 @@
}
function runSearch(){
if(state.tab==='ai'){ runAi(); return; }
var q = input.value.trim();
state.query = q;
state.index = 0;
@@ -2292,7 +2363,7 @@
return;
}
state.actions = false;
fetch('/api/search?q='+encodeURIComponent(q), {headers:{'X-CSRF-Token': document.body.getAttribute('hx-headers') ? (JSON.parse(document.body.getAttribute('hx-headers'))['X-CSRF-Token']||'') : ''}})
fetch('/api/search?q='+encodeURIComponent(q))
.then(function(r){ return r.json(); })
.then(function(data){
if(input.value.trim()!==q) return; // stale
@@ -2306,6 +2377,50 @@
.catch(function(){ state.items=[]; render(); });
}
function runAi(){
var q = input.value.trim();
state.ai = null;
if(!q){ state.ai = {hint:1}; renderAi(); return; }
state.ai = {loading:1}; renderAi();
fetch('/api/v2/search/ask', {
method:'POST',
headers: {'Content-Type':'application/json', 'X-CSRF-Token': getCsrf()},
body: JSON.stringify({question: q})
}).then(function(r){ if(!r.ok) throw 0; return r.json(); })
.then(function(d){
if(state.tab!=='ai' || input.value.trim()!==q) return;
state.ai = d; renderAi();
})
.catch(function(){
if(state.tab!=='ai') return;
state.ai = {error:1}; renderAi();
});
}
function renderAi(){
var a = state.ai;
if(!a){ list.innerHTML=''; return; }
if(a.loading){ list.innerHTML='<div class="cmd-palette-ai"><span class="cp-loading">Recherche de la réponse…</span></div>'; return; }
if(a.error){ list.innerHTML='<div class="cmd-palette-ai">Demande impossible — réessayez.</div>'; return; }
if(a.hint){ list.innerHTML='<div class="cmd-palette-ai">Posez une question sur votre espace : la réponse cite vos pages.</div>'; return; }
var cits = a.citations || [], byId = {};
cits.forEach(function(c){ byId[String(c.id)] = c; });
// échappement AVANT injection des liens (ids = chiffres, type = [a-z]+)
var md = esc(a.answer_markdown || '')
.replace(/\[\[([a-z]+):(\d+)\]\]/g, function(m, type, id){
var c = byId[id];
var label = esc((c && (c.title || c.name)) || (type + ' #' + id));
var href = type === 'collection' ? '/db/' + id : '/pages/' + id;
return '<a class="cp-cite" href="' + href + '">' + label + '</a>';
})
.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
var cites = cits.map(function(c){
var href = c.type === 'collection' ? '/db/' + c.id : '/pages/' + c.id;
return '<a class="cp-cite" href="' + href + '">' + esc(c.title || c.name || 'page #' + c.id) + '</a>';
}).join('');
list.innerHTML = '<div class="cmd-palette-ai">' + md +
(cites ? '<div class="cp-cites"><b>Sources</b> ' + cites + '</div>' : '') + '</div>';
}
function open(){
if(state.open) return;
state.open=true; overlay.classList.add('open');
@@ -2360,6 +2475,16 @@
list = document.getElementById('fd-cp-list');
if(!overlay) return;
input.addEventListener('input', function(){ clearTimeout(state.timer); state.timer=setTimeout(runSearch, 150); });
var tabs = document.getElementById('fd-cp-tabs');
if(tabs) tabs.addEventListener('click', function(e){
var b = e.target && e.target.closest ? e.target.closest('.cp-tab') : null;
if(!b) return;
state.tab = b.getAttribute('data-tab');
var all = tabs.querySelectorAll('.cp-tab');
for(var i=0;i<all.length;i++) all[i].classList.toggle('active', all[i]===b);
state.index = 0;
runSearch();
});
document.addEventListener('keydown', onKey);
});
})();
+3 -3
View File
@@ -74,7 +74,7 @@
<template x-for="(f, i) in activeFilters" :key="i">
<div class="filter-pill">
<span x-text="f.property + ': ' + f.value"></span>
<button class="remove-filter" @click="removeFilter(i); refreshView()">✕</button>
<button class="remove-filter" @click="removeFilterAndRefresh(i)">✕</button>
</div>
</template>
<div class="filter-pill" style="cursor:pointer; position:relative;" @click="showStatusMenu = !showStatusMenu">
@@ -84,7 +84,7 @@
<div class="dropdown-panel" :class="{ visible: showStatusMenu }" style="top:100%; left:0;"
@click.stop>
<template x-for="s in statusOptions" :key="s.value">
<div class="dropdown-option" @click="toggleStatus(s.value); showStatusMenu = false; refreshView()">
<div class="dropdown-option" @click="pickStatus(s.value)">
<span class="option-checkbox" :class="{ selected: statusFilters.includes(s.value) }">
<span x-show="statusFilters.includes(s.value)">✓</span>
</span>
@@ -95,7 +95,7 @@
</div>
</div>
<button class="filter-add-btn" @click="addFilter()">+ Filter</button>
<button class="filter-reset-btn" @click="resetFilters(); refreshView()" x-show="activeFilters.length > 0">Reset</button>
<button class="filter-reset-btn" @click="resetFiltersAndRefresh()" x-show="activeFilters.length > 0">Reset</button>
<div class="view-toolbar-spacer"></div>
+2 -9
View File
@@ -49,12 +49,5 @@
{% endfor %}
</div>
<script nonce="{{ csp_nonce() }}">
function openCardDetail(id) {
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
target: '#card-modal-content', swap: 'innerHTML'
});
document.getElementById('card-modal').style.display = 'flex';
document.getElementById('card-modal-title').textContent = 'Issue #' + id;
}
</script>
<!-- A38 : openCardDetail vit dans static/js/app.js (dedupliquee,
corps identique x2 dans ces deux fragments de vue) -->
+7 -6
View File
@@ -95,17 +95,17 @@
style="width:100%; min-height:60px; background:var(--bg-secondary); border:1px solid var(--border);
border-radius:6px; padding:8px; color:var(--text-primary); font-family:inherit; font-size:13px;
resize:vertical; margin-top:8px;"
@keydown.ctrl.enter="addComment($event.target.value); $event.target.value=''"></textarea>
@keydown.ctrl.enter="addCommentAndClear($event.target)"></textarea>
</div>
</div>
<script nonce="{{ csp_nonce() }}">
// ponytail: CSRF helper
function getCsrf() {
const m = document.cookie.match(/csrf_token=([^;]+)/);
return m ? m[1] : '';
}
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: cardDetail »).
if (window.Alpine) { Alpine.data('cardDetail', cardDetail); }
else document.addEventListener('alpine:init', function () { Alpine.data('cardDetail', cardDetail); });
function cardDetail() {
return {
updateField(field, value) {
@@ -139,6 +139,7 @@
})
.then(() => htmx.ajax('GET', `/api/issues/${owner}/${repo}/${issue_id}?format=html`, { target: '#card-modal-content', swap: 'innerHTML' }));
},
addCommentAndClear(el) { this.addComment(el.value); el.value = ''; },
addComment(body) {
if (!body.trim()) return;
console.log('Add comment:', body);
+2 -9
View File
@@ -58,12 +58,5 @@
{% endfor %}
</div>
<script nonce="{{ csp_nonce() }}">
function openCardDetail(id) {
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
target: '#card-modal-content', swap: 'innerHTML'
});
document.getElementById('card-modal').style.display = 'flex';
document.getElementById('card-modal-title').textContent = 'Issue #' + id;
}
</script>
<!-- A38 : openCardDetail vit dans static/js/app.js (dedupliquee,
corps identique x2 dans ces deux fragments de vue) -->
+5 -5
View File
@@ -6,7 +6,7 @@
{% block topbar %}
{% set breadcrumb_items = [{"label": owner ~ "/" ~ repo, "url": None}] %}
{% set page_icon = "link" %}
{% set right_actions = '<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">' ~ fd_icon("file",14) ~ '+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">' ~ fd_icon("upload",14) ~ '</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">' ~ fd_icon("refresh",14) ~ '</button><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
{% set right_actions %}<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">{{ fd_icon("file",14) }}+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">{{ fd_icon("upload",14) }}</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">{{ fd_icon("refresh",14) }}</button><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
{% include '_header.html' %}
{% endblock %}
@@ -36,7 +36,7 @@
</style>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/gitea_workspace.js?v={{ asset_version }}"></script>
<div class="gw-main" x-data="giteaWorkspace">
<div class="gw-main" x-data="giteaWorkspace()">
<!-- File view -->
<div x-show="showFile && !showEditor" x-transition>
<div class="gw-file-toolbar">
@@ -120,7 +120,7 @@
@click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)"
@contextmenu.prevent="openGwContext($event, item)"
style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;">
<span x-html="item.type==='folder' ? getSvgIcon('folder',16) : getSvgIcon('file',16)" style="font-size:16px;"></span>
<span x-init="bindGwIcon($el, item)" style="font-size:16px;"></span>
<span x-text="item.name" style="flex:1;font-size:14px;" :style="{ fontWeight: item.type==='folder' ? '500' : '400' }"></span>
<span x-text="item.type==='folder' ? '' : formatSize(item.size)" style="font-size:12px;color:var(--text-tertiary);"></span>
</div>
@@ -165,7 +165,7 @@
@click="openPrivate(pp.id)">
<div>
<div style="font-weight:500;" x-text="pp.title"></div>
<div style="font-size:12px;color:var(--text-dim);" x-text="pp.updated_at ? new Date(pp.updated_at+'Z').toLocaleString() : ''"></div>
<div style="font-size:12px;color:var(--text-dim);" x-text="fmtGwDate(pp)"></div>
</div>
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px;" @click.stop="deletePrivate(pp.id)">{{ fd_icon('trash',14) }}</button>
</div>
@@ -180,7 +180,7 @@
<strong>Editing: <span x-text="editingPrivateTitle || 'Untitled'"></span></strong>
<span style="flex:1;"></span>
<button class="btn" @click="savePrivate()">{{ fd_icon("save",14) }} Save</button>
<button class="btn" @click="editingPrivate=null;editingPrivateTitle='';editingPrivateContent='';">Cancel</button>
<button class="btn" @click="closePrivateEdit()">Cancel</button>
</div>
<div class="gw-content">
<input x-model="editingPrivateTitle" placeholder="Page title" style="width:100%;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:16px;margin-bottom:12px;outline:none;">
+2 -2
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Importer — FlowDeck</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
<style>
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;--warn:#D9730D;}
*{margin:0;padding:0;box-sizing:border-box;}
@@ -201,7 +201,7 @@ select,input[type=text]{background:var(--bg3);color:var(--text);border:1px solid
<template x-for="(e,i) in (report ? report.errors : [])" :key="i"><div x-text="'✕ '+e.title+' : '+e.error"></div></template>
</div>
<div class="warnings" x-show="report && report.relations && report.relations.relations_resolved">
<div x-text="'🔗 '+report.relations.relations_resolved+' relation(s) reconstruite(s)'"></div>
<div x-text="report && report.relations ? '🔗 '+report.relations.relations_resolved+' relation(s) reconstruite(s)' : ''"></div>
</div>
<div class="actions">
<button class="btn btn-ghost" @click="downloadReport()">Télécharger le rapport (JSON)</button>
+9 -9
View File
@@ -233,7 +233,7 @@
</div>
<div class="lib-toolbar">
<div class="lib-toolbar-wrap" x-show="tab !== 'repository'">
<button class="lib-icon-btn" :class="{active: filterOpen}" title="Filter" @click.stop="filterOpen=!filterOpen; sortOpen=false; viewOpen=false">
<button class="lib-icon-btn" :class="{active: filterOpen}" title="Filter" @click.stop="toggleFilterMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
</button>
<div class="lib-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition>
@@ -245,7 +245,7 @@
</div>
</div>
<div class="lib-toolbar-wrap">
<button class="lib-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="sortOpen=!sortOpen; filterOpen=false; viewOpen=false">
<button class="lib-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSortMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="14" y2="9"/><line x1="4" y1="15" x2="10" y2="15"/><polyline points="17 5 17 19"/><polyline points="13 16 17 20 21 16"/></svg>
</button>
<div class="lib-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition>
@@ -255,11 +255,11 @@
<div class="dd-item" :class="{active: sortBy==='author'}" @click="setSort('author')"><span class="check" x-text="sortBy==='author' ? '✓' : ''"></span> Created by</div>
</div>
</div>
<button class="lib-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="searchOpen=!searchOpen; $nextTick(()=>{ if(searchOpen) document.getElementById('lib-search-input').focus(); })">
<button class="lib-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="toggleSearch()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</button>
<div class="lib-toolbar-wrap">
<button class="lib-icon-btn" :class="{active: viewOpen}" title="View options" @click.stop="viewOpen=!viewOpen; filterOpen=false; sortOpen=false">
<button class="lib-icon-btn" :class="{active: viewOpen}" title="View options" @click.stop="toggleViewMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/><circle cx="9" cy="6" r="1.5" fill="currentColor" stroke="none"/><circle cx="15" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="9" cy="18" r="1.5" fill="currentColor" stroke="none"/></svg>
</button>
<div class="lib-dropdown" x-show="viewOpen" @click.outside="viewOpen=false" x-transition>
@@ -310,7 +310,7 @@
<button @click="copyLinks()" title="Copy links">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
</button>
<button @click="openMovePicker(Object.keys(selected).map(Number))" title="Move to">
<button @click="openMoveSelected()" title="Move to">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
</button>
<div style="position:relative;margin-left:auto;">
@@ -326,7 +326,7 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
Copy links to all
</div>
<div class="menu-item" @click="openMovePicker(Object.keys(selected).map(Number))">
<div class="menu-item" @click="openMoveSelected()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
Move to
</div>
@@ -378,7 +378,7 @@
<!-- ── Empty state ── -->
<div class="lib-empty" id="lib-empty">
<div class="empty-icon" x-html="getSvgIcon(emptyIcon,48)"></div>
<div class="empty-icon" x-init="bindHtmlIcon($el)"></div>
<h3 x-text="emptyTitle"></h3>
<p x-text="emptyText"></p>
</div>
@@ -397,7 +397,7 @@
<div class="move-modal-item" @click="confirmMove(0)">{{ fd_icon("file",14) }} <span>Root (no parent)</span></div>
<template x-for="it in moveCandidates" :key="it.id">
<div class="move-modal-item" @click="confirmMove(it.id)">
<span x-html="_renderIcon(it)"></span> <span x-text="it.title"></span>
<span x-init="bindHtmlItem($el, it)"></span> <span x-text="it.title"></span>
</div>
</template>
</div>
@@ -414,7 +414,7 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<div class="peek-title">
<span x-html="_renderIcon(peekItem)"></span>
<span x-init="bindHtmlItem($el, peekItem)"></span>
<span x-text="peekItem.title"></span>
</div>
<button @click="openItem(peekItem)" title="Open full page">
+30 -27
View File
@@ -11,7 +11,7 @@
{% block content %}
<span id="fd-local-ws-id" hidden>{{ workspace_id }}</span>
<div x-data="_wsInitData"
<div x-data="wsInitData()"
@dragover.prevent="onDragOver($event)"
@dragleave="onDragLeave($event)"
@drop.prevent="onDrop($event)"
@@ -482,10 +482,10 @@
<script type="application/json" id="lw-config" nonce="{{ csp_nonce() }}">{{ {"current_folder_id": current_folder_id, "workspace_id": workspace_id} | tojson }}</script>
<script data-cfasync="false" src="/static/js/local_workspace.js?v={{ asset_version }}"></script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof _wsInitData !== 'undefined');</script>
<div class="ws-split"
@contextmenu.prevent="_wsInitData.onContextMenu($event)"
@contextmenu.prevent="onContextMenu($event)"
@touchstart="onTouchStart($event)"
@touchend="onTouchEnd($event)"
@touchmove="onTouchMove($event)"
@@ -553,7 +553,7 @@
<div class="ws-toolbar" style="margin-left:auto;">
<!-- View dropdown -->
<div class="lib-toolbar-wrap" style="position:relative;">
<button class="ws-icon-btn" :class="{active: viewMenuOpen}" title="View" @click.stop="viewMenuOpen=!viewMenuOpen; sortOpen=false; searchOpen=false">
<button class="ws-icon-btn" :class="{active: viewMenuOpen}" title="View" @click.stop="toggleViewMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>
</button>
<div class="ws-dropdown" x-show="viewMenuOpen" @click.outside="viewMenuOpen=false" x-transition style="right:0;">
@@ -567,7 +567,7 @@
</div>
<!-- Sort -->
<div class="lib-toolbar-wrap" style="position:relative;">
<button class="ws-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="sortOpen=!sortOpen; viewMenuOpen=false">
<button class="ws-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSortMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="14" y2="9"/><line x1="4" y1="15" x2="10" y2="15"/><polyline points="17 5 17 19"/><polyline points="13 16 17 20 21 16"/></svg>
</button>
<div class="ws-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition style="right:0;">
@@ -581,7 +581,7 @@
</div>
<!-- Filter -->
<div class="lib-toolbar-wrap" style="position:relative;">
<button class="ws-icon-btn" :class="{active: filterOpen || filterType}" title="Filter" @click.stop="filterOpen=!filterOpen; viewMenuOpen=false; sortOpen=false">
<button class="ws-icon-btn" :class="{active: filterOpen || filterType}" title="Filter" @click.stop="toggleFilterMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
</button>
<div class="ws-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition style="right:0;">
@@ -597,7 +597,7 @@
</div>
</div>
<!-- Search toggle -->
<button class="ws-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="searchOpen=!searchOpen; if(searchOpen) $nextTick(()=>$refs.searchInput?.focus())">
<button class="ws-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="toggleSearch()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</button>
<!-- Expand/Collapse (tree only) -->
@@ -650,12 +650,12 @@
<!-- Filter chips (visual indicator when filter is active — shown in all views) -->
<div class="filter-row" x-show="filterType" style="padding:4px 0;margin-bottom:6px;">
<button class="filter-chip" :class="{ active: filterType === 'folder' }" @click="filterByType('folder')" title="Folders" x-html="getSvgIcon('folder',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'page' }" @click="filterByType('page')" title="Pages" x-html="getSvgIcon('file',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'pdf' }" @click="filterByType('pdf')" title="PDF" x-html="getSvgIcon('file',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'image' }" @click="filterByType('image')" title="Images" x-html="getSvgIcon('image',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'code' }" @click="filterByType('code')" title="Code" x-html="getSvgIcon('file',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'text' }" @click="filterByType('text')" title="Text" x-html="getSvgIcon('file',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'folder' }" @click="filterByType('folder')" title="Folders" x-init="bindSvg($el, 'folder', 14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'page' }" @click="filterByType('page')" title="Pages" x-init="bindSvg($el, 'file', 14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'pdf' }" @click="filterByType('pdf')" title="PDF" x-init="bindSvg($el, 'file', 14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'image' }" @click="filterByType('image')" title="Images" x-init="bindSvg($el, 'image', 14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'code' }" @click="filterByType('code')" title="Code" x-init="bindSvg($el, 'file', 14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'text' }" @click="filterByType('text')" title="Text" x-init="bindSvg($el, 'file', 14)"></button>
<span class="filter-clear" @click="filterType='', doFilter()">clear filter</span>
</div>
@@ -723,7 +723,7 @@
<span style="width:16px;flex-shrink:0;"></span>
</template>
<span class="icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
<span class="icon" x-init="bindFileIcon($el, node)"></span>
<template x-if="node.is_folder">
<span class="name folder" @click.stop="navigateToFolder(node.id)"
@@ -754,8 +754,8 @@
<div class="actions">
<template x-if="node.is_folder">
<span style="display:flex;gap:2px">
<button class="ws-act" @click.stop="window.FlowDeck.createPage(node.id)" title="New file">{{ fd_icon("file",14) }}</button>
<button class="ws-act" @click.stop="window.FlowDeck.showCreateFolderModal(node.id)" title="New folder">{{ fd_icon("folder",14) }}</button>
<button class="ws-act" @click.stop="createPageAt(node)" title="New file">{{ fd_icon("file",14) }}</button>
<button class="ws-act" @click.stop="createFolderAt(node)" title="New folder">{{ fd_icon("folder",14) }}</button>
<a class="ws-act" href="/local-workspace" title="Open workspace page" style="text-decoration:none;display:inline-flex;align-items:center;">{{ fd_icon("external-link",14) }}</a>
</span>
</template>
@@ -765,7 +765,7 @@
</div>
<!-- Children -->
<ul class="ws-tree" x-show="expanded[node.id]" x-transition
x-html="renderChildren(node.children, (node.depth||0)+1, tagsVersion)">
x-init="bindChildren($el, node)">
</ul>
</li>
</template>
@@ -807,7 +807,7 @@
<tr :class="{ selected: !!selectedIds[node.id] }">
<td><input type="checkbox" :checked="!!selectedIds[node.id]" @click.stop="toggleSelect(node, $event)"></td>
<td>
<span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
<span class="table-icon" x-init="bindFileIcon($el, node)"></span>
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span class="col-name" @click="node.is_folder ? navigateToFolder(node.id) : openPage(node.id)" x-text="node.name"></span>
</td>
@@ -864,7 +864,7 @@
</tr>
<template x-for="node in displayTree" :key="'l'+node.id">
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer">
<td><span x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
<td><span x-init="bindFileIcon($el, node)"></span>
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span></td>
<td class="col-name" x-text="node.name"></td>
<td style="font-size:12px;color:var(--text-tertiary)" x-text="node.is_folder ? 'Folder' : _fileTypeLabel(node.name, node.content_format)"></td>
@@ -902,7 +902,7 @@
<template x-for="node in displayTree" :key="'d'+node.id">
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer"
:class="{ 'selected-row': previewItem && previewItem.id === node.id }">
<td><span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
<td><span class="table-icon" x-init="bindFileIcon($el, node)"></span>
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span></td>
<td class="col-name" x-text="node.name"></td>
<td class="col-path" x-text="node._path || '—'"></td>
@@ -933,7 +933,7 @@
<template x-for="node in displayTree" :key="'g'+node.id">
<div class="title-card" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()"
:class="{ 'selected-card': previewItem && previewItem.id === node.id }">
<div class="title-card-icon" x-html="node.is_folder ? getSvgIcon('folder',24) : _fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></div>
<div class="title-card-icon" x-init="bindNodeIcon($el, node)"></div>
<div class="title-card-name">
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span x-text="node.name"></span>
@@ -960,7 +960,7 @@
<div class="content-item" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()"
:class="{ 'selected-row': previewItem && previewItem.id === node.id }">
<div class="content-item-header">
<span x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
<span x-init="bindFileIcon($el, node)"></span>
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span class="col-name" x-text="node.name"></span>
<span style="font-size:11px;color:var(--text-tertiary);margin-left:auto" x-text="_formatDate(node.updated_at)"></span>
@@ -1023,7 +1023,7 @@
<h3><span x-show="createType==='folder'">{{ fd_icon('folder',16) }}</span><span x-show="createType!='folder'">{{ fd_icon('file',16) }}</span> <span x-text="createType==='folder'?'New Folder':'New File'"></span></h3>
<p class="modal-sub">
In <strong>{{ workspace_name }}</strong> <span x-show="folderStack.length>1">/ <span x-text="(folderStack[folderStack.length-1]||{}).name||''"></span></span>
<span x-show="parentFolder"> / <span x-text="parentFolder?.name"></span></span>
<span x-show="parentFolder"> / <span x-text="parentFolder ? parentFolder.name : ''"></span></span>
</p>
<input class="modal-input" x-model="newName" :placeholder="createType==='folder'?'Folder name':'File name'" @keydown.enter="doCreate" x-ref="cinp">
<div class="modal-actions">
@@ -1037,7 +1037,7 @@
<div class="modal-overlay" x-show="showRename" @click.outside="showRename=false" @keydown.escape="showRename=false" style="display:none">
<div class="modal-box">
<h3>{{ fd_icon("edit",14) }} Rename</h3>
<p class="modal-sub">Rename <strong x-text="target?.name"></strong></p>
<p class="modal-sub">Rename <strong x-text="target ? target.name : ''"></strong></p>
<input class="modal-input" x-model="newName" @keydown.enter="doRename" x-ref="rinp">
<div class="modal-actions">
<button class="btn btn-secondary" @click="showRename=false">Cancel</button>
@@ -1050,7 +1050,7 @@
<div class="modal-overlay" x-show="showDelete" @click.outside="showDelete=false" @keydown.escape="showDelete=false" style="display:none">
<div class="modal-box">
<h3>{{ fd_icon("trash",16) }} Delete</h3>
<p class="modal-sub">Delete <strong x-text="target?.name"></strong>?</p>
<p class="modal-sub">Delete <strong x-text="target ? target.name : ''"></strong>?</p>
<div class="delete-confirm">This cannot be undone. Children will also be deleted.</div>
<div class="modal-actions">
<button class="btn btn-secondary" @click="showDelete=false">Cancel</button>
@@ -1065,11 +1065,14 @@
{% include "_ctx_menu.html" %}
<!-- Preview tooltip -->
<div class="file-preview" x-show="previewVisible"
<!-- ponytail: le parseur referme la div racine avant ce bloc (structure
pré-existante, masquée par le fallback window d'Alpine standard) →
wsPreview = wrapper déléguant vers _wsInitData (voir local_workspace.js) -->
<div class="file-preview" x-data="wsPreview()" x-show="previewVisible"
:style="{ left: previewX + 'px', top: previewY + 'px' }"
@mouseenter="previewVisible = true" @mouseleave="previewVisible = false">
<div class="file-preview-header" x-text="previewName"></div>
<div class="file-preview-body" x-html="previewContent"></div>
<div class="file-preview-body" x-init="bindPreview($el)"></div>
</div>
<!-- Preview Panel — identical to Library peek-overlay -->
+1 -1
View File
@@ -3,7 +3,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% set page_icon = "file" if page.content_format != 'file' else "paperclip" %}
{% set page_title = page.title %}
{% set nav_page_id = page.id %}
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn" @click="window.E && window.E.toggleComments()" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="window.E && window.E.commentCount>0 ? window.E.commentCount : \'\'"></span></button><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
{% set right_actions %}<span class="topbar-edited" style="cursor:pointer;" @click="edCall('toggleActivityOpen')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn" @click="edCall('toggleComments')" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="edCommentCount()"></span></button><button class="topbar-btn share-btn" @click="edCall('toggleShareOpen')"><span x-show="!edShared()">{{ fd_icon("lock",14) }} Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall('copyPageLink')" title="Copy link">{{ fd_icon("link",14) }}</button><button class="topbar-btn star-btn" @click="edCall('toggleFavorite')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall('toggleMoreOpen')">⋯</button>{% endset %}
{% include '_header.html' %}
{% endblock %} {% block content %}
{% include "_page_editor_content.html" %}
+1 -1
View File
@@ -2,7 +2,7 @@
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% set page_icon = "file" %}
{% set page_title = page.title %}
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
{% set right_actions %}<span class="topbar-edited" style="cursor:pointer;" @click="edCall('toggleActivityOpen')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn share-btn" @click="edCall('toggleShareOpen')"><span x-show="!edShared()">{{ fd_icon("lock",14) }} Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall('copyPageLink')" title="Copy link">{{ fd_icon("link",14) }}</button><button class="topbar-btn star-btn" @click="edCall('toggleFavorite')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall('toggleMoreOpen')">⋯</button>{% endset %}
{% include '_header.html' %}
{% endblock %} {% block content %}
{% include "_database_table.html" %}
+146 -20
View File
@@ -127,7 +127,7 @@
@media (max-width:760px){.llm-layout{grid-template-columns:1fr;}.llm-list{max-height:220px;}}
</style>
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="window.history.back()">
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="historyBack()">
<div class="settings-panel" style="position:relative;">
<button class="settings-close" @click="closeSettings()" title="Close">×</button>
@@ -136,24 +136,24 @@
<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="activeSection='api-tokens'; loadApiTokens()"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div>
<div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="activeSection='sessions'; loadSessions()"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="activeSection='extensions'; loadClipperDevices()"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</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>
<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="activeSection='automations'; loadAutomations()">{{ fd_icon("zap",14) }} Automations</div>
<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="navTo('automations')">{{ fd_icon("zap",14) }} Automations</div>
<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">
<div>
<div class="settings-nav-header">Admin</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">{{ fd_icon("users",14) }} Users &amp; Roles</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="activeSection='admin-audit'; loadAdminAudit()">{{ fd_icon("file-text",14) }} Audit Log</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-backups' }" @click="activeSection='admin-backups'; loadBackups()">{{ fd_icon("download",14) }} Backups</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-sso' }" @click="activeSection='admin-sso'; loadSsoConfig()">{{ fd_icon("lock",14) }} SSO / Enterprise</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="navTo('admin-users')">{{ fd_icon("users",14) }} Users &amp; Roles</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="navTo('admin-audit')">{{ fd_icon("file-text",14) }} Audit Log</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-backups' }" @click="navTo('admin-backups')">{{ fd_icon("download",14) }} Backups</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-sso' }" @click="navTo('admin-sso')">{{ fd_icon("lock",14) }} SSO / Enterprise</div>
</div>
</template>
</div>
@@ -515,9 +515,9 @@
<div class="modal-box" style="max-width:360px;">
<h3 style="margin:0 0 8px;">Delete tag</h3>
<p style="color:var(--text-dim);margin:0 0 16px;">
Are you sure you want to delete the tag "<strong x-text="deletingTag?.name"></strong>"?
<span x-show="deletingTag?.count > 0" style="color:var(--danger);display:block;margin-top:4px;">
{{ fd_icon('alert-triangle',14) }} This tag is used on <span x-text="deletingTag?.count"></span> item(s).
Are you sure you want to delete the tag "<strong x-text="deletingTag ? deletingTag.name : ''"></strong>"?
<span x-show="deletingTag && deletingTag.count > 0" style="color:var(--danger);display:block;margin-top:4px;">
{{ fd_icon('alert-triangle',14) }} This tag is used on <span x-text="deletingTag ? deletingTag.count : 0"></span> item(s).
</span>
</p>
<div style="display:flex;gap:8px;justify-content:flex-end;">
@@ -531,7 +531,7 @@
<div class="modal-box" style="max-width:360px;">
<h3 style="margin:0 0 12px;">Rename tag</h3>
<div style="display:flex;gap:8px;align-items:center;">
<div class="tag-color-dot" :style="{background: renamingTag?.color}" style="width:16px;height:16px;"></div>
<div class="tag-color-dot" :style="{background: renamingTag ? renamingTag.color : ''}" style="width:16px;height:16px;"></div>
<input type="text" class="settings-input" x-model="renameValue"
@keydown.enter="confirmRenameTag()" @keydown.escape="renamingTag=null"
style="flex:1;" autofocus>
@@ -635,16 +635,142 @@
</div>
</div>
</div>
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;">
<div class="setting-row" x-show="!editSteps.length" style="flex-direction:column;align-items:stretch;gap:6px;">
<div class="setting-label">Conditions (JSON — toutes AND)</div>
<textarea class="settings-input" style="width:100%;min-height:60px;font-family:monospace;font-size:12px;" x-model="af.condition_json" placeholder='[{"property":"Status","op":"eq","value":"Done"}]'></textarea>
<div class="setting-desc">Ops : eq, neq, contains, not_contains, is_empty, is_not_empty, changed</div>
</div>
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;">
<div class="setting-row" x-show="!editSteps.length" style="flex-direction:column;align-items:stretch;gap:6px;">
<div class="setting-label">Actions (JSON)</div>
<textarea class="settings-input" style="width:100%;min-height:80px;font-family:monospace;font-size:12px;" x-model="af.actions_json" placeholder='[{"type":"webhook","url":"https://..."}, {"type":"set_property","property":"Status","value":"Done"}, {"type":"create_page","collection_id":1,"title":"New task"}, {"type":"notify","message":"Automation fired"}]'></textarea>
<div class="setting-desc">Types : webhook, set_property, create_page, notify</div>
</div>
<!-- Pipeline visuel (steps API v7.0) -->
<div class="setting-row" x-show="editAutomationId" style="flex-direction:column;align-items:stretch;gap:6px;">
<div class="setting-label">Pipeline visuel (steps)</div>
<div class="setting-desc" x-show="!editSteps.length">Aucun step : automation en mode legacy (JSON ci-dessus). Ajoutez une étape (ou convertissez le JSON) pour basculer en pipeline.</div>
<template x-for="(s, i) in editSteps" :key="s.id">
<div style="border:1px solid var(--border);border-radius:6px;padding:8px 10px;margin-bottom:6px;">
<div style="display:flex;gap:8px;align-items:center;">
<span class="setting-label" style="font-weight:500;" x-text="(i+1) + '. ' + stepSummary(s)"></span>
<span style="flex:1"></span>
<button class="btn-sm" title="Monter" @click="moveStep(i, -1)">↑</button>
<button class="btn-sm" title="Descendre" @click="moveStep(i, 1)">↓</button>
<button class="btn-sm" @click="stepEdit = stepEdit===i ? -1 : i" x-text="stepEdit===i ? 'Fermer' : 'Modifier'"></button>
<button class="btn-sm" style="color:#e5534b;" title="Supprimer" @click="delStep(i)">✕</button>
</div>
<div x-show="stepEdit===i" style="margin-top:8px;display:flex;flex-direction:column;gap:6px;">
<template x-if="s.kind==='trigger'">
<div>
<div class="setting-label">Événement</div>
<input class="settings-input" style="width:100%;" x-model="s.config.event" list="auto-events">
<datalist id="auto-events">
<option value="page.created"></option><option value="page.updated"></option><option value="page.deleted"></option>
<option value="collection.created"></option><option value="collection.updated"></option><option value="collection.deleted"></option>
<option value="form.submitted"></option><option value="meeting.summarized"></option><option value="site.viewed"></option>
</datalist>
</div>
</template>
<template x-if="s.kind==='condition'">
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<input class="settings-input" placeholder="Propriété" x-model="s.config.property">
<select class="settings-input" x-model="s.config.op">
<option value="eq">=</option><option value="neq">≠</option>
<option value="contains">contient</option><option value="not_contains">ne contient pas</option>
<option value="is_empty">vide</option><option value="is_not_empty">non vide</option>
<option value="changed">changé</option>
</select>
<input class="settings-input" placeholder="Valeur" x-model="s.config.value">
</div>
</template>
<template x-if="s.kind==='delay'">
<div>
<div class="setting-label">Secondes (0-86400 · exécution plafonnée à 300 s)</div>
<input class="settings-input" type="number" min="0" max="86400" x-model.number="s.config.seconds">
</div>
</template>
<template x-if="s.kind==='action'">
<div style="display:flex;flex-direction:column;gap:6px;">
<select class="settings-input" x-model="s.config.type">
<option value="webhook">Webhook</option>
<option value="set_property">Définir une propriété</option>
<option value="create_page">Créer une page</option>
<option value="notify">Notification</option>
<option value="slack">Slack</option>
<option value="email">Email</option>
<option value="forge_issue">Issue Gitea/GitHub</option>
<option value="agent_trigger">Déclencher un agent</option>
</select>
<template x-if="s.config.type==='webhook'">
<input class="settings-input" placeholder="https://…" x-model="s.config.url">
</template>
<template x-if="s.config.type==='set_property'">
<div style="display:flex;gap:8px;">
<input class="settings-input" placeholder="Propriété" x-model="s.config.property">
<input class="settings-input" placeholder="Valeur" x-model="s.config.value">
</div>
</template>
<template x-if="s.config.type==='create_page'">
<div style="display:flex;gap:8px;">
<select class="settings-input" x-model="s.config.collection_id">
<option value="">Base (optionnel)</option>
<template x-for="c in globalCollections" :key="c.id">
<option x-bind:value="c.id" x-text="c.icon + ' ' + c.name"></option>
</template>
</select>
<input class="settings-input" placeholder="Titre" x-model="s.config.title">
</div>
</template>
<template x-if="s.config.type==='notify'">
<input class="settings-input" placeholder="Message" x-model="s.config.message">
</template>
<template x-if="s.config.type==='slack'">
<div style="display:flex;gap:8px;">
<input class="settings-input" placeholder="Webhook URL (chiffrée)" x-model="s.config.webhook_url">
<input class="settings-input" placeholder="Texte" x-model="s.config.text">
</div>
</template>
<template x-if="s.config.type==='email'">
<div style="display:flex;gap:8px;flex-direction:column;">
<input class="settings-input" placeholder="À (to)" x-model="s.config.to">
<input class="settings-input" placeholder="Sujet" x-model="s.config.subject">
<textarea class="settings-input" style="min-height:56px;" placeholder="Corps" x-model="s.config.body"></textarea>
</div>
</template>
<template x-if="s.config.type==='forge_issue'">
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<input class="settings-input" placeholder="owner" x-model="s.config.owner">
<input class="settings-input" placeholder="repo" x-model="s.config.repo">
<input class="settings-input" placeholder="Titre" x-model="s.config.title">
<input class="settings-input" placeholder="Labels (virgules)" x-model="s.config.labels">
</div>
</template>
<template x-if="s.config.type==='agent_trigger'">
<div style="display:flex;gap:8px;">
<input class="settings-input" type="number" placeholder="Agent ID" x-model.number="s.config.agent_id">
<input class="settings-input" placeholder="Message" x-model="s.config.message">
</div>
</template>
</div>
</template>
<div><button class="btn-sm" @click="saveStep(s)">Enregistrer l'étape</button></div>
</div>
</div>
</template>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<select class="settings-input" style="min-width:150px;" x-model="stepNewKind">
<option value="trigger">Déclencheur</option>
<option value="condition">Condition</option>
<option value="delay">Attente</option>
<option value="action">Action</option>
</select>
<button class="btn-sm" @click="addStep()">+ Ajouter l'étape</button>
<button class="btn-sm" style="color:var(--accent,#2383e2);" x-show="!editSteps.length && editAutomationId"
@click="convertToSteps()">✨ Convertir le JSON en pipeline</button>
</div>
</div>
<div style="display:flex;gap:8px;margin-top:8px;">
<button class="btn-sm" @click="saveAutomation()" x-text="editAutomationId ? 'Enregistrer' : 'Créer'"></button>
<button class="btn-sm" style="color:var(--text-secondary);" x-show="editAutomationId" @click="cancelEditAutomation()">Annuler</button>
@@ -770,11 +896,11 @@
<td x-text="u.folder_count" style="text-align:center;"></td>
<td x-text="u.total_mb + ' MB'" style="text-align:right;"></td>
<td>
<span style="font-size:11px;color:var(--text-dim);" x-text="u.last_login ? new Date(u.last_login*1000).toLocaleDateString() : 'Never'"></span>
<span style="font-size:11px;color:var(--text-dim);" x-text="fmtLastLogin(u)"></span>
</td>
<td>
<div style="display:flex;gap:4px;">
<button class="btn-sm" @click="editingUser=u; editUserForm={login:u.login,name:u.full_name,email:u.email,is_admin:u.is_admin,is_active:u.is_active}" title="Edit">{{ fd_icon("edit",14) }}</button>
<button class="btn-sm" @click="editUserRow(u)" title="Edit">{{ fd_icon("edit",14) }}</button>
<button class="btn-sm btn-sm-danger" @click="adminDeleteUser(u.id)" title="Delete" x-show="adminUsers.length > 1">{{ fd_icon("trash",14) }}</button>
</div>
</td>
@@ -813,7 +939,7 @@
<p class="section-desc">Actions API, changements de permissions et connexions SSO (unifiés).</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;align-items:center;">
<template x-for="s in ['all','api','permissions','sso']" :key="s">
<button class="btn" :class="{ 'btn-primary': auditSource===s }" style="font-size:12px;" @click="auditSource=s; loadAuditLogs()" x-text="s"></button>
<button class="btn" :class="{ 'btn-primary': auditSource===s }" style="font-size:12px;" @click="setAuditSource(s)" x-text="s"></button>
</template>
<input type="text" placeholder="actor (user id)" x-model="auditActor" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
<input type="text" placeholder="action (LIKE)" x-model="auditAction" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
@@ -837,7 +963,7 @@
<tbody>
<template x-for="e in auditLogs" :key="e.at + '-' + e.source + '-' + e.actor">
<tr>
<td><span style="font-size:12px;" x-text="new Date(e.at).toLocaleString()"></span></td>
<td><span style="font-size:12px;" x-text="fmtAuditDate(e)"></span></td>
<td><span style="font-size:11px;" :class="'audit-src audit-src-'+e.source" x-text="e.source"></span></td>
<td><code style="font-size:11px;" x-text="e.actor"></code></td>
<td><code style="font-size:11px;" x-text="e.action"></code></td>
@@ -853,7 +979,7 @@
</table>
</div>
<div style="display:flex;justify-content:center;margin-top:12px;">
<button class="btn" style="font-size:12px;" x-show="auditHasMore" @click="auditOffset+=auditPageSize; loadAuditLogs(false)">Load more</button>
<button class="btn" style="font-size:12px;" x-show="auditHasMore" @click="auditNext()">Load more</button>
</div>
<style>
.audit-src{display:inline-block;padding:1px 7px;border-radius:8px;font-weight:600;}
+6
View File
@@ -100,6 +100,12 @@
</div>
<script nonce="{{ csp_nonce() }}">
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: tableView »).
if (window.Alpine) { Alpine.data('tableView', tableView); }
else document.addEventListener('alpine:init', function () { Alpine.data('tableView', tableView); });
function tableView() {
return {
sortField: '',
+6
View File
@@ -44,6 +44,12 @@
</div>
<script nonce="{{ csp_nonce() }}">
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: teamLoad »).
if (window.Alpine) { Alpine.data('teamLoad', teamLoad); }
else document.addEventListener('alpine:init', function () { Alpine.data('teamLoad', teamLoad); });
function teamLoad() {
return {};
}
+10 -4
View File
@@ -63,13 +63,19 @@
{% block scripts %}
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: trashData »).
if (window.Alpine) { Alpine.data('trashData', trashData); }
else document.addEventListener('alpine:init', function () { Alpine.data('trashData', trashData); });
function trashData() {
return {
search: '',
items: [],
async init() {
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
const token = csrf ? csrf[1] : '';
const csrf = getCsrf();;
const token = csrf;
try {
const r = await fetch('/board/api/trash', { headers: { 'X-CSRF-Token': token } });
this.items = await r.json();
@@ -80,13 +86,13 @@
return this.items.filter(i => !q || i.name.toLowerCase().includes(q) || (i.path||'').toLowerCase().includes(q));
},
async restore(id) {
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
const csrf = getCsrf();;
const r = await fetch(`/board/api/trash/${id}/restore`, { method: 'POST', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
},
async deleteForever(id) {
if (!confirm('Permanently delete this page? This cannot be undone.')) return;
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
const csrf = getCsrf();;
const r = await fetch(`/board/api/trash/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
}
+7 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bienvenue sur FlowDeck</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
<style>
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;}
*{margin:0;padding:0;box-sizing:border-box;}
@@ -134,6 +134,12 @@ h1{font-size:22px;font-weight:700;margin-bottom:6px;}
</div>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: onboarding »).
if (window.Alpine) { Alpine.data('onboarding', onboarding); }
else document.addEventListener('alpine:init', function () { Alpine.data('onboarding', onboarding); });
function onboarding() {
return {
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
+9 -3
View File
@@ -6,7 +6,7 @@
{% block topbar %}
{% set page_icon = "home" %}
{% set page_title = "Workspace — Projects" %}
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">' ~ fd_icon("key",16) ~ '</a><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
{% set right_actions %}<a href="/auth/login?provider=local" class="topbar-btn" title="Login">{{ fd_icon("key",16) }}</a><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
{% include '_header.html' %}
{% endblock %}
@@ -62,7 +62,7 @@
<template x-for="p in builtinProjects" :key="p.id">
<div class="project-card" @click="openProject(p)">
<div class="project-card-top">
<span class="project-card-icon" x-html="getSvgIcon(p.icon || 'folder',20)"></span>
<span class="project-card-icon" x-init="bindProjectIcon($el, p)"></span>
<span class="forge-badge builtin">Built-in</span>
</div>
<div class="project-card-name" x-text="p.name"></div>
@@ -141,6 +141,12 @@
</div>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: workspacePage »).
if (window.Alpine) { Alpine.data('workspacePage', workspacePage); }
else document.addEventListener('alpine:init', function () { Alpine.data('workspacePage', workspacePage); });
function workspacePage() {
return {
builtinProjects: [],
@@ -174,7 +180,7 @@ function workspacePage() {
if (!this.newProjectName.trim()) return;
const r = await fetch('/api/workspace/projects', {
method: 'POST',
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'},
body: JSON.stringify({name: this.newProjectName.trim()})
});
if (r.ok) {
+3 -3
View File
@@ -6,7 +6,7 @@
{% block topbar %}
{% set breadcrumb_items = [{"label": "Workspaces", "url": None}] %}
{% set page_icon = "home" %}
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">' ~ fd_icon("key",16) ~ '</a><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
{% set right_actions %}<a href="/auth/login?provider=local" class="topbar-btn" title="Login">{{ fd_icon("key",16) }}</a><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
{% include '_header.html' %}
{% endblock %}
@@ -159,12 +159,12 @@
</div>
<!-- Create/Rename dialog -->
<div class="dialog-overlay" x-show="showCreate||showRename" @click.outside="showCreate=false;showRename=false">
<div class="dialog-overlay" x-show="showCreate||showRename" @click.outside="closeCreateRename()">
<div class="dialog-box">
<h3 x-text="showRename?'Rename Workspace':'New Workspace'"></h3>
<input class="dialog-input" x-model="wsName" placeholder="Workspace name" @keydown.enter="showRename?doRename():doCreate()">
<div class="dialog-actions">
<button class="btn btn-secondary" @click="showCreate=false;showRename=false">Cancel</button>
<button class="btn btn-secondary" @click="closeCreateRename()">Cancel</button>
<button class="btn btn-primary" @click="showRename?doRename():doCreate()" x-text="showRename?'Rename':'Create'"></button>
</div>
</div>
+7
View File
@@ -16,6 +16,12 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape
# dans ce cas, donc rien n'est bloqué).
CSP_NONCE: ContextVar[str] = ContextVar("csp_nonce", default="")
# A43 : jeton CSRF rendu côté serveur dans `hx-headers` (base.html) — posé
# par le middleware CSRF AVANT call_next, lu via `{{ csrf_token() }}`
# (vide = cookie absent sur cette requête, htmx:configRequest re-lit le
# cookie au moment de l'appel → jamais de « __CSRF_PLACEHOLDER__ » servi).
CSRF_TOKEN: ContextVar[str] = ContextVar("csrf_token", default="")
ENV = Environment(
loader=FileSystemLoader("app/templates"),
autoescape=select_autoescape(["html"]),
@@ -33,3 +39,4 @@ 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()
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.32.0"
"version": "7.45.4"
},
"paths": {
"/auth/register": {
+40
View File
@@ -0,0 +1,40 @@
// CSP build : x-data="foo()" — globale window vs Alpine.data, lequel résout ?
const { chromium } = require('playwright-core');
const path = require('path');
const fs = require('fs');
function findChromium() {
const root = path.join(process.env.LOCALAPPDATA, 'ms-playwright');
const dirs = fs.readdirSync(root).filter((d) => d.startsWith('chromium-') && !d.includes('headless'));
dirs.sort();
return path.join(root, dirs[dirs.length - 1], 'chrome-win64', 'chrome.exe');
}
const ALPINE = fs.readFileSync(
'C:/Users/bruno/AppData/Local/hermes/cache/scratch/alpine_csp.js',
'utf-8'
);
const html = `<!DOCTYPE html><html><body>
<div id="a" x-data="composantGlobal()" x-init="init()"><span id="s1" x-text="v"></span></div>
<div id="b" x-data="composantAlpineData()" x-init="init()"><span id="s2" x-text="v"></span></div>
<script>${ALPINE.replace(/<\/script>/g, '<\\/script>')}</script>
<script>
function composantGlobal() { return { v: '?', init() { this.v = 'glok'; } }; }
document.addEventListener('alpine:init', () => {
Alpine.data('composantAlpineData', () => ({ v: '?', init() { this.v = 'adok'; } }));
});
</script></body></html>`;
(async () => {
const browser = await chromium.launch({ headless: true, executablePath: findChromium() });
const page = await browser.newPage();
const errs = [];
page.on('pageerror', (e) => errs.push(e.message.slice(0, 120)));
await page.setContent(html, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(500);
const res = await page.evaluate(() => ({
global: document.querySelector('#s1').textContent,
alpineData: document.querySelector('#s2').textContent,
}));
console.log('RESULTATS:', JSON.stringify(res), 'ERREURS:', errs.length ? errs : 'aucune');
await browser.close();
})();
+237
View File
@@ -0,0 +1,237 @@
const { test, expect } = require('@playwright/test');
/**
* Aperçu CSP strict (A20 phase 3) : charge la page avec le build CSP
* d'Alpine (fichier officiel `@alpinejs/csp`, 0 eval) servi à la place de
* alpine.min.js via interception — SANS déployer. Toute expression que le
* parseur maison ne digère pas = pageerror « CSP Parser Error » (filet) ;
* les x-html restants = directive interdite du build (console error).
* Quand toutes les surfaces passent ici → bascule réelle + retrait
* d'unsafe-eval (ROADMAP A20 phase 3).
*/
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
test.use({ serviceWorkers: 'block' });
const errors = [];
let currentUrl = '';
test.beforeEach(async ({ page }) => {
errors.length = 0;
currentUrl = '';
await page.route('**/static/js/alpine.min.js', (route) =>
route.fulfill({
path: require('path').join(__dirname, 'fixtures', 'alpine.csp.js'),
contentType: 'application/javascript',
})
);
page.on('console', (m) => {
if (m.type() !== 'error') return;
if (/Failed to load resource/.test(m.text())) return;
errors.push(m.text());
});
page.on('pageerror', (e) =>
errors.push('pageerror@' + (currentUrl || '?') + ': ' + e.message)
);
});
test.afterEach(() => expect(errors).toEqual([]));
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: 10000 })
.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) throw new Error('compte e2e existant — FD_USER/FD_PASS incorrects');
if (!resp.ok()) throw new Error(`register ${resp.status()}: ${await resp.text()}`);
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForURL('**/workspaces', { timeout: 15000 });
}
}
async function assertBound(page) {
return page.evaluate(() => {
const el = document.querySelector('[x-data]');
if (!el || !window.Alpine) return 'absent';
try {
const d = window.Alpine.$data(el);
return d && typeof d === 'object' ? 'ok' : 'vide';
} catch (e) {
return 'throw:' + e.message;
}
});
}
test('A20-ph3 : surfaces simples sous build CSP (welcome/trash/accounts/workspace)', async ({ page }) => {
// scan statique = 0 expression/x-html sur ces gabarits → ici on traque
// les échecs RUNTIME (globales, timing de registre, scope de structure)
// /welcome est anonyme (avant login aussi) mais login() ne gêne pas
await login(page);
for (const url of ['/welcome', '/trash', '/accounts', '/workspace', '/import',
'/gitea-workspace']) {
currentUrl = url;
await page.goto(FD_BASE + url, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(500);
expect(await assertBound(page), `x-data non lié sur ${url}`).toBe('ok');
}
// panneau agent (composant de base, x-html markdown migré via bindMarkdown)
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(400);
const agent = await page.evaluate(() => {
const el = document.querySelector('#fd-agent-panel');
if (!el || !window.Alpine) return 'absent';
try {
const d = window.Alpine.$data(el);
return d && typeof d === 'object' ? 'ok' : 'vide';
} catch (e) {
return 'throw:' + e.message;
}
});
expect(agent).toBe('ok');
});
test('A20-ph3 : éditeur de page (right_actions) sous build CSP', async ({ page }) => {
// le topbar vit dans le scope appState : les12 sites window.E ont été
// remplacés par edCall/edTimeAgo/edCommentCount/edShared/bindStar —
// toute expression non parsable = pageerror (filet).
await login(page);
const coll = await page.evaluate(async () => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
const r = await fetch('/db/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ name: 'e2e-csp-editor' }),
});
return r.json();
});
expect(coll.id, JSON.stringify(coll)).toBeTruthy();
try {
await page.goto(`${FD_BASE}/pages/${coll.id}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(600);
// le composant éditeur est lié
const editor = await page.evaluate(() => {
const el = document.querySelector('#page-editor, .page-editor, [x-data]');
const root = document.querySelector('.app-layout');
const d1 = root && window.Alpine ? window.Alpine.$data(root) : null;
const hasEd = d1 && typeof d1.edCall === 'function';
let ed = 'absent';
try {
const cand = Array.from(document.querySelectorAll('[x-data]'))
.map((e) => e.getAttribute('x-data'))
.filter((a) => a && a.startsWith('editorState'));
ed = cand.length ? 'ok' : 'aucun-editorState';
} catch (e) { ed = 'throw'; }
return { ed: ed, delegates: hasEd ? 'ok' : 'absent', el: !!el };
});
expect(editor.delegates).toBe('ok');
expect(editor.ed).toBe('ok');
// les boutons du topbar sont réellement servis (bug A10 « ~ + Markup »
// corrigé : block-set) ET leurs expressions passent le filet CSP
await expect(page.locator('.star-btn').first()).toBeAttached();
} finally {
await page.evaluate(async (id) => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
await fetch(`/db/api/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf } });
}, coll.id);
}
});
test('UI : éditeur visuel de steps automations (API v7.0)', async ({ page }) => {
// crée une automation, ouvre l'édition, ajoute une étape (POST /steps),
// vérifie la carte résumée — sous CSP réel (expressions du nouveau bloc)
await login(page);
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(500);
await page.click('.settings-nav-item:has-text("Automations")');
const name = 'e2e-steps-' + Date.now();
await page.fill('input[placeholder*="Notifier le statut"]', name);
await page.click('button:has-text("Créer")');
await page.waitForTimeout(700);
const row = page.locator('.setting-row', { hasText: name }).last();
await row.locator('button:has-text("✎")').click();
await page.waitForTimeout(700);
await expect(page.locator('text=Pipeline visuel (steps)')).toBeVisible();
await page.click('button:has-text("Ajouter l\'étape")');
await page.waitForFunction(
() => Array.from(document.querySelectorAll('.setting-label'))
.some((e) => (e.textContent || '').includes('Action · webhook')),
null,
{ timeout: 8000 }
);
// nettoyage API
await page.evaluate(async (n) => {
const d = await (await fetch('/workspace/automations')).json();
const a = (d.automations || []).find((x) => x.name === n);
if (a) {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
await fetch('/workspace/automations/' + a.id,
{ method: 'DELETE', headers: { 'X-CSRF-Token': csrf } });
await fetch('/workspace/automations/' + a.id + '/steps', { method: 'DELETE' }).catch(() => {});
}
}, name);
});
test('A20-ph3 : settings sous build Alpine CSP', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(600);
expect(await assertBound(page)).toBe('ok');
await expect(page.locator('.settings-overlay')).toBeVisible();
});
test('A20-ph3 : local workspace sous build Alpine CSP', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/local-workspace`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(600);
expect(await assertBound(page)).toBe('ok');
// recherche : toggleSearch() (méthode réelle) + focus Alpine.nextTick
await page.click('button.ws-icon-btn[title="Search"]');
await page.waitForTimeout(400);
const focused = await page.evaluate(
() => document.activeElement && document.activeElement.getAttribute('x-ref') === 'searchInput'
);
expect(focused).toBe(true);
// chips filtre : bindSvg() via x-init (x-html interdit en CSP) —
// x-init tourne même si la rangée est masquée (x-show=filterType)
const svg = await page.evaluate(
() => (document.querySelector('button.filter-chip[title="Folders"]') || {}).innerHTML || ''
);
expect(svg).toContain('<svg');
});
test('A20-ph3 : library sous build Alpine CSP', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(600);
// le composant est lié par le registre Alpine.data (scope CSP)
expect(await assertBound(page)).toBe('ok');
// icône du empty-state : x-html remplacé par x-init + Alpine.effect
await expect(page.locator('#lib-empty .empty-icon')).toBeVisible({ timeout: 8000 });
const svg = await page.evaluate(
() => document.querySelector('#lib-empty .empty-icon').innerHTML
);
expect(svg).toContain('<svg');
// bouton recherche : @click.stop → toggleSearch() (méthode réelle,
// Alpine.nextTick pour le focus) — l'expression inline arrow n'existe plus
await page.click('.lib-icon-btn[title="Search"]');
await expect(page.locator('#lib-search-input')).toBeVisible();
await page.waitForTimeout(300);
const focused = await page.evaluate(
() => document.activeElement && document.activeElement.id === 'lib-search-input'
);
expect(focused).toBe(true);
});
File diff suppressed because one or more lines are too long
+322
View File
@@ -0,0 +1,322 @@
/**
* Probe de fluidité de navigation partielle (fdLoad) — mesure brute, pas un test gate.
*
* Pour chaque navigation il enregistre :
* - les marques htmx (beforeRequest / beforeSwap / afterSwap / afterSettle)
* - la fenêtre x-ignore (zone non montée Alpine)
* - un échantillon par frame : nb d'éléments [x-show] ENCORE VISIBLES non montés
* (= le contenu « brut » qui se voit avant qu'Alpine ne le masque → les flashes
* de fenêtres) et nb d'éléments [x-cloak] encore masqués (= contenu qui pop)
* - les layout-shift (CLS) et leur source
*
* Lancement : npx playwright test probe_nav_perf --reporter=list
*/
const { test } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { 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' });
await page.waitForURL('**/workspaces', { timeout: 15000 });
}
}
// Instrumentation posée UNE fois dans la page (avant le clic).
const INSTALL = () => {
const P = (window.__probe = { t0: performance.now(), marks: [], frames: [], shifts: [], rawMax: 0, rawFrames: 0 });
const mark = (n) => P.marks.push([n, Math.round(performance.now() - P.t0)]);
['htmx:beforeRequest', 'htmx:beforeSwap', 'htmx:afterSwap', 'htmx:afterSettle'].forEach((ev) =>
document.addEventListener(ev, () => mark(ev))
);
// Fenêtre x-ignore sur .main-wrapper (le nœud change à chaque swap → on observe tout le doc)
new MutationObserver((muts) => {
for (const m of muts) {
if (m.type !== 'attributes' || m.attributeName !== 'x-ignore') continue;
const t = m.target;
if (!(t instanceof Element) || !t.classList.contains('main-wrapper')) continue;
mark(t.hasAttribute('x-ignore') ? 'x-ignore ON' : 'x-ignore OFF');
}
}).observe(document.documentElement, { attributes: true, attributeFilter: ['x-ignore'], subtree: true });
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
P.shifts.push({
v: +e.value.toFixed(4),
t: Math.round(e.startTime - P.t0),
sources: (e.sources || []).map((s) => {
const n = s.node;
return n ? `${n.nodeName}.${(n.className || '').toString().split(' ')[0]}` : '?';
}),
});
}
}).observe({ type: 'layout-shift', buffered: false });
// Échantillonnage par frame. « visible » = peint à l'écran : la zone peut
// porter opacity:0 (fix v7.45.3) → rects toujours là, mais rien n'est peint.
const zonePainted = (z) => z && parseFloat(getComputedStyle(z).opacity || '1') > 0;
const tick = () => {
let raw = 0, rawVisible = 0, cloak = 0;
const zone = document.querySelector('.main-wrapper');
const painted = zonePainted(zone);
if (zone) {
for (const el of zone.querySelectorAll('[x-show]')) {
if (el._x_marker) continue; // monté par Alpine
raw++;
if (painted && el.getClientRects().length) rawVisible++; // peint ET en flow
}
cloak = zone.querySelectorAll('[x-cloak]').length;
}
P.rawMax = Math.max(P.rawMax, rawVisible);
if (rawVisible) P.rawFrames++;
P.frames.push([Math.round(performance.now() - P.t0), raw, rawVisible, cloak, painted ? 1 : 0]);
if (performance.now() - P.t0 < 3000) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
return true;
};
test('probe fluidité nav', async ({ page }) => {
test.setTimeout(120000);
await login(page);
const NAVS = ['/library', '/settings', '/workspaces', '/trash', '/local-workspace'];
const results = [];
const SHOTS = require('path').join(__dirname, 'shots');
require('fs').mkdirSync(SHOTS, { recursive: true });
for (const dest of NAVS) {
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'load' });
await page.waitForTimeout(1200);
await page.evaluate(INSTALL);
await page.evaluate((p) => {
const a = document.createElement('a');
a.href = p;
a.textContent = 'probe';
document.body.appendChild(a);
a.click();
}, dest);
// Filet : si du contenu brut est peint, on capture pour voir CE qui flash
let shot = null;
const tEnd = Date.now() + 3200;
while (Date.now() < tEnd && !shot) {
const last = await page.evaluate(() => {
const f = window.__probe.frames;
return f.length ? f[f.length - 1] : null;
});
if (last && last[2] > 0) {
shot = `navflash_${dest.replace(/\W+/g, '')}.png`;
await page.screenshot({ path: require('path').join(SHOTS, shot) });
break;
}
await page.waitForTimeout(25);
}
const P = await page.evaluate(() => window.__probe);
// État final : la zone doit être révélée (opacity>0) ET montée Alpine,
// et .app-layout (x-cloak anti-FOUC) doit être retiré → pas de page blanche
const final = await page.evaluate(() => {
const z = document.querySelector('.main-wrapper');
const a = document.querySelector('.app-layout');
if (!z) return { opacity: 'NO-ZONE', unmounted: -1, xIgnore: null };
return {
opacity: getComputedStyle(z).opacity,
pointerEvents: getComputedStyle(z).pointerEvents,
unmounted: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length,
xIgnore: z.hasAttribute('x-ignore'),
layout: a ? { cloak: a.hasAttribute('x-cloak'), display: getComputedStyle(a).display } : 'ABSENT',
};
});
// Résumé : première et dernière frame où du contenu brut visible subsiste
const rawFrames = P.frames.filter((f) => f[2] > 0);
const results_summary = {
dest,
marks: Object.fromEntries(P.marks),
rawVisibleFrames: rawFrames.length,
rawFirst: rawFrames.length ? rawFrames[0] : null,
rawLast: rawFrames.length ? rawFrames[rawFrames.length - 1] : null,
rawMaxElements: P.rawMax,
hiddenFrames: P.frames.filter((f) => f[4] === 0).length,
cloakMax: Math.max(0, ...P.frames.map((f) => f[3])),
shifts: P.shifts,
final,
frameCount: P.frames.length,
};
results.push(results_summary);
console.log('NAV ' + dest + ' => ' + JSON.stringify(results_summary));
}
// GATE : aucun contenu brut peint + zone révélée et montée à la fin
const paintedRaw = results.filter((r) => r.rawVisibleFrames > 0);
const notRevealed = results.filter((r) => parseFloat(r.final.opacity) !== 1 || r.final.unmounted !== 0 || r.final.xIgnore
|| !r.final.layout || r.final.layout.cloak === true || r.final.layout.display === 'none');
console.log('SOMMAIRE ' + JSON.stringify(results.map((r) => ({ d: r.dest, raw: r.rawVisibleFrames, hidden: r.hiddenFrames, op: r.final.opacity, un: r.final.unmounted, shift: r.shifts.length }))));
if (paintedRaw.length) throw new Error('contenu brut peint: ' + paintedRaw.map((r) => `${r.dest} (${r.rawVisibleFrames} frames, max ${r.rawMaxElements})`).join(' ; '));
if (notRevealed.length) throw new Error('zone non révélée/montée: ' + JSON.stringify(notRevealed.map((r) => [r.dest, r.final])));
console.log('GATE OK — 0 frame de contenu brut, zone révélée et montée sur ' + results.length + ' navigations');
});
// Le geste réel du rapport : clic sur le bouton Home de la sidebar (et non un
// ancre synthétique) — on mesure ce qui peint et on garde une capture si flash.
test('probe bouton Home', async ({ page }) => {
test.setTimeout(120000);
const SHOTS = require('path').join(__dirname, 'shots');
require('fs').mkdirSync(SHOTS, { recursive: true });
await login(page);
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'load' });
await page.waitForTimeout(1200);
await page.evaluate(INSTALL);
const console_ = [];
page.on('console', (m) => { if (m.type() !== 'log') console_.push(m.type() + ' | ' + m.text().slice(0, 180).replace(/\s+/g, ' ')); });
page.on('pageerror', (e) => console_.push('PAGEERROR | ' + String(e).slice(0, 180)));
const before = await page.evaluate(() => ({ onLine: navigator.onLine, home: !!document.querySelector('.home-btn'), href: (document.querySelector('.home-btn') || {}).href }));
console.log('HOME avant clic ' + JSON.stringify(before));
await page.click('.home-btn');
let shot = null;
const tEnd = Date.now() + 4000;
while (Date.now() < tEnd && !shot) {
const last = await page.evaluate(() => {
const f = window.__probe.frames;
return f.length ? f[f.length - 1] : null;
});
if (last && last[2] > 0) {
shot = 'home_flash.png';
await page.screenshot({ path: require('path').join(SHOTS, shot) });
break;
}
await page.waitForTimeout(25);
}
await page.waitForTimeout(2000);
const P = await page.evaluate(() => window.__probe);
const rawFrames = P.frames.filter((f) => f[2] > 0);
const after = await page.evaluate(() => {
const b = document.querySelector('.offline-banner');
const z = document.querySelector('.main-wrapper');
return {
url: location.pathname + location.search,
onLine: navigator.onLine,
banner: b ? { cloak: b.hasAttribute('x-cloak'), display: getComputedStyle(b).display, mounted: !!b._x_marker } : 'absente',
zone: z ? { op: getComputedStyle(z).opacity, un: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length, xIgnore: z.hasAttribute('x-ignore') } : null,
mounts: typeof window.__fdLocalWorkspaceScriptsLoaded !== 'undefined' ? window.__fdLocalWorkspaceScriptsLoaded : 'n/a',
};
});
const res = { marks: Object.fromEntries(P.marks), rawVisibleFrames: rawFrames.length, rawFirst: rawFrames[0] || null, rawMax: P.rawMax, shot, shifts: P.shifts, console: console_, after };
console.log('HOME résultat => ' + JSON.stringify(res));
if (res.rawVisibleFrames > 0) throw new Error(`contenu brut peint au clic Home: ${res.rawVisibleFrames} frames (max ${res.rawMax})`);
console.log('GATE HOME OK — 0 frame de contenu brut');
});
// Scénario du rapport : la bande rouge dit « hors ligne » → on rejoue le clic
// Home AVEC le réseau coupé (SW actif) pour voir ce qui peint.
test('probe bouton Home hors ligne', async ({ page, context }) => {
test.setTimeout(120000);
const SHOTS = require('path').join(__dirname, 'shots');
require('fs').mkdirSync(SHOTS, { recursive: true });
await login(page);
await page.addInitScript(INSTALL);
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'load' });
await page.waitForTimeout(1500); // laisse le SW s'installer et cacher les pages
await page.evaluate(() => { window.__probe = null; window.__probe = undefined; });
await page.evaluate(INSTALL);
await context.setOffline(true);
await page.waitForTimeout(200);
const onLine = await page.evaluate(() => navigator.onLine);
await page.click('.home-btn');
const shots = [];
for (const [delay, name] of [[700, 't700'], [2500, 't2500']]) {
await page.waitForTimeout(delay === 700 ? 700 : 1800);
const p = `home_offline_${name}.png`;
await page.screenshot({ path: require('path').join(SHOTS, p) }).catch(() => {});
shots.push(p);
}
const P = await page.evaluate(() => window.__probe || null).catch(() => null);
const after = await page.evaluate(() => {
const b = document.querySelector('.offline-banner');
const z = document.querySelector('.main-wrapper');
const visible = (sel) => Array.from(document.querySelectorAll(sel)).filter((e) => e.getClientRects().length && getComputedStyle(e).display !== 'none').map((e) => (e.className || '').toString().split(' ')[0]).slice(0, 12);
return {
url: location.pathname + location.search,
onLine: navigator.onLine,
banner: b ? { display: getComputedStyle(b).display, mounted: !!b._x_marker } : 'absente',
zone: z ? { op: getComputedStyle(z).opacity, un: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length, xIgnore: z.hasAttribute('x-ignore') } : null,
overlays: visible('.modal-overlay, .ctx-menu, .context-menu, .user-menu-dropdown, .dropdown-menu, .popover'),
};
}).catch((e) => ({ err: String(e) }));
const rawFrames = P ? P.frames.filter((f) => f[2] > 0) : [];
const res = { onLineBefore: onLine, marks: P ? Object.fromEntries(P.marks) : null, rawVisibleFrames: rawFrames.length, rawFirst: rawFrames[0] || null, rawMax: P ? P.rawMax : null, shots, shifts: P ? P.shifts : null, after };
console.log('HOME OFFLINE => ' + JSON.stringify(res));
});
// Charge COMPLÈTE (F5 / premier visit / SW) : le masquage fdLoad ne s'applique
// pas là, seul x-cloak protège — on mesure ce qui peint avant Alpine.start().
test('probe fluidité plein chargement', async ({ page }) => {
test.setTimeout(120000);
const SHOTS = require('path').join(__dirname, 'shots');
require('fs').mkdirSync(SHOTS, { recursive: true });
await login(page);
await page.addInitScript(INSTALL);
const OUT = [];
for (const dest of ['/local-workspace', '/library', '/workspaces']) {
await page.goto(FD_BASE + dest, { waitUntil: 'commit' });
let shot = null;
const tEnd = Date.now() + 4000;
while (Date.now() < tEnd && !shot) {
const last = await page.evaluate(() => {
const f = window.__probe && window.__probe.frames;
return f && f.length ? f[f.length - 1] : null;
}).catch(() => null);
if (last && last[2] > 0) {
shot = `fullflash_${dest.replace(/\W+/g, '')}.png`;
await page.screenshot({ path: require('path').join(SHOTS, shot) });
break;
}
await page.waitForTimeout(25);
}
await page.waitForTimeout(1500);
const P = await page.evaluate(() => window.__probe);
const rawFrames = P.frames.filter((f) => f[2] > 0);
const res = {
dest,
rawVisibleFrames: rawFrames.length,
rawFirst: rawFrames[0] || null,
rawLast: rawFrames[rawFrames.length - 1] || null,
rawMax: P.rawMax,
shot,
shifts: P.shifts,
final: await page.evaluate(() => {
const z = document.querySelector('.main-wrapper');
const a = document.querySelector('.app-layout');
return z ? {
op: getComputedStyle(z).opacity,
un: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length,
layout: a ? { cloak: a.hasAttribute('x-cloak'), display: getComputedStyle(a).display } : 'ABSENT',
} : null;
}),
};
OUT.push(res);
console.log('FULL ' + dest + ' => ' + JSON.stringify(res));
}
console.log('SOMMAIRE_FULL ' + JSON.stringify(OUT.map((r) => [r.dest, r.rawVisibleFrames, r.rawMax, r.shot])));
const notRevealed = OUT.filter((r) => !r.final || parseFloat(r.final.op) !== 1 || r.final.un !== 0
|| !r.final.layout || r.final.layout.cloak === true || r.final.layout.display === 'none');
if (notRevealed.length) throw new Error('page blanche/non montée au chargement: ' + JSON.stringify(notRevealed.map((r) => [r.dest, r.final])));
if (OUT.some((r) => r.rawVisibleFrames > 0)) {
throw new Error('contenu brut peint au plein chargement: ' + OUT.filter((r) => r.rawVisibleFrames > 0).map((r) => `${r.dest} (${r.rawVisibleFrames} frames, max ${r.rawMax})`).join(' ; '));
}
console.log('GATE FULL OK — 0 frame de contenu brut au chargement complet');
});
+97
View File
@@ -0,0 +1,97 @@
/** Diag temporaire : navigation partielle vers l'éditeur, erreurs par phase */
const { test } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { 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' });
await page.waitForURL('**/workspaces', { timeout: 15000 });
}
}
function alpineErrs(msgs) {
return msgs.filter((m) => m.includes('Alpine Expression Error') || m.includes('PAGEERROR'));
}
async function partialClick(page, pid) {
await page.evaluate((id) => {
const a = document.createElement('a');
a.href = '/pages/' + id;
a.textContent = 'x';
document.body.appendChild(a);
a.click();
}, pid);
}
test('diag phases', async ({ page }) => {
test.setTimeout(90000);
const msgs = [];
page.on('console', (m) => msgs.push(m.type() + ' | ' + m.text().slice(0, 160).replace(/\s+/g, ' ')));
page.on('pageerror', (e) => msgs.push('PAGEERROR | ' + String(e).slice(0, 200)));
await login(page);
const pid = await page.evaluate(async () => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
const r = await fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ name: 'Diag2', type: 'page' }),
});
const d = await r.json();
return d.id || (d.item && d.item.id);
});
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(800);
msgs.length = 0;
// PHASE A : navigation partielle (clic intercepte par fdLoad)
await partialClick(page, pid);
await page.waitForTimeout(4000);
const stackA = await page.evaluate(() => {
const el = document.querySelector('[x-data="editorState()"]');
const st = el && el._x_dataStack;
return { stack: st ? st.map((o) => (o ? Object.keys(o).length : -1)) : 'none', refreshed: !!(el && el.__fdRefreshed) };
});
const trace = await page.evaluate(() => window.__fdTrace || []);
stackA.trace = trace;
console.log('PHASE A (partielle): alpineErrs=', alpineErrs(msgs).length, 'total=', msgs.length, JSON.stringify(stackA));
console.log('TRACE:', JSON.stringify(trace));
alpineErrs(msgs).slice(0, 6).forEach((m) => console.log(' A>', m));
msgs.filter((m) => !m.includes('Alpine Expression')).slice(0, 5).forEach((m) => console.log(' A-other>', m));
// A2 : les erreurs s'arretent-elles apres le montage (fenetre transitoire) ?
const a1 = alpineErrs(msgs).length;
await page.waitForTimeout(3000);
const a2 = alpineErrs(msgs).length;
const ui = await page.evaluate(() => ({
title: !!document.getElementById('_titleEl'),
blocks: document.querySelectorAll('[data-bid]').length,
editorVisible: !!document.querySelector('.page-editor-wrapper, .editor-wrap, [x-data^="editorState"]'),
}));
console.log('PHASE A2: erreurs a t0=', a1, '-> apres +3s=', a2, 'UI=', JSON.stringify(ui));
// PHASE B : rechargement complet de la meme page
msgs.length = 0;
await page.goto(`${FD_BASE}/pages/${pid}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
console.log('PHASE B (complet): alpineErrs=', alpineErrs(msgs).length, 'total=', msgs.length);
alpineErrs(msgs).slice(0, 6).forEach((m) => console.log(' B>', m));
// PHASE C : deuxieme navigation partielle vers la meme page
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(600);
msgs.length = 0;
await partialClick(page, pid);
await page.waitForTimeout(3500);
console.log('PHASE C (2e partielle): alpineErrs=', alpineErrs(msgs).length, 'total=', msgs.length);
alpineErrs(msgs).slice(0, 6).forEach((m) => console.log(' C>', m));
});
+108
View File
@@ -0,0 +1,108 @@
/**
* Régression — 2 bugs rapportés (post v7.45.0) :
* 1. Logout : le SW servait sa page « hors ligne » sur TOUTE navigation
* redirigée (fetch event en redirect:'manual' → opaqueredirect → rejet).
* 2. Drag & drop de fichiers : POST /api/local-workspace/upload sans
* X-CSRF-Token → 403 (A19 a retiré l'exemption sans équiper l'appel).
*
* Instance attendue sur FD_BASE_URL (défaut 8080), compte e2e documenté.
*/
const { test, expect } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { 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.ok() && resp.status() !== 409) {
throw new Error(`register ${resp.status()}: ${await resp.text()}`);
}
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForURL('**/workspaces', { timeout: 15000 });
}
// workspace actif requis par /api/local-workspace/upload (A22)
const ws = await page.evaluate(async () => (await fetch('/api/workspaces')).json());
if (!ws.workspaces || ws.workspaces.length === 0) {
await page.evaluate(async () => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
const r = await fetch('/api/workspaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ name: 'E2E workspace' }),
});
const w = await r.json();
await fetch(`/api/workspaces/${w.id}/select`, {
method: 'POST',
headers: { 'X-CSRF-Token': csrf },
});
});
}
}
test('logout avec SW : atterrit sur la page login, pas hors ligne', async ({ page }) => {
await login(page);
await page.evaluate(async () => {
if ('serviceWorker' in navigator) await navigator.serviceWorker.ready;
});
await page.waitForTimeout(1000);
expect(await page.evaluate(() => !!navigator.serviceWorker.controller)).toBe(true);
await page.goto(`${FD_BASE}/auth/logout`, { waitUntil: 'domcontentloaded' });
await page.waitForURL(/\/auth\/login/, { timeout: 10000 });
const body = await page.evaluate(() => document.body.innerText);
expect(body).not.toContain('hors ligne');
expect(page.url()).toContain('/auth/login');
});
test('drop de fichier : upload accepté (CSRF), 200 et item créé', async ({ page }) => {
test.setTimeout(60000);
await login(page);
await page.goto(`${FD_BASE}/local-workspace`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1000);
const uploadResp = [];
page.on('response', (r) => {
if (r.url().includes('/api/local-workspace/upload')) {
uploadResp.push(
r.text().then((body) => ({ status: r.status(), body })).catch(() => ({ status: r.status(), body: '' }))
);
}
});
await page.evaluate(() => {
const zone = document.querySelector('[x-data*="wsInitData"]') || document.body;
const dt = new DataTransfer();
dt.items.add(new File(['hello world'], 'e2e-drop.txt', { type: 'text/plain' }));
zone.dispatchEvent(new DragEvent('dragover', { dataTransfer: dt, bubbles: true, cancelable: true }));
zone.dispatchEvent(new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true }));
});
await expect.poll(() => uploadResp.length, { timeout: 15000 }).toBeGreaterThan(0);
const resp = await uploadResp[0];
expect(resp.status, resp.body).toBe(200);
// Nettoyage : l'upload crée une page fichier → on la supprime.
const parsed = JSON.parse(resp.body || '{}');
for (const item of parsed.items || []) {
if (!item.id) continue;
await page.evaluate(async (pid) => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
await fetch('/api/local-workspace/items/' + pid, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrf },
});
}, item.id);
}
});
+90
View File
@@ -0,0 +1,90 @@
/** Sweep diag : nav partielle vs complet sur N pages — erreurs + état fdCtx/appState */
const { test } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { 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' });
await page.waitForURL('**/workspaces', { timeout: 15000 });
}
}
const PAGES = ['/workspaces', '/library', '/local-workspace', '/settings', '/trash', '/accounts', '/import'];
test('sweep', async ({ page }) => {
test.setTimeout(180000);
const msgs = [];
page.on('console', (m) => msgs.push(m.type() + ' | ' + m.text().slice(0, 200).replace(/\s+/g, ' ')));
page.on('pageerror', (e) => msgs.push('PAGEERROR | ' + String(e).slice(0, 160)));
await login(page);
for (const path of PAGES) {
// baseline : chargement complet
msgs.length = 0;
await page.goto(FD_BASE + path, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2200);
const fullErrs = msgs.filter((m) => m.includes('PAGEERROR') || m.includes('Alpine Expression Error') || m.includes('Uncaught'));
const stFull = await page.evaluate(() => ({
fdCtx: (() => { try { return typeof Alpine.store('fdCtx'); } catch (e) { return 'ERR'; } })(),
fdCtxHas: (() => { try { var s = Alpine.store('fdCtx'); return s ? typeof s.has + '/' + typeof s.avail : 'no store'; } catch (e) { return 'ERR'; } })(),
appState: typeof window.appState,
}));
// nav partielle : depuis /workspaces
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(700);
msgs.length = 0;
await page.evaluate((p) => {
const a = document.createElement('a');
a.href = p;
a.textContent = 'x';
document.body.appendChild(a);
a.click();
}, path);
await page.waitForTimeout(2500);
const partErrs = msgs.filter((m) => m.includes('PAGEERROR') || m.includes('Alpine Expression Error') || m.includes('Uncaught'));
// 2e visite partielle dans LE MEME document (declencheur du SyntaxError
// « Identifier ... has already been declared » sur script sans garde)
await page.evaluate(() => {
const a = document.createElement('a');
a.href = '/workspaces';
a.textContent = 'x';
document.body.appendChild(a);
a.click();
});
await page.waitForTimeout(900);
msgs.length = 0;
await page.evaluate((p) => {
const a = document.createElement('a');
a.href = p;
a.textContent = 'x';
document.body.appendChild(a);
a.click();
}, path);
await page.waitForTimeout(2500);
const part2Errs = msgs.filter((m) => m.includes('PAGEERROR') || m.includes('Alpine Expression Error') || m.includes('Uncaught') || m.includes('already been declared'));
const stPart = await page.evaluate(() => ({
fdCtx: (() => { try { return typeof Alpine.store('fdCtx'); } catch (e) { return 'ERR'; } })(),
fdCtxHas: (() => { try { var s = Alpine.store('fdCtx'); return s ? typeof s.has + '/' + typeof s.avail : 'no store'; } catch (e) { return 'ERR'; } })(),
appState: typeof window.appState,
lwGuard: !!window.__fdLocalWorkspaceScriptsLoaded,
}));
console.log(`PAGE ${path}`);
console.log(` complet : errs=${fullErrs.length} ${JSON.stringify(stFull)}`);
fullErrs.slice(0, 2).forEach((m) => console.log(' F>', m));
console.log(` 1ere partiel : errs=${partErrs.length} ${JSON.stringify(stPart)}`);
partErrs.slice(0, 3).forEach((m) => console.log(' P>', m));
console.log(` 2e partiel : errs=${part2Errs.length}`);
part2Errs.slice(0, 3).forEach((m) => console.log(' P2>', m));
}
});
+186
View File
@@ -0,0 +1,186 @@
const { test, expect } = require('@playwright/test');
/**
* Smoke E2E — fondations vérifiant les portes des reports d'audit :
* - A39 : bascule de vues (création d'une vue Board depuis la barre de
* vues d'une collection → rendu de la grille)
* - A20 : Alpine + palette de commandes (Ctrl+K, recherche GET, fermeture)
* - filet : 0 erreur console (les violations CSP atterrissent ici)
*
* READ-ONLY sur les données existantes : crée puis SUPPRIME sa collection
* (répétable). Instance de test attendue sur FD_BASE_URL (défaut 8080).
*/
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
// Service Workers BLOQUÉS : /sw.js sert sa page « hors ligne » quand la
// réponse de navigation est une redirection (redirect:'manual' sur les
// requêtes navigate) — bruit PWA hors sujet ici (pwa_offline.spec.js
// couvre le SW). On interroge le serveur directement.
test.use({ serviceWorkers: 'block' });
const consoleErrors = [];
test.beforeEach(async ({ page }) => {
consoleErrors.length = 0;
page.on('console', (m) => {
if (m.type() !== 'error') return;
// les 401 de ressources (checks de session sur login) sont du bruit
// navigateur, pas une erreur JS/CSP — le reste compte
if (/Failed to load resource/.test(m.text())) return;
consoleErrors.push(m.text());
});
page.on('pageerror', (e) => consoleErrors.push('pageerror: ' + e.message));
});
test.afterEach(() => {
// Aucune erreur JS/CSP pendant le scénario
expect(consoleErrors).toEqual([]);
});
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 Promise.race([
page
.waitForURL('**/workspaces', { timeout: 8000 })
.then(() => true)
.catch(() => false),
]);
if (!ok) {
// Compte absent de l'instance de test → création (bootstrap du harness,
// pas un mot de passe deviné : c'est le compte e2e documenté du repo).
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
data: { email: USER, password: PASS, name: 'E2E' },
});
if (resp.status() === 409) {
throw new Error(
'compte e2e existant mais mot de passe refusé — définir FD_USER/FD_PASS'
);
}
if (!resp.ok()) {
throw new Error(`register ${resp.status()}: ${await resp.text()}`);
}
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForURL('**/workspaces', { timeout: 15000 });
}
// workspace requis pour créer une collection (compte neuf = aucun ws)
const ws = await page.evaluate(async () => (await fetch('/api/workspaces')).json());
if (!ws.workspaces || ws.workspaces.length === 0) {
await page.evaluate(async () => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
const r = await fetch('/api/workspaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ name: 'E2E workspace' }),
});
const w = await r.json();
await fetch(`/api/workspaces/${w.id}/select`, { method: 'POST' });
});
}
}
test('gate A39 : bascule de vues (table → Calendar, rendu par onglet)', async ({ page }) => {
await login(page);
// collection jetable (créée puis supprimée = répétable). /db/{id} est une
// page STANDALONE (hors base.html) : les onglets .view-tabs naviguent en
// ?view=… et le corps est rendu côté serveur par _render_view().
const coll = await page.evaluate(async () => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
const r = await fetch('/db/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ name: 'e2e-view-switch' }),
});
return r.json();
});
expect(coll.id, `création collection: ${JSON.stringify(coll)}`).toBeTruthy();
try {
await page.goto(`${FD_BASE}/db/${coll.id}`, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.view-tabs a.tab')).toHaveCount(11);
await expect(page.locator('.calendar')).toHaveCount(0); // vue table par défaut
// bascule réelle : clic sur l'onglet Calendar → navigation ?view=calendar
await page.click('.view-tabs a.tab:has-text("Calendar")');
await page.waitForURL(/view_type=calendar/, { timeout: 10000 });
await expect(page.locator('.view-tabs a.tab.active')).toContainText('Calendar');
// corps Calendar rendu par _render_calendar (grille 6×7)
await expect(page.locator('.calendar')).toHaveCount(1);
expect(await page.locator('.cal-header').count()).toBeGreaterThanOrEqual(7);
} finally {
await page.evaluate(async (id) => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
await fetch(`/db/api/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf } });
}, coll.id);
}
});
test('gate A20 : palette Ctrl+K (Alpine + recherche GET)', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(800);
await page.keyboard.press('Control+k');
await page.waitForTimeout(400);
// overlay ouvert (classe .open pilotée par l'IIFE de base.html)
const open = await page.evaluate(() => {
const ov = document.querySelector('#fd-command-palette');
return !!ov && ov.classList.contains('open');
});
expect(open).toBe(true);
// tape une requête → la recherche GET répond et rend des résultats
await page.keyboard.type('a');
await page.waitForTimeout(900);
const items = await page.evaluate(
() => document.querySelectorAll('.cmd-palette-item').length
);
expect(items).toBeGreaterThan(0);
// Alpine doit être lié (build CSP : le x-data + ses expressions évalués
// SANS eval) sur un composant réel de la page
const alpine = await page.evaluate(() => {
const el = document.querySelector('[x-data]');
if (!el || !window.Alpine) return 'absent';
try {
const data = window.Alpine.$data(el);
return data && typeof data === 'object' ? 'ok:' + Object.keys(data).slice(0, 3).join(',') : 'vide';
} catch (e) {
return 'throw:' + e.message;
}
});
expect(alpine).toMatch(/^ok:/);
// Échap ferme la palette
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
const closed = await page.evaluate(() => {
const ov = document.querySelector('#fd-command-palette');
return !ov || !ov.classList.contains('open');
});
expect(closed).toBe(true);
// onglet ✨ Réponses IA : POST /api/v2/search/ask (extractif hors-LM)
await page.keyboard.press('Control+k');
await page.waitForTimeout(300);
await page.click('.cp-tab[data-tab="ai"]');
await page.waitForSelector('.cmd-palette-ai', { timeout: 10000 });
await page.waitForFunction(
() => {
const el = document.querySelector('.cmd-palette-ai');
return el && !el.querySelector('.cp-loading');
},
null,
{ timeout: 15000 }
);
const ai = await page.evaluate(() => {
const el = document.querySelector('.cmd-palette-ai');
return { text: ((el && el.textContent) || '').trim() };
});
expect(ai.text.length).toBeGreaterThan(5);
});
+2
View File
@@ -40,6 +40,8 @@ const browserGlobals = {
Alpine: "readonly", htmx: "readonly", Sortable: "readonly",
// Globals exposed on window by app.js
openModal: "readonly", closeModal: "readonly",
// getCsrf : helper unique posé dans le <head> de base.html (A38 phase 1)
getCsrf: "readonly",
// getSvgIcon : script inline de base.html (nonce) ; Prism : CDN des vues ;
// TextDecoder : API navigateur (ES2015)
getSvgIcon: "readonly", Prism: "readonly", TextDecoder: "readonly",
+1 -1
View File
@@ -7,7 +7,7 @@ pythonpath = ["."]
[tool.ruff]
target-version = "py312"
target-version = "py313"
line-length = 110
exclude = [".venv", ".venv311", ".pytest_cache", "static/js/vendor"]
+22 -1
View File
@@ -3,7 +3,28 @@
Référence: Notion Light Mode (par défaut)
═══════════════════════════════════════════════════════════ */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
/* Inter auto-hébergé — remplace l'@import Google Fonts que la CSP bloque
(style-src sans fonts.googleapis depuis v7.27). Police variable v20 :
une face par sous-ensemble, font-weight 100-900. */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/static/fonts/inter-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/static/fonts/inter-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* ===== LIGHT THEME (default) ===== */
:root {
Binary file not shown.
Binary file not shown.
+4
View File
@@ -21,6 +21,10 @@
window.Alpine.__fdCtxStore = true;
Alpine.store('fdCtx', {
addTagAndClear(value, color, el){
this.addNewTag(value, color);
if (el) el.value = '';
},
open: false, x: 0, y: 0, node: null, page: null,
maxH: 0, _cx: 0, _cy: 0, _ro: null,
handlers: {},
+2 -2
View File
@@ -188,7 +188,7 @@
var fd = new FormData();
fd.append('name', this.customName || 'emoji');
fd.append('file', this.customFile);
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
var csrf = getCsrf();
var r = await fetch('/api/custom-emojis', { method: 'POST', headers: { 'X-CSRF-Token': csrf }, body: fd, credentials: 'same-origin' });
var d = await r.json();
if (d && d.emoji) {
@@ -203,7 +203,7 @@
},
async deleteCustom(id) {
try {
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
var csrf = getCsrf();
await fetch('/api/custom-emojis/' + id, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf }, credentials: 'same-origin' });
this.custom = this.custom.filter(function (e) { return e.id !== id; });
} catch { /* volontaire */ }
+13 -8
View File
@@ -97,7 +97,7 @@
var payload = {prompt: message};
if(context && context.trim()) payload.context = String(context).slice(0, 20000);
return fetch('/api/agent/generate', {
method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify(payload)
}).then(function(resp){
return resp.json();
@@ -243,7 +243,7 @@
if(self.llmModel) payload.model = self.llmModel;
fetch('/api/agent/generate', {
method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(payload)
method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, body: JSON.stringify(payload)
}).then(function(r){ return r.json(); }).then(function(d){
self.messages = self.messages.filter(function(m){ return m.id !== thinkId; });
if(d && d.ok && d.text){
@@ -265,6 +265,10 @@
});
}).catch(function(){});
},
// ── A20 ph3 : x-html (markdown) → x-init + effet réactif ──
bindMarkdown(el, m){
Alpine.effect(() => { el.innerHTML = this.renderMarkdown(m.content); });
},
approveProposal(m){
if(!m || m.applied || m.offline) return;
var ok = window.fdApplyDocument ? window.fdApplyDocument(m.content, m.mode) : false;
@@ -366,7 +370,7 @@
installGallerySkill(slug, icon, name){
var self = this;
fetch('/api/agent/skills/gallery/' + encodeURIComponent(slug) + '/install', {
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'}, body: '{}'
method: 'POST', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'}, body: '{}'
}).then(function(r){
return r.json().then(function(d){ return {ok: r.ok, d: d}; });
}).then(function(res){
@@ -390,6 +394,7 @@
this._insertToken({token:pin.token, label:pin.label, icon:pin.icon, kind:'skill'});
this.toast('Skill épinglé — décrivez votre demande, il sera appliqué à l\u2019envoi.');
},
showHistory(){ this.tab = 'history'; this.loadConversations(); },
loadConversations(){
var self = this;
fetch('/api/agent/conversations').then(function(r){return r.json()}).then(function(d){
@@ -530,7 +535,7 @@
var body = {title:'Nouvelle conversation'};
if(self.llmProvider) body.provider = self.llmProvider;
if(self.llmModel) body.model = self.llmModel;
fetch('/api/agent/conversations', {method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(body)})
fetch('/api/agent/conversations', {method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, body: JSON.stringify(body)})
.then(function(r){return r.json()}).then(function(d){
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
self.conversations.unshift(conv); self.currentConv = conv;
@@ -547,7 +552,7 @@
if(self.llmProvider) body.provider = self.llmProvider;
if(self.llmModel) body.model = self.llmModel;
self._ensuring = fetch('/api/agent/conversations', {
method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(body)
method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, body: JSON.stringify(body)
}).then(function(r){return r.json()}).then(function(d){
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
self.conversations.unshift(conv); self.currentConv = conv;
@@ -569,7 +574,7 @@
if(self.llmProvider) body.provider = self.llmProvider;
if(self.llmModel) body.model = self.llmModel;
fetch('/api/agent/conversations/'+self.currentConv.id, {
method:'PATCH', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(body)
method:'PATCH', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, body: JSON.stringify(body)
}).catch(function(){});
},
@@ -1370,7 +1375,7 @@
if(m.id && /^\d+$/.test(String(m.id))) payload.message_id = Number(m.id);
fetch('/api/agent/feedback', {
method: 'POST',
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'},
body: JSON.stringify(payload)
}).then(function(r){ return r.json(); }).then(function(d){
if(d && d.status === 'recorded'){ m.fb = rating; }
@@ -1468,7 +1473,7 @@
return fetch('/api/agent/conversations/'+self.currentConv.id+'/run', {
method:'POST',
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify(payload)
}).then(function(resp){
if(!resp.ok){ return resp.json().then(function(j){ throw new Error(j.detail || ('HTTP '+resp.status)); }); }
+23
View File
File diff suppressed because one or more lines are too long
+88 -2
View File
@@ -1,5 +1,6 @@
// FlowDeck Notion UI — Client-side logic
// Alpine.js + SortableJS + HTMX + Mobile support
/* exported openCardDetail */ /* global owner, repo */
// ── Frontend Error Capture ──────────────────────────────────
// Intercepte TOUTES les erreurs JS et les envoie au backend.
@@ -282,10 +283,8 @@
if (!window.htmx) { window.location.href = url; return; }
if (loading) return;
loading = true;
document.documentElement.classList.add('fd-navigating');
function done() {
loading = false;
document.documentElement.classList.remove('fd-navigating');
}
var p;
try {
@@ -325,6 +324,81 @@
fdLoad(window.location.href, false);
});
// ── Swap partiel : monter proprement la zone swapée sous Alpine ──
// Lors d'une navigation partielle (fdLoad), les <script src> de la page
// s'exécutent APRÈS le microtask MutationObserver d'Alpine : toute la zone
// .main-wrapper serait initialisée avant que composants ET stores (menu
// contextuel $store.fdCtx, …) existent → « Undefined variable: … » puis
// « reading 'has' » sur les stores. On pose x-ignore sur TOUTE la zone
// pendant le chargement des scripts, puis on démarle quand tous les
// <script src> ont exécuté (load/error) — filet de sécurité 4 s.
// (initTree est idempotent : le _x_marker posé au premier passage évite
// tout double montage.)
var fdLastMw = document.querySelector('.main-wrapper'); // nœud du chargement complet
window.fdRefreshXData = function (name) {
document.querySelectorAll('[x-data="' + name + '()"]').forEach(function (el) {
if (el.__fdRefreshed) return;
var st = el._x_dataStack;
if (!st) return; // sous x-ignore → done() montera
if (st[0] && Object.keys(st[0]).length) return; // déjà monté correctement
el.__fdRefreshed = true; // monté cassé (hors watcher) → on remonte
var fresh = el.cloneNode(true);
el.parentNode.replaceChild(fresh, el);
if (window.htmx) { try { htmx.process(fresh); } catch { /* volontaire */ } }
});
};
document.addEventListener('htmx:afterSwap', function () {
var mw = document.querySelector('.main-wrapper');
// nœud inchangé = swap hors navigation (sidebar, vues) : rien à faire
if (!mw || mw === fdLastMw || mw.__fdSwapWatch) return;
fdLastMw = mw;
mw.__fdSwapWatch = true;
// Avant tout paint (même task que le swap) : la zone est x-ignore, donc non
// montée Alpine — tous ses [x-show] seraient peints à leur valeur brute
// (rafale de menus/panneaux/états visibles une fraction de seconde). On ne
// la peint qu'une fois initTree passé.
mw.style.opacity = '0';
mw.style.pointerEvents = 'none';
mw.setAttribute('x-ignore', '');
var pending = mw.querySelectorAll('script[src]').length;
var reveal = function () {
if (!mw.isConnected) return;
mw.style.opacity = '';
mw.style.pointerEvents = '';
};
var done = function () {
reveal(); // idempotent : appelé même si la branche suivante retourne
if (!mw.__fdSwapWatch) return;
mw.__fdSwapWatch = false;
document.removeEventListener('load', onScript, true);
document.removeEventListener('error', onScript, true);
if (!mw.isConnected || !mw.hasAttribute('x-ignore')) return;
mw.removeAttribute('x-ignore');
mw._x_ignore = false; // propriété posée par Alpine au checkpoint
try { Alpine.initTree(mw); } catch (e) { console.warn('[FlowDeck] mount', e); }
reveal();
};
var onScript = function (e) {
if (!e.target || e.target.tagName !== 'SCRIPT') return;
if (--pending <= 0) done();
};
if (!pending) {
// Avant le microtask MutationObserver d'Alpine : retirer x-ignore suffit,
// le MO initialisera au checkpoint (initTree de sécurité = no-op).
mw.removeAttribute('x-ignore');
mw.__fdSwapWatch = false;
setTimeout(function () {
if (mw.isConnected) { try { Alpine.initTree(mw); } catch (e) { console.warn('[FlowDeck] mount', e); } }
reveal(); // après initTree : un paint max de blanc, jamais de contenu brut
}, 0);
return;
}
document.addEventListener('load', onScript, true);
document.addEventListener('error', onScript, true);
setTimeout(done, 4000);
});
// Ctrl/Cmd + \ : toggle the sidebar (Notion-style).
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && (e.key === '\\' || e.code === 'Backslash')) {
@@ -395,3 +469,15 @@
});
})();
// A38 : openCardDetail était définie (corps identique) dans board_fragment
// ET detailed_board — une seule définition globale ici, appelée par les
// onclick/@click des deux fragments (owner/repo globaux fournis par board.js).
function openCardDetail(id) {
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
target: '#card-modal-content',
swap: 'innerHTML'
});
document.getElementById('card-modal').style.display = 'flex';
document.getElementById('card-modal-title').textContent = 'Issue #' + id;
}
+30 -4
View File
@@ -1,3 +1,5 @@
if (!window.__fdBoardScriptsLoaded) {
window.__fdBoardScriptsLoaded = true;
/* exported setActiveTab, kanbanBoard, filterSystem, sortSystem, newIssueForm, showNewIssue -- appeles depuis les attributs HTML des templates */
const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
@@ -18,6 +20,11 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
el.classList.add('active');
}
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: kanbanBoard »).
if (window.Alpine) { Alpine.data('kanbanBoard', kanbanBoard); fdRefreshXData('kanbanBoard'); } else document.addEventListener('alpine:init', function () { Alpine.data('kanbanBoard', kanbanBoard); });
function kanbanBoard() {
return {
collapsedGroups: [],
@@ -33,6 +40,11 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
};
}
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: filterSystem »).
if (window.Alpine) { Alpine.data('filterSystem', filterSystem); fdRefreshXData('filterSystem'); } else document.addEventListener('alpine:init', function () { Alpine.data('filterSystem', filterSystem); });
function filterSystem() {
return {
activeFilters: [],
@@ -60,6 +72,9 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
},
removeFilter(i) { this.activeFilters.splice(i, 1); },
resetFilters() { this.activeFilters = []; this.statusFilters = []; },
removeFilterAndRefresh(i) { this.removeFilter(i); this.refreshView(); },
resetFiltersAndRefresh() { this.resetFilters(); this.refreshView(); },
pickStatus(v) { this.toggleStatus(v); this.showStatusMenu = false; this.refreshView(); },
refreshView() {
const activeTab = document.querySelector('.view-tab.active');
if (activeTab) activeTab.click();
@@ -67,6 +82,11 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
};
}
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: sortSystem »).
if (window.Alpine) { Alpine.data('sortSystem', sortSystem); fdRefreshXData('sortSystem'); } else document.addEventListener('alpine:init', function () { Alpine.data('sortSystem', sortSystem); });
function sortSystem() {
return {
sorts: [],
@@ -89,15 +109,20 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
};
}
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: newIssueForm »).
if (window.Alpine) { Alpine.data('newIssueForm', newIssueForm); fdRefreshXData('newIssueForm'); } else document.addEventListener('alpine:init', function () { Alpine.data('newIssueForm', newIssueForm); });
function newIssueForm() {
return {
title: '', status: 'todo',
create() {
if (!this.title.trim()) return;
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
const csrf = getCsrf();
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, {
method: 'POST',
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
headers: { 'X-CSRF-Token': csrf }
})
.then(r => r.json())
.then(data => {
@@ -131,10 +156,10 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
onEnd: function(evt) {
const cardId = evt.item.dataset.cardId;
const toStatus = evt.to.dataset.status;
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
const csrf = getCsrf();
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, {
method: 'POST',
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
headers: { 'X-CSRF-Token': csrf }
})
.then(() => {
// ponytail: refresh current view after move
@@ -146,3 +171,4 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
});
}
});
}
+3 -1
View File
@@ -1,3 +1,5 @@
if (!window.__fdDbTableScriptsLoaded) {
window.__fdDbTableScriptsLoaded = true;
const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v=el?JSON.parse(el.textContent):null;return v===undefined?null:v}catch{return null}})();
(function() {
@@ -57,7 +59,6 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v=
var found = parseOpts(prop).filter(function(o){ return o.name === name; })[0];
return (found && PALETTE[found.color]) || '#8b8b8b';
}
function getCsrf() { var m = document.cookie.match(/csrf_token=([^;]+)/); return m ? m[1] : ''; }
function toast(msg, kind) {
if (typeof window.showToast === 'function') window.showToast(msg, kind);
else if (kind === 'error') console.warn(msg);
@@ -1310,3 +1311,4 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v=
.catch(function(){ return rowId; });
}
})();
}
+26 -6
View File
@@ -1,5 +1,5 @@
document.addEventListener('alpine:init', () => {
var _regGiteaWs = () => {
Alpine.data('giteaWorkspace', () => {
const params = new URLSearchParams(window.location.search);
const owner = params.get('owner') || '';
@@ -98,7 +98,7 @@ document.addEventListener('alpine:init', () => {
var newPath = oldPath.replace(/[^/]+$/, newName.trim());
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
method: 'PUT',
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json' },
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json' },
body: JSON.stringify({ path: oldPath, new_path: newPath, message: 'Rename ' + oldPath + ' to ' + newPath })
}).then(function(r){ return r.json(); })
.then(function(d){
@@ -113,7 +113,7 @@ document.addEventListener('alpine:init', () => {
if (!item) return;
if (!confirm('Delete ' + item.name + '?')) return;
var self = this;
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, {headers: {'X-CSRF-Token': getCsrf()},
method: 'DELETE'
}).then(function(r){
if (r.ok) { self.refreshTree(); }
@@ -208,7 +208,7 @@ document.addEventListener('alpine:init', () => {
if (!path) return;
if (!confirm('Delete ' + path + ' from ' + self.owner + '/' + self.repo + '?')) return;
var sha = el.getAttribute('data-gitea-sha') || '';
fetch('/api/gitea/projects/' + self.owner + '/' + self.repo + '/file?path=' + encodeURIComponent(path) + '&sha=' + encodeURIComponent(sha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
fetch('/api/gitea/projects/' + self.owner + '/' + self.repo + '/file?path=' + encodeURIComponent(path) + '&sha=' + encodeURIComponent(sha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': getCsrf()},
method: 'DELETE',
}).then(function(r) {
if (r.ok) self.refreshTree();
@@ -350,7 +350,7 @@ document.addEventListener('alpine:init', () => {
if (!this.filePath) return;
if (!confirm('Delete ' + this.filePath + ' from ' + this.owner + '/' + this.repo + '?')) return;
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(this.filePath) + '&sha=' + encodeURIComponent(this.fileSha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(this.filePath) + '&sha=' + encodeURIComponent(this.fileSha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': getCsrf()},
method: 'DELETE',
});
if (r.ok) {
@@ -367,6 +367,20 @@ document.addEventListener('alpine:init', () => {
this.loadSidebarTree();
},
// ── A20 ph3 : new Date / x-html interdits sous build CSP ──
closePrivateEdit() {
this.editingPrivate = null;
this.editingPrivateTitle = '';
this.editingPrivateContent = '';
},
fmtGwDate(pp) {
return pp.updated_at ? new Date(pp.updated_at + 'Z').toLocaleString() : '';
},
bindGwIcon(el, item) {
Alpine.effect(() => {
el.innerHTML = item.type === 'folder' ? getSvgIcon('folder', 16) : getSvgIcon('file', 16);
});
},
formatSize(bytes) {
if (!bytes) return '';
if (bytes < 1024) return bytes + ' B';
@@ -623,4 +637,10 @@ document.addEventListener('alpine:init', () => {
},
};
});
});
};
if (window.Alpine) {
_regGiteaWs();
fdRefreshXData('giteaWorkspace');
} else {
document.addEventListener('alpine:init', _regGiteaWs);
}
+5
View File
@@ -1,5 +1,10 @@
/* exported importWizard -- appeles depuis les attributs HTML des templates */
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: importWizard »).
if (window.Alpine) { Alpine.data('importWizard', importWizard); fdRefreshXData('importWizard'); } else document.addEventListener('alpine:init', function () { Alpine.data('importWizard', importWizard); });
function importWizard() {
return {
sources: [],
+45 -5
View File
@@ -1,5 +1,10 @@
/* exported libraryPage -- appeles depuis les attributs HTML des templates */
// A20 phase 3 : enregistrement Alpine.data — le build CSP ne résout que le
// registre (probe : globale window → « Undefined variable: libraryPage »).
if (window.Alpine) { Alpine.data('libraryPage', libraryPage); fdRefreshXData('libraryPage'); }
else document.addEventListener('alpine:init', function () { Alpine.data('libraryPage', libraryPage); });
function libraryPage() {
return {
tab: 'recents',
@@ -160,7 +165,42 @@ function libraryPage() {
_escHtml(s) { var d=document.createElement('div'); d.textContent = s||''; return d.innerHTML; },
_escAttr(s) { return String(s||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); },
_renderIcon(item) {
openMoveSelected() {
this.openMovePicker(Object.keys(this.selected).map(Number));
},
// ── A20 phase 3 (surface library) : formes compatibles build CSP ──
// $nextTick / x-html ne passent pas par l'évaluateur maison du build CSP
// (arrow inline = parse error ; x-html = interdit) → méthodes JS réelles,
// réactivité via Alpine.effect — valable sous les deux builds.
toggleFilterMenu() {
this.filterOpen = !this.filterOpen;
this.sortOpen = false;
this.viewOpen = false;
},
toggleSortMenu() {
this.sortOpen = !this.sortOpen;
this.filterOpen = false;
this.viewOpen = false;
},
toggleViewMenu() {
this.viewOpen = !this.viewOpen;
this.filterOpen = false;
this.sortOpen = false;
},
toggleSearch() {
this.searchOpen = !this.searchOpen;
if (this.searchOpen) Alpine.nextTick(() => {
var el = document.getElementById('lib-search-input');
if (el) el.focus();
});
},
bindHtmlIcon(el) {
Alpine.effect(() => { el.innerHTML = getSvgIcon(this.emptyIcon, 48); });
},
bindHtmlItem(el, item) {
Alpine.effect(() => { el.innerHTML = this._renderIcon(item); });
},
_renderIcon(item) {
if (!item) return getSvgIcon('file', 14);
// Custom emoji (image URL), icon name, or unicode emoji
if (item.page_icon) return '<span style="font-size:14px;line-height:1;display:inline-flex;align-items:center;">' + (window.fdIconHtml ? window.fdIconHtml(item.page_icon, 14) : this._escHtml(item.page_icon)) + '</span>';
@@ -363,8 +403,8 @@ function libraryPage() {
},
_getCsrf() {
var m = document.cookie.match(/csrf_token=([^;]+)/);
return m ? m[1] : '';
var m = getCsrf();
return m;
},
_syncSidebar() {
@@ -740,7 +780,7 @@ function libraryPage() {
var item = store && store.node;
if (!item) return;
var self = this;
fetch('/api/local-workspace/items/' + item.id + '/tags/' + tagId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' })
fetch('/api/local-workspace/items/' + item.id + '/tags/' + tagId, {headers: {'X-CSRF-Token': getCsrf()}, method: 'DELETE' })
.then(function(r) {
if (!r.ok) return;
item.tags = (item.tags || []).filter(function(t) { return t.id !== tagId; });
@@ -760,7 +800,7 @@ function libraryPage() {
try {
var r = await fetch('/api/local-workspace/items/' + item.id + '/tags', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf()},
body: JSON.stringify({name: tagName, color: color})
});
if (r.ok) {
+107 -24
View File
@@ -1,7 +1,10 @@
if (!window.__fdLocalWorkspaceScriptsLoaded) {
window.__fdLocalWorkspaceScriptsLoaded = true;
const LW=(()=>{try{const el=document.getElementById('lw-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData');
window._wsInitData = (function() {
const _wsInitData = (function() { // lexical : NON propriété globalThis →
// invisible au snapshot ji du build CSP (valeur bannie sinon)
return {
tree:[],
displayTree:[],
@@ -300,7 +303,7 @@ window._wsInitData = (function() {
var self = this;
// Soft-delete all selected items
for (var i=0; i<ids.length; i++) {
await fetch('/api/local-workspace/items/' + ids[i], {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
await fetch('/api/local-workspace/items/' + ids[i], {headers: {'X-CSRF-Token': getCsrf()}, method: 'DELETE' });
}
this.clearSelection();
this._reloadAfterAction();
@@ -627,7 +630,7 @@ window._wsInitData = (function() {
if (!node) return;
try {
var method = node.favorited ? 'DELETE' : 'POST';
var r = await fetch('/board/api/favorites/' + node.id, { method: method });
var r = await fetch('/board/api/favorites/' + node.id, { method: method, headers: {'X-CSRF-Token': getCsrf()} });
if (r.ok) {
node.favorited = !node.favorited;
if (window.appState && window.appState.refreshFavorites) window.appState.refreshFavorites();
@@ -643,7 +646,7 @@ window._wsInitData = (function() {
var r = await fetch('/board/api/pages/' + node.id + '/icon', {
method: 'POST',
headers: {'Content-Type': 'application/json',
'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || ''},
'X-CSRF-Token': getCsrf()},
body: JSON.stringify({icon: icon})
});
if (!r.ok) throw new Error('icon update failed');
@@ -725,7 +728,7 @@ window._wsInitData = (function() {
color = color || (store && store.newTagColor) || '#787774';
try {
var r = await fetch('/api/local-workspace/items/' + node.id + '/tags', {
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
method: 'POST', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({name: tagName, color: color})
});
if (r.ok) {
@@ -862,7 +865,7 @@ window._wsInitData = (function() {
if (!newName) return;
try {
var r = await fetch('/api/local-workspace/items/' + id, {
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
method: 'PUT', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({name: newName})
});
if (r.ok) {
@@ -1042,7 +1045,7 @@ window._wsInitData = (function() {
this.renamingId = null;
if (!n || n === node.name) return;
var r = await fetch('/api/local-workspace/items/' + node.id, {
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
method: 'PUT', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({name: n})
});
if (r.ok) {
@@ -1096,7 +1099,7 @@ window._wsInitData = (function() {
var targetId = this.currentFolder > 0 ? this.currentFolder : null;
this.clipboard.forEach(function(id) {
fetch('/api/local-workspace/items/' + id + '/move', {
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
method: 'PUT', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({parent_id: targetId})
}).then(function(r) { if (r.ok) self._reloadAfterAction(); });
});
@@ -1107,7 +1110,7 @@ window._wsInitData = (function() {
// ── Duplicate ──
async duplicateItem(node) {
var r = await fetch('/api/local-workspace/items', {
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
method: 'POST', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({name: node.name + ' (copy)', type: node.type || 'page',
parent_id: this.currentFolder > 0 ? this.currentFolder : null})
});
@@ -1135,7 +1138,7 @@ window._wsInitData = (function() {
this.undoMessage = 'Deleted "' + (node.name || 'item') + '"';
this.undoVisible = true;
// Delete via API
var r = await fetch('/api/local-workspace/items/' + this.undoItemId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE' });
var r = await fetch('/api/local-workspace/items/' + this.undoItemId, {headers: {'X-CSRF-Token': getCsrf()}, method:'DELETE' });
if (!r.ok) { this.undoVisible = false; return; }
// Reload from API for reliability (sidebar refresh handled by _reloadAfterAction)
this._reloadAfterAction();
@@ -1159,7 +1162,7 @@ window._wsInitData = (function() {
self._reloadAfterAction();
return;
}
fetch('/api/local-workspace/items/' + ids[i] + '/restore', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'POST' })
fetch('/api/local-workspace/items/' + ids[i] + '/restore', {headers: {'X-CSRF-Token': getCsrf()}, method: 'POST' })
.finally(function() { restoreOne(i + 1); });
}
restoreOne(0);
@@ -1455,7 +1458,7 @@ window._wsInitData = (function() {
if (this.parentFolder) body.parent_id = this.parentFolder.db_id;
var r = await fetch('/api/local-workspace/items', {
method:'POST',
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body:JSON.stringify(body)
});
if (r.ok) {
@@ -1539,7 +1542,7 @@ window._wsInitData = (function() {
if (!n||!this.target) return;
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {
method:'PUT',
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body:JSON.stringify({name:n})
});
if (r.ok) { this._reloadAfterAction(); }
@@ -1552,7 +1555,7 @@ window._wsInitData = (function() {
async doDelete() {
if (!this.target) return;
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE' });
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {headers: {'X-CSRF-Token': getCsrf()}, method:'DELETE' });
if (r.ok) { this._reloadAfterAction(); }
},
@@ -1739,7 +1742,7 @@ window._wsInitData = (function() {
try {
var r = await fetch('/api/local-workspace/items/' + itemId + '/move', {
method: 'PUT',
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json' },
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (r.ok) {
@@ -1762,7 +1765,7 @@ window._wsInitData = (function() {
var url = folderHandled ? '/api/local-workspace/upload-folder' : '/api/local-workspace/upload';
try {
var r = await fetch(url, { method: 'POST', body: formData });
var r = await fetch(url, { method: 'POST', headers: {'X-CSRF-Token': getCsrf()}, body: formData });
this.uploadProgress = 80;
var d = await r.json();
if (r.ok) {
@@ -1834,7 +1837,7 @@ window._wsInitData = (function() {
try {
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags', {
method: 'POST',
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'},
body: JSON.stringify({name: tagName})
});
if (r.ok) {
@@ -1853,7 +1856,7 @@ window._wsInitData = (function() {
async removeTag(itemId, tagId) {
try {
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags/' + tagId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags/' + tagId, {headers: {'X-CSRF-Token': getCsrf()},
method: 'DELETE'
});
if (r.ok) {
@@ -1999,7 +2002,7 @@ window._wsInitData = (function() {
try {
await fetch('/api/local-workspace/items/' + ids[i] + '/move', {
method: 'PUT',
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({ parent_id: parentId || null })
});
} catch { /* volontaire */ }
@@ -2007,13 +2010,93 @@ window._wsInitData = (function() {
this.clearSelection();
this._reloadAfterAction();
if (window.showToast) window.showToast('Item' + (ids.length > 1 ? 's' : '') + ' moved', 'success');
}
},
};
// ── A20 ph3 : x-html interdit par le build CSP → x-init + Alpine.effect
// (réactivité conservée : lecture des données réactives dans l'effect) ──
toggleViewMenu() {
this.viewMenuOpen = !this.viewMenuOpen;
this.sortOpen = false;
this.searchOpen = false;
},
toggleSortMenu() {
this.sortOpen = !this.sortOpen;
this.viewMenuOpen = false;
},
toggleFilterMenu() {
this.filterOpen = !this.filterOpen;
this.viewMenuOpen = false;
this.sortOpen = false;
},
toggleSearch() {
this.searchOpen = !this.searchOpen;
if (this.searchOpen) Alpine.nextTick(() => {
const el = document.querySelector('[x-ref="searchInput"]');
if (el) el.focus();
});
},
createPageAt(node) { window.FlowDeck.createPage(node.id); },
createFolderAt(node) { window.FlowDeck.showCreateFolderModal(node.id); },
bindSvg(el, name, size) { el.innerHTML = getSvgIcon(name, size); },
bindFileIcon(el, node) {
Alpine.effect(() => {
el.innerHTML = this._fileIcon(node.name, node.is_folder, node.content_format, node.page_icon);
});
},
bindNodeIcon(el, node) {
Alpine.effect(() => {
el.innerHTML = node.is_folder
? getSvgIcon('folder', 24)
: this._fileIcon(node.name, node.is_folder, node.content_format, node.page_icon);
});
},
bindChildren(el, node) {
Alpine.effect(() => {
el.innerHTML = this.renderChildren(node.children, (node.depth || 0) + 1, this.tagsVersion);
});
},
bindPreview(el) { Alpine.effect(() => { el.innerHTML = this.previewContent; }); },
};
})();
// Injecter toutes les props comme globales pour Alpine (résout les 36 erreurs de scope)
var _wsKeys = Object.keys(window._wsInitData);
var _wsKeys = Object.keys(_wsInitData);
for (var _i = 0; _i < _wsKeys.length; _i++) {
try { window[_wsKeys[_i]] = window._wsInitData[_wsKeys[_i]]; } catch { /* volontaire */ }
try { window[_wsKeys[_i]] = _wsInitData[_wsKeys[_i]]; } catch { /* volontaire */ }
}
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(_wsInitData).length);
// A20 ph3 : registre Alpine.data (le build CSP ne résout que le registre ;
// x-data="wsInitData()" au lieu de l'identifiant global _wsInitData).
var _regWs = function () {
// objet partagé JS↔Alpine via closure LEXICALE (const hors window →
// non snapshoté par ji ; probe4 : factory retournant l'objet = accepté).
Alpine.data('wsInitData', function () { return _wsInitData; });
// Bloc preview : le parseur le place HORS de la div racine (structure
// pré-existante) → montage distinct DÉLÉGUANT vers l'objet partagé.
// Wrapper = objet unique (les magics $nextTick… ne sont redéfinissables
// qu'une fois) + lecture/écriture via Alpine.reactive(_wsInitData) pour
// garder la réactivité JS↔Alpine.
Alpine.data('wsPreview', function () {
const R = Alpine.reactive(_wsInitData);
const t = {};
['previewVisible', 'previewX', 'previewY', 'previewName'].forEach(function (k) {
Object.defineProperty(t, k, {
get: function () { return R[k]; },
set: function (v) { R[k] = v; },
configurable: true,
});
});
t.bindPreview = function (el) {
Alpine.effect(function () { el.innerHTML = R.previewContent; });
};
return t;
});
};
if (window.Alpine) {
_regWs();
fdRefreshXData('wsInitData');
fdRefreshXData('wsPreview');
} else {
document.addEventListener('alpine:init', _regWs);
}
}
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length);
+3
View File
@@ -1,3 +1,5 @@
if (!window.__fdRtScriptsLoaded) {
window.__fdRtScriptsLoaded = true;
const RT=(()=>{try{const el=document.getElementById('rt-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
/* eslint-disable */
@@ -530,3 +532,4 @@ window.__fdRT = (function () {
return { start, syncNow };
})();
}
+61 -33
View File
@@ -1125,6 +1125,16 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
wrap.classList.toggle('full-width',!!this.fullWidth);
wrap.classList.toggle('small-text',!!this.fontSmall);
},
closeInviteSuggest(){ this.inviteSuggestOpen = false; this.inviteUsers = []; },
setSharePerm(a, perm){ this.updateShare(a.id, perm); a.permOpen = false; },
removeShareAndClose(a){ this.removeShare(a.id); a.permOpen = false; },
pickGeneralAccess(v){ this.generalAccess = v; this.accessMenuOpen = false; },
applyIcon(){ this.setIcon(this.iconValue); this.iconOpen = false; },
moreToggleLock(){ this.moreOpen = false; this.toggleLock(); },
moreFullWidth(){ this.moreOpen = false; this.setPageOption('full_width', !this.fullWidth); },
moreFontSmall(){ this.moreOpen = false; this.setPageOption('font_small', !this.fontSmall); },
moreSaveTemplate(){ this.moreOpen = false; this.saveAsTemplate(); },
markAndSave(){ this.dirty = true; this.save(); },
async toggleLock(){
if(!this.canEdit){this.showToast&&this.showToast('Only the person who locked this page (or an admin) can unlock it','error');return;}
const next=!this.isLocked;
@@ -1421,6 +1431,15 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
}catch(e){this.showToast('Remove failed','error');}
},
toggleIcon(){this.iconOpen=!this.iconOpen;},
openBacklink(b){
this.backlinksOpen = false;
window.location.href = '/pages/' + b.id;
},
fmtImportSize(f){ return Math.round(f.size / 1024) + ' KB'; },
// A20 ph3 : x-html interdit sous build CSP → effet réactif
bindIconHtml(el){
Alpine.effect(() => { el.innerHTML = this.iconHtml(); });
},
iconHtml(){
var v=this.iconValue;
if(!v)v=(this.contentFormat==='file')?'paperclip':'file';
@@ -1499,7 +1518,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
cutSelectedBlocks(){const indices=_selIndicesSorted();if(!indices.length)return;this.sync();this.pushHistory();const selected=indices.map(i=>this.blocks[i]).filter(Boolean);if(!selected.length)return;const md=this._blocksToMarkdown(selected);if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md).then(()=>{});}this.blocks=this.blocks.filter((b,i)=>indices.indexOf(i)<0);if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();this.showToast('Blocs coupés');},
async pasteBlocks(){const idx=this._focusedIdx();if(idx<0)return;try{const text=await navigator.clipboard.readText();if(!text||!text.trim())return;const parsed=this.md2b(text);if(!parsed.length)return;this.sync();this.pushHistory();this.blocks.splice(idx+1,0,...parsed);this.dirty=true;this.autoSave();this.render();_rtSync();this._focusBid(parsed[parsed.length-1].id);_selClear();}catch(e){if(e.name==='NotAllowedError'){this.showToast('Permission presse-papier refusée');}else{this.showToast('Coller impossible','error');}}},
_focusedIdx(){const a=this.getActiveBlock();return a?a.idx:-1;},
async moveBlockToPage(idx,pageId){if(idx<0||idx>=this.blocks.length||pageId===this.pid)return;this.sync();this.pushHistory();const block=this.blocks[idx];const self=this;const csrf=document.cookie.match(/csrf_token=([^;]+)/);this.showToast('Déplacement du bloc…');try{const r=await fetch(`/board/api/pages/${pageId}`,{credentials:'same-origin'});const d=await r.json();let tblocks=[];if(d.content_format==='blocks'&&d.content){try{tblocks=JSON.parse(d.content);}catch(e){tblocks=[];}}tblocks.push(block);await fetch(`/board/api/pages/${pageId}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:d.title||'',blocks:tblocks})});
async moveBlockToPage(idx,pageId){if(idx<0||idx>=this.blocks.length||pageId===this.pid)return;this.sync();this.pushHistory();const block=this.blocks[idx];const self=this;const csrf = getCsrf();;this.showToast('Déplacement du bloc…');try{const r=await fetch(`/board/api/pages/${pageId}`,{credentials:'same-origin'});const d=await r.json();let tblocks=[];if(d.content_format==='blocks'&&d.content){try{tblocks=JSON.parse(d.content);}catch(e){tblocks=[];}}tblocks.push(block);await fetch(`/board/api/pages/${pageId}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify({title:d.title||'',blocks:tblocks})});
if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();this.showToast('Bloc déplacé');
}catch(e){this.showToast('Échec du déplacement','error');}},
@@ -1585,9 +1604,9 @@ applyAIBlocks(text){
},
toggleFavorite(){
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
const csrf = getCsrf();;
const m=this.favorited?'DELETE':'POST';
fetch(`/board/api/favorites/${this.pid}`,{method:m,headers:{'X-CSRF-Token':csrf?csrf[1]:''}})
fetch(`/board/api/favorites/${this.pid}`,{method:m,headers:{'X-CSRF-Token':csrf}})
.then(r=>r.json()).then(()=>{this.favorited=!this.favorited;this.showToast(this.favorited?'Added to favorites':'Removed from favorites');
if(window.appState&&window.appState.refreshFavorites)window.appState.refreshFavorites();
}).catch(()=>{this.showToast('Failed to toggle favorite');});
@@ -1608,8 +1627,8 @@ applyAIBlocks(text){
_syncIsShared(){this.pageIsShared=!!(this.pagePublished||this.generalAccess==='anyone'||(this.accessList||[]).length>0);},
togglePublish(){this.pagePublished=!this.pagePublished;this._syncIsShared();this.saveShare();},
saveShare(){
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
fetch(`/board/api/share/${this.pid}`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({mode:this.generalAccess,published:this.pagePublished})}).then(()=>this._syncIsShared()).catch(()=>{});
const csrf = getCsrf();;
fetch(`/board/api/share/${this.pid}`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify({mode:this.generalAccess,published:this.pagePublished})}).then(()=>this._syncIsShared()).catch(()=>{});
},
async copyPageLink(){
console.log('copyPageLink invoked');
@@ -1632,10 +1651,10 @@ applyAIBlocks(text){
},
publishPage() {
var self = this;
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
var csrf = getCsrf();;
fetch('/api/pages/' + this.pid + '/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' }
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }
}).then(function(r){ return r.json(); }).then(function(d){
if (d.is_published) {
self.pagePublished = true;
@@ -1648,10 +1667,10 @@ applyAIBlocks(text){
},
unpublishPage() {
var self = this;
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
var csrf = getCsrf();;
fetch('/api/pages/' + this.pid + '/publish', {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
headers: { 'X-CSRF-Token': csrf }
}).then(function(r){ return r.json(); }).then(function(d){
if (!d.is_published) {
self.pagePublished = false;
@@ -1665,10 +1684,10 @@ applyAIBlocks(text){
shareInvite() {
var self = this;
if (this.inviteGroupId) {
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
var csrf = getCsrf();;
fetch('/api/pages/' + this.pid + '/share', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ group_id: this.inviteGroupId, permission: this.invitePermission })
}).then(function(r){ return r.json(); }).then(function(d){
if (d.status === 'shared') {
@@ -1680,10 +1699,10 @@ applyAIBlocks(text){
return;
}
if (!this.inviteEmail.trim()) return;
var csrf2 = document.cookie.match(/csrf_token=([^;]+)/);
var csrf2 = getCsrf();;
fetch('/api/pages/' + this.pid + '/share', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf2 ? csrf2[1] : '' },
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf2 },
body: JSON.stringify({ user_id: this.inviteUserId, email: this.inviteEmail.trim(), permission: this.invitePermission })
}).then(function(r){ return r.json(); }).then(function(d){
if (d.status === 'shared') {
@@ -1760,10 +1779,10 @@ applyAIBlocks(text){
},
updateShare(sid, perm) {
var self = this;
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
var csrf = getCsrf();;
fetch('/api/pages/' + this.pid + '/share/' + sid, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ permission: perm })
}).then(function(r){ return r.json(); }).then(function(d){
self.showToast(d.status === 'updated' ? 'Permission updated' : (d.detail || 'Update failed'));
@@ -1779,10 +1798,10 @@ applyAIBlocks(text){
},
removeShare(sid) {
var self = this;
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
var csrf = getCsrf();;
fetch('/api/pages/' + this.pid + '/share/' + sid, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
headers: { 'X-CSRF-Token': csrf }
}).then(function(r){ return r.json(); }).then(function(d){
if (d.status === 'removed') { self.showToast('Share removed'); self.loadShares(); }
else { self.showToast(d.detail || 'Remove failed'); }
@@ -1794,7 +1813,7 @@ applyAIBlocks(text){
this.moreOpen=false;
this.exportOpen=false;
// Ensure latest blocks are persisted before exporting any format
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
var csrf = getCsrf();;
var self = this;
var doDownload = function(){
var title = encodeURIComponent((self.pageTitle || 'Untitled').trim() || 'Untitled');
@@ -1813,7 +1832,7 @@ applyAIBlocks(text){
doDownload();
}
},
duplicatePage(){this.moreOpen=false;const csrf=document.cookie.match(/csrf_token=([^;]+)/);const t=(this.pageTitle||'').trim()||'New Page';fetch(`/board/api/pages?title=${encodeURIComponent(t+' copy')}&section=Private&project=${encodeURIComponent(PD.workspace_key||'')}`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(r=>r.json()).then(d=>{window.location.href=`/pages/${d.id}`;}).catch(()=>{window.showToast('Duplicate failed','error');});},
duplicatePage(){this.moreOpen=false;const csrf = getCsrf();;const t=(this.pageTitle||'').trim()||'New Page';fetch(`/board/api/pages?title=${encodeURIComponent(t+' copy')}&section=Private&project=${encodeURIComponent(PD.workspace_key||'')}`,{method:'POST',headers:{'X-CSRF-Token':csrf}}).then(r=>r.json()).then(d=>{window.location.href=`/pages/${d.id}`;}).catch(()=>{window.showToast('Duplicate failed','error');});},
movePage(){
this.moreOpen=false;
this.moveOpen = true;
@@ -1829,11 +1848,11 @@ applyAIBlocks(text){
},
doMove(wsId) {
this.moveOpen = false;
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
var csrf = getCsrf();;
var self = this;
fetch('/board/api/pages/' + this.pid + '/move', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ workspace_id: wsId })
}).then(function(r){ return r.json(); })
.then(function(d){
@@ -1846,7 +1865,7 @@ applyAIBlocks(text){
})
.catch(function(){ self.showToast('Move failed'); });
},
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(()=>{window.location.href='/';}).catch(()=>{window.showToast('Failed','error');});},
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf = getCsrf();;fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf}}).then(()=>{window.location.href='/';}).catch(()=>{window.showToast('Failed','error');});},
downloadFile(){this.moreOpen=false;if(!this.fileUrl)return;var a=document.createElement('a');a.href=this.fileUrl;a.download='';document.body.appendChild(a);a.click();document.body.removeChild(a);this.showToast('Download started','success');},
async copyFileContent(){this.moreOpen=false;if(!this.fileUrl)return;try{var r=await fetch('/api/pages/'+this.pid+'/file-content');var d=await r.json();if(d.ok&&d.content!==undefined){await navigator.clipboard.writeText(d.content);this.showToast('Content copied to clipboard','success');}else{this.showToast('Not a text file','error');}}catch(e){this.showToast('Copy failed','error');}},
@@ -1871,12 +1890,12 @@ applyAIBlocks(text){
async createDbFromTemplate(tplName){
const self=this;
var name = tplName ? (tplName+' — '+new Date().toLocaleDateString('fr-FR')) : 'Database';
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
const csrf = getCsrf();;
try{
const body={name:name, parent_page_id:this.pid};
if(tplName) body.template=tplName;
const r=await fetch('/db/inline/api',{
method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},
method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},
body:JSON.stringify(body)
});
const d=await r.json();
@@ -1896,10 +1915,10 @@ applyAIBlocks(text){
async createForm(){
const name=prompt('Form name:','New Form');
if(!name||!name.trim())return;
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
const csrf = getCsrf();;
try{
const r=await fetch('/db/inline/api',{
method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},
method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},
body:JSON.stringify({name:name.trim(),parent_page_id:this.pid})
});
const d=await r.json();
@@ -2106,7 +2125,7 @@ applyAIBlocks(text){
toggleComments(){this.commentsOpen=!this.commentsOpen;if(this.commentsOpen)this.loadComments();},
async loadComments(){try{const r=await fetch('/api/pages/'+this.pid+'/comments',{credentials:'same-origin'});const d=await r.json();this.comments=d.comments||[];this.commentCount=this.comments.length;}catch(e){this.comments=[];}},
fmtTime(s){if(!s)return '';const t=new Date((String(s).includes('T')||String(s).includes('Z'))?s:(s+'Z'));if(isNaN(t.getTime()))t=new Date(s);const diff=Math.floor((Date.now()-t.getTime())/1000);if(diff<60)return 'just now';if(diff<3600)return Math.floor(diff/60)+'m ago';if(diff<86400)return Math.floor(diff/3600)+'h ago';return Math.floor(diff/86400)+'d ago';},
csrfTok(){return (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'';},
csrfTok(){return getCsrf();},
async addPageComment(){
const text=(this.commentDraft||'').trim();if(!text)return;
const self=this;const sel=this._commentSel;
@@ -2378,8 +2397,8 @@ applyAIBlocks(text){
this._fileTitleT=null;
const title=(this.pageTitle||'').trim();
if(!title)return;
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
fetch(`/board/api/pages/${this.pid}?title=${encodeURIComponent(title)}`,{method:'PUT',headers:{'X-CSRF-Token':csrf?csrf[1]:''}})
const csrf = getCsrf();;
fetch(`/board/api/pages/${this.pid}?title=${encodeURIComponent(title)}`,{method:'PUT',headers:{'X-CSRF-Token':csrf}})
.then(()=>{this.dirty=false;})
.catch(()=>{});
},600);
@@ -2400,8 +2419,8 @@ applyAIBlocks(text){
.catch(()=>{this.saving=false;});
};
if(!navigator.onLine){queueOffline();return;}
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:blocksArr})})
const csrf = getCsrf();;
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify({title:this.pageTitle,blocks:blocksArr})})
.then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString();
this._syncTitleUI();
if(cb)cb();
@@ -2411,11 +2430,11 @@ applyAIBlocks(text){
const idx=this.getIdx(bid);if(idx<0)return;
const b=this.blocks[idx];
if(!b.automation_id){this.pickAutomation(bid);return;}
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
const csrf = getCsrf();;
const self=this;
this.showToast('⚡ '+ (b.automation_name||'Automation') + '…');
fetch('/api/automations/'+b.automation_id+'/run',{method:'POST',
headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},
headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},
body:JSON.stringify({page_id:this.pid})})
.then(r=>r.json())
.then(d=>{
@@ -2512,5 +2531,14 @@ applyAIBlocks(text){
default: return c;
}
}
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: editorState »).
// Navigation partielle (fdLoad) : Alpine démarre déjà → 'alpine:init'
// ne sera plus émis, on enregistre tout de suite (pattern _ctx_menu.js)
// et on remonte la racine montée avant l'enregistrement.
if (window.Alpine) { Alpine.data('editorState', editorState); fdRefreshXData('editorState'); }
else document.addEventListener('alpine:init', function () { Alpine.data('editorState', editorState); });
window.editorState = editorState;
}
+156 -12
View File
@@ -1,8 +1,22 @@
if (!window.__fdSettingsScriptsLoaded) {
window.__fdSettingsScriptsLoaded = true;
/* exported settingsInit -- appeles depuis les attributs HTML des templates */
const ST=(()=>{try{const el=document.getElementById('st-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: settingsInit »).
if (window.Alpine) { Alpine.data('settingsInit', settingsInit); fdRefreshXData('settingsInit'); } else document.addEventListener('alpine:init', function () { Alpine.data('settingsInit', settingsInit); });
function settingsInit() {
return {
// ── A20 ph3 : expressions hostiles au parseur CSP (window/Date) ──
historyBack() { window.history.back(); },
fmtLastLogin(u) {
return u.last_login ? new Date(u.last_login * 1000).toLocaleDateString() : 'Never';
},
fmtAuditDate(e) { return new Date(e.at).toLocaleString(); },
activeSection: 'account',
allTags: [],
newTagName: '',
@@ -63,6 +77,9 @@ function settingsInit() {
automations: [],
autoRuns: [],
editAutomationId: null,
editSteps: [],
stepEdit: -1,
stepNewKind: 'action',
globalCollections: [],
af: { name:'', trigger_type:'event', event:'page.created', cron_expression:'*/15 * * * *', collection_id:'', condition_json:'[]', actions_json:'[]' },
@@ -146,7 +163,7 @@ function settingsInit() {
var n = this.newTagName.trim();
if (!n) return;
var r = await fetch('/api/settings/tags', {
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
method: 'POST', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({name: n, color: this.newTagColor})
});
if (r.ok) { this.newTagName = ''; this.newTagColor = '#787774'; await this.loadTags(); }
@@ -154,7 +171,7 @@ function settingsInit() {
async updateTagColor(id, color) {
await fetch('/api/settings/tags/' + id, {
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
method: 'PUT', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({color: color})
});
await this.loadTags();
@@ -162,7 +179,7 @@ function settingsInit() {
async deleteTag(id) {
if (!confirm('Delete this tag?')) return;
await fetch('/api/settings/tags/' + id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
await fetch('/api/settings/tags/' + id, {headers: {'X-CSRF-Token': getCsrf()}, method: 'DELETE' });
await this.loadTags();
},
@@ -183,7 +200,7 @@ function settingsInit() {
this.renamingTag = null; return;
}
await fetch('/api/settings/tags/' + tag.id, {
method: 'PUT', headers: {'Content-Type':'application/json', 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
method: 'PUT', headers: {'Content-Type':'application/json', 'X-CSRF-Token': getCsrf()},
body: JSON.stringify({name: newName})
});
this.renamingTag = null;
@@ -456,7 +473,7 @@ function settingsInit() {
try {
var r = await fetch('/api/agent/keys/' + id + '/models', {
method: 'POST',
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({api_key: f.api_key, api_base: f.api_base})
});
var d = await r.json();
@@ -485,7 +502,7 @@ function settingsInit() {
if (f.models && f.models.length) body.models = f.models;
var r = await fetch('/api/agent/keys/' + id, {
method: 'PUT',
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify(body)
});
var d = await r.json();
@@ -521,7 +538,7 @@ function settingsInit() {
if (f.api_key) body.api_key = f.api_key;
var r = await fetch('/api/agent/keys/' + id + '/test', {
method: 'POST',
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify(body)
});
var d = await r.json();
@@ -547,7 +564,7 @@ function settingsInit() {
var f = this.keyForm(id);
f.deleting = true; f.msg = ''; f.ok = false;
try {
var r = await fetch('/api/agent/keys/' + id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
var r = await fetch('/api/agent/keys/' + id, {headers: {'X-CSRF-Token': getCsrf()}, method: 'DELETE' });
var d = await r.json();
if (r.ok) {
f.has_key = false; f.api_key = ''; f.models = []; f.model = ''; f.last_error = '';
@@ -707,7 +724,7 @@ function settingsInit() {
if (!file) return;
var form = new FormData();
form.append('file', file);
var r = await fetch('/api/settings/avatar', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'POST', body: form });
var r = await fetch('/api/settings/avatar', {headers: {'X-CSRF-Token': getCsrf()}, method: 'POST', body: form });
if (r.ok) {
var d = await r.json();
this.avatarUrl = d.avatar_url + '?t=' + Date.now();
@@ -718,7 +735,7 @@ function settingsInit() {
async selectAvatarColor(color) {
this.avatarColor = color;
var r = await fetch('/api/settings/avatar-color', {
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
method: 'POST', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({color: color})
});
if (r.ok) { this.avatarUrl = ''; }
@@ -761,6 +778,128 @@ function settingsInit() {
if (r.ok) { this.githubLinked = false; }
} catch { /* volontaire */ }
},
// ── Éditeur visuel de steps (API v7.0 : /steps CRUD) ──
async loadSteps() {
if (!this.editAutomationId) { this.editSteps = []; return; }
try {
var r = await fetch('/workspace/automations/' + this.editAutomationId + '/steps',
{credentials:'same-origin'});
var d = await r.json();
this.editSteps = Array.isArray(d) ? d : (d.steps || []);
} catch { this.editSteps = []; }
},
stepSummary(s) {
var c = s.config || {};
if (s.kind === 'trigger') return '\ud83d\udce1 D\u00e9clencheur \u00b7 ' + (c.event || '?');
if (s.kind === 'condition') return '\u2696 Condition \u00b7 ' + (c.property || '?') + ' ' + (c.op || 'eq') + ' ' + (c.value || '');
if (s.kind === 'delay') return '\u23f3 Attente \u00b7 ' + (c.seconds || 0) + ' s';
var extra = c.url || c.message || c.text || c.to || (c.owner ? c.owner + '/' + c.repo : '') || c.title || '';
return '\u26a1 Action \u00b7 ' + (c.type || '?') + (extra ? ' \u2192 ' + String(extra).slice(0, 60) : '');
},
defaultStepConfig(kind) {
if (kind === 'trigger') return { event: 'page.created' };
if (kind === 'condition') return { property: '', op: 'eq', value: '' };
if (kind === 'delay') return { seconds: 60 };
return { type: 'webhook', url: '' };
},
async addStep() {
if (!this.editAutomationId) return;
try {
var r = await fetch('/workspace/automations/' + this.editAutomationId + '/steps', {
method: 'POST', credentials:'same-origin',
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
body: JSON.stringify({ kind: this.stepNewKind, config: this.defaultStepConfig(this.stepNewKind) })
});
var d = await r.json();
if (!r.ok) { window.showToast && window.showToast(d.detail || '\u00c9chec', 'error'); return; }
await this.loadSteps();
this.stepEdit = this.editSteps.length - 1;
} catch { /* volontaire */ }
},
async saveStep(s) {
var cfg = s.config || {};
// forge_issue : labels en cha\u00eene s\u00e9par\u00e9e par virgules → liste
if (cfg.type === 'forge_issue' && typeof cfg.labels === 'string') {
cfg.labels = cfg.labels.split(',').map(function(x){ return x.trim(); }).filter(Boolean);
}
try {
var r = await fetch('/workspace/automations/steps/' + s.id, {
method:'PUT', credentials:'same-origin',
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
body: JSON.stringify({ kind: s.kind, config: cfg })
});
var d = await r.json();
if (!r.ok) { window.showToast && window.showToast(d.detail || '\u00c9chec', 'error'); return; }
window.showToast && window.showToast('\u00c9tape enregistr\u00e9e');
await this.loadSteps();
} catch { /* volontaire */ }
},
async delStep(i) {
var s = this.editSteps[i]; if (!s) return;
try {
await fetch('/workspace/automations/steps/' + s.id, {
method:'DELETE', credentials:'same-origin',
headers:{'X-CSRF-Token': this.getCsrfToken()} });
this.stepEdit = -1;
await this.loadSteps();
} catch { /* volontaire */ }
},
async moveStep(i, dir) {
var j = i + dir;
if (j < 0 || j >= this.editSteps.length) return;
var a = this.editSteps[i], b = this.editSteps[j];
var h = {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()};
try {
await fetch('/workspace/automations/steps/' + a.id, {method:'PUT', headers:h,
credentials:'same-origin', body: JSON.stringify({position: b.position})});
await fetch('/workspace/automations/steps/' + b.id, {method:'PUT', headers:h,
credentials:'same-origin', body: JSON.stringify({position: a.position})});
await this.loadSteps();
} catch { /* volontaire */ }
},
async convertToSteps() {
// JSON legacy → pipeline ordonn\u00e9 (trigger, conditions, actions)
if (!this.editAutomationId) return;
var h = {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()};
var base = '/workspace/automations/' + this.editAutomationId + '/steps';
var order = [];
if (this.af.trigger_type !== 'cron') {
var tcfg = {event: this.af.event || 'page.created'};
if (this.af.collection_id) tcfg.collection_id = parseInt(this.af.collection_id, 10);
order.push({kind:'trigger', config: tcfg});
}
try {
JSON.parse(this.af.condition_json || '[]').forEach(function(c){ order.push({kind:'condition', config:c}); });
} catch { /* volontaire */ }
try {
JSON.parse(this.af.actions_json || '[]').forEach(function(a){ order.push({kind:'action', config:a}); });
} catch { /* volontaire */ }
for (var i = 0; i < order.length; i++) {
var r = await fetch(base, {method:'POST', headers:h, credentials:'same-origin',
body: JSON.stringify(order[i])});
if (!r.ok) {
var d = await r.json().catch(function(){ return {}; });
window.showToast && window.showToast(d.detail || 'Conversion \u00e9chou\u00e9e', 'error');
break;
}
}
await this.loadSteps();
},
navTo(sec) {
this.activeSection = sec;
var loaders = {'api-tokens':'loadApiTokens', 'sessions':'loadSessions',
'extensions':'loadClipperDevices', 'automations':'loadAutomations',
'admin-users':'loadAdminUsers', 'admin-audit':'loadAdminAudit',
'admin-backups':'loadBackups', 'admin-sso':'loadSsoConfig'};
if (loaders[sec] && this[loaders[sec]]) this[loaders[sec]]();
},
setAuditSource(s) { this.auditSource = s; this.loadAuditLogs(); },
auditNext() { this.auditOffset += this.auditPageSize; this.loadAuditLogs(false); },
editUserRow(u) {
this.editingUser = u;
this.editUserForm = { login: u.login, name: u.full_name, email: u.email,
is_admin: u.is_admin, is_active: u.is_active };
},
async loadAutomations() {
try { await this.loadCollectionsForForm(); } catch { /* volontaire */ }
try {
@@ -814,9 +953,13 @@ function settingsInit() {
condition_json: a.condition_json || '[]',
actions_json: a.actions_json || '[]'
};
this.stepEdit = -1;
this.loadSteps();
},
cancelEditAutomation() {
this.editAutomationId = null;
this.editSteps = [];
this.stepEdit = -1;
this.af = { name:'', trigger_type:'event', event:'page.created', cron_expression:'*/15 * * * *', collection_id:'', condition_json:'[]', actions_json:'[]' };
},
async toggleAutomation(id, currentlyEnabled) {
@@ -863,7 +1006,7 @@ function settingsInit() {
// ── v5.2.0 API tokens ──
async loadApiTokens() {
try {
var r = await fetch('/api/settings/tokens', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, credentials:'same-origin'});
var r = await fetch('/api/settings/tokens', {headers: {'X-CSRF-Token': getCsrf()}, credentials:'same-origin'});
var d = await r.json();
this.apiTokens = d.tokens || [];
} catch { this.apiTokens = []; }
@@ -873,7 +1016,7 @@ function settingsInit() {
if (!name) return;
try {
var r = await fetch('/api/settings/tokens', {method:'POST', credentials:'same-origin',
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify({name: name})});
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, body: JSON.stringify({name: name})});
var d = await r.json();
if (!r.ok) { window.showToast && window.showToast(d.detail || 'Erreur', 'error'); return; }
this.newToken = d;
@@ -1092,3 +1235,4 @@ function settingsInit() {
},
};
}
}
+10 -4
View File
@@ -1,11 +1,17 @@
/* exported workspacesPage -- appeles depuis les attributs HTML des templates */
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: workspacesPage »).
if (window.Alpine) { Alpine.data('workspacesPage', workspacesPage); fdRefreshXData('workspacesPage'); } else document.addEventListener('alpine:init', function () { Alpine.data('workspacesPage', workspacesPage); });
function workspacesPage() {
return {
workspaces: [],
activeId: null,
showCreate: false,
showRename: false,
closeCreateRename() { this.showCreate = false; this.showRename = false; },
wsName: '',
renameTarget: null,
@@ -41,7 +47,7 @@ function workspacesPage() {
},
async selectLocal(ws) {
await fetch(`/api/workspaces/${ws.id}/select`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'POST'});
await fetch(`/api/workspaces/${ws.id}/select`, {headers: {'X-CSRF-Token': getCsrf()}, method:'POST'});
window.location = '/local-workspace';
},
@@ -49,7 +55,7 @@ function workspacesPage() {
if (!this.wsName.trim()) return;
await fetch('/api/workspaces', {
method:'POST',
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body:JSON.stringify({name:this.wsName.trim()})
});
this.wsName = '';
@@ -67,7 +73,7 @@ function workspacesPage() {
if (!this.wsName.trim()||!this.renameTarget) return;
await fetch(`/api/workspaces/${this.renameTarget.id}`, {
method:'PUT',
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body:JSON.stringify({name:this.wsName.trim()})
});
this.wsName = '';
@@ -78,7 +84,7 @@ function workspacesPage() {
async deleteWs(ws) {
if (!confirm(`Delete workspace "${ws.name}" and all its pages?`)) return;
await fetch(`/api/workspaces/${ws.id}`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE'});
await fetch(`/api/workspaces/${ws.id}`, {headers: {'X-CSRF-Token': getCsrf()}, method:'DELETE'});
await this.load();
},
+21 -8
View File
@@ -8,8 +8,8 @@
═══════════════════════════════════════════════════════════ */
'use strict';
const CACHE_NAME = 'flowdeck-v6';
const DATA_CACHE = 'flowdeck-data-v6';
const CACHE_NAME = 'flowdeck-v8'; // v8 : Alpine CSP build (A20 fini)
const DATA_CACHE = 'flowdeck-data-v7';
// App shell (assets versionnés comme référencés dans les templates).
const PRECACHE_URLS = [
@@ -23,7 +23,7 @@ const PRECACHE_URLS = [
'/static/css/prism.css',
// JS
'/static/js/htmx.min.js',
'/static/js/alpine.min.js',
'/static/js/alpine.csp.min.js',
'/static/js/sortable.min.js',
'/static/js/app.js?v=2.4.8',
'/static/js/offline.js?v=6.0.0',
@@ -156,9 +156,22 @@ async function cacheFirst(request, { cacheName = CACHE_NAME } = {}) {
async function networkFirst(request, { cacheName = CACHE_NAME, fallbackToShell = false, timeoutMs = 4000 } = {}) {
const cache = await caches.open(cacheName);
try {
const res = await timeoutFetch(request, timeoutMs);
let res = await timeoutFetch(request, timeoutMs);
// Les navigations interceptées arrivent en redirect:'manual' : une 302 du
// serveur se lit opaqueredirect (status 0) → on rejoue la requête avec suivi
// explicite. Sans ça, tout logout / redirection rendait la page hors ligne.
if (res.type === 'opaqueredirect') {
// Navigation en redirect:'manual' : on suit la redirection à la main.
res = await timeoutFetch(new Request(request, { redirect: 'follow' }), timeoutMs);
if (res && res.ok && res.redirected) {
// Chromium refuse une response 'redirected' servie à une navigation
// (ERR_FAILED) → on émet une vraie redirection vers l'URL finale.
return new Response(null, { status: 302, headers: { Location: res.url } });
}
}
if (res && res.ok) {
cache.put(request, res.clone());
// Ne pas mettre en cache une réponse de redirection sous l'URL d'origine.
if (!res.redirected) cache.put(request, res.clone());
return res;
}
throw new Error('bad status');
@@ -176,12 +189,12 @@ async function networkFirst(request, { cacheName = CACHE_NAME, fallbackToShell =
function timeoutFetch(request, ms) {
return new Promise((resolve, reject) => {
const ctrl = new AbortController();
const timer = setTimeout(() => {
const controller = new AbortController();
controller.abort();
ctrl.abort();
reject(new Error('timeout'));
}, ms);
fetch(request).then(
fetch(request, { signal: ctrl.signal }).then(
(res) => { clearTimeout(timer); resolve(res); },
(err) => { clearTimeout(timer); reject(err); }
);
+6 -1
View File
@@ -2554,7 +2554,12 @@ def test_all_view_tabs_present(client):
resp = client.get(f"/db/{coll_id}/view/table")
assert resp.status_code == 200
for vt in ["table", "board", "calendar", "gallery", "list", "timeline", "gantt", "chart", "form", "map", "feed"]:
assert f"?view={vt}" in resp.text, f"Missing view tab: {vt}"
# A39/E2E : le nom du paramètre doit être `view_type` (celui de la
# route) — `?view=` était ignoré et l'onglet restait sur Table.
assert f"?view_type={vt}" in resp.text, f"Missing view tab: {vt}"
# et la query commute réellement la vue rendue
resp = client.get(f"/db/{coll_id}?view_type=calendar")
assert 'class="calendar"' in resp.text
def test_view_unknown_falls_back_to_table(client):
+97 -1
View File
@@ -226,7 +226,8 @@ def _assert_nonce(csp: str, html: str) -> str:
assert nm, script_src
nonce = nm.group(1)
assert "'unsafe-inline'" not in script_src, script_src
assert "'unsafe-eval'" in script_src # Alpine/htmx — reste d'A20
# A20 TERMINÉ : Alpine en build CSP (alpine.csp.min.js) + htmx allowEval=false
assert "'unsafe-eval'" not in script_src
assert "script-src-attr 'unsafe-inline'" in csp
tags = [
mm.group(0)
@@ -338,6 +339,101 @@ def test_http_client_shared_and_loop_scoped():
assert second is not first
def test_csrf_server_rendered_no_placeholder(client):
"""A43-1 : `hx-headers` est rendu côté serveur avec le vrai jeton (plus
de `__CSRF_PLACEHOLDER__` servi — la fenêtre de course JS disparaît),
et la valeur vaut le cookie `csrf_token` de la session."""
import json as _json
page = None
for url in ("/", "/dashboard", "/board", "/notes", "/settings", "/workspaces"):
cand = client.get(url)
if cand.status_code == 200 and "htmx-config" in cand.text:
page = cand
break
assert page is not None, "aucune page base.html atteignable"
# 1ʳᵉ visite : cookie créé dans la response → on refait un aller-retour
r = client.get(page.url if hasattr(page, "url") else "/dashboard")
if "htmx-config" not in r.text:
r = page
assert "__CSRF_PLACEHOLDER__" not in r.text, "placeholder servi au navigateur"
import re as _re
m = _re.search(r"hx-headers=\'([^\']*)\'", r.text)
assert m, "attribut hx-headers absent"
token = _json.loads(m.group(1).replace("&quot;", '"'))["X-CSRF-Token"]
cookie = client.cookies.get("csrf_token", "")
assert cookie, "cookie csrf_token absent"
assert token == cookie, (token[:8], cookie[:8])
def test_no_duplicate_global_functions():
"""A38 : aucune fonction `function NAME` GLOBALE (profondeur 0) définie
2+ fois entre les templates et static/js — les paires à risque d'ombre
silencieuse (onDoc, escHtml, openCardDetail…) vivent dans des IIFEs ou
sont dédupliquées (openCardDetail → app.js)."""
import pathlib as _pathlib
import re as _re
files = list(_pathlib.Path("app/templates").glob("*.html")) + list(
_pathlib.Path("static/js").glob("*.js")
)
found: dict[str, list[str]] = {}
for p in files:
s = p.read_text(encoding="utf-8", errors="ignore")
depth = 0
line = 1
i = 0
state = None
n = len(s)
# ponytail: scanner naïve (strings/comments/backticks) — un faux
# positif se voit immédiatement à la lecture du nom signalé
while i < n:
c = s[i]
if c == "\n":
line += 1
if state is None:
if c in ('"', "'"):
state = c
i += 1
continue
if c == "`":
state = c
i += 1
continue
if c == "/" and i + 1 < n and s[i + 1] == "/":
state = "//"
i += 2
continue
if c == "/" and i + 1 < n and s[i + 1] == "*":
state = "/*"
i += 2
continue
if c == "{":
depth += 1
elif c == "}":
depth -= 1
else:
if c == "\\":
i += 2
continue
if (state in ('"', "'") and c == state) or (state == "`" and c == state):
state = None
elif state == "//" and c == "\n":
state = None
elif state == "/*" and c == "*" and i + 1 < n and s[i + 1] == "/":
state = None
i += 2
continue
i += 1
if state is None and depth == 0 and s.startswith("function ", i):
m = _re.match(r"function\s+([A-Za-z_]\w*)", s[i : i + 60])
if m:
found.setdefault(m.group(1), []).append(f"{p.name}:{line}")
dups = {k: v for k, v in found.items() if len(v) >= 2}
assert dups == {}, dups
def test_no_duplicate_routes():
"""A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre."""
from app.main import app
+22
View File
@@ -0,0 +1,22 @@
"""Les `right_actions` du topbar doivent être servis PARSED (A10).
Regresssion historique : `'literal' ~ fd_icon(...)` — fd_icon = macro →
Markup, et `Markup.__radd__/__add__` échappe les segments littéraux →
boutons livrés en entities (`&#34;`) sur TOUTES les pages. Fix : block-set
`{% set x %}…{{ fd_icon() }}…{% endset %}` (5 templates).
"""
from tests.conftest import login_test_client
def test_right_actions_parsed(client):
login_test_client(client)
r = client.get("/workspaces")
assert r.status_code == 200
body = r.text
parsed = 'class="topbar-btn"' in body
escaped = "&#34;topbar-btn&#34;" in body
print("PARSE:", parsed, "| ESCAPED:", escaped)
print("DBG present:", "DBGCLS" in body)
k = body.find("DBGCLS=")
print("CTX:", body[max(0, k - 80) : k + 320].replace("\n", " ") if k >= 0 else "pas de DBG")
assert parsed and not escaped, "right_actions servi échappé"