Compare commits

..
7 Commits
Author SHA1 Message Date
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
45 changed files with 1100 additions and 119 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=""
+227
View File
@@ -1,5 +1,232 @@
# Changelog - FlowDeck
## 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
+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
+24 -4
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
7.33.0
7.40.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.33.0 (audit — A43 TERMINÉ + A38 phase 1 CSRF : 76 lectures → getCsrf() | **Statut**: EN COURS 🔄
> **Début**: 2026-07-08 | **Version**: v7.40.0 (A20 ph3 LOT 3a : welcome/trash/accounts/workspace/import verts — 8 surfaces CSP couvertes, bug x-text import corrigé) | **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.33.0",
version="7.40.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+6 -4
View File
@@ -71,14 +71,16 @@ 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).
# `unsafe-eval` : Alpine STANDARD (x-data) en a besoin. htmx n'y touche
# plus (`allowEval: false` dans le meta htmx-config — 0 hx-on/hx-vars).
# Retrait = A20 phase 3 : build `@alpinejs/csp` (testé : 0 eval, OK sur
# probe) mais bloqué par 13 expressions non parsables (arrows/typeof/new/
# ?.) + 24 `x-html` réactifs (icônes SVG) → refonte des composants en
# Alpine.data — voir ROADMAP.
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-attr 'unsafe-inline'; "
# ponytail: aucun @font-face Google (grep négatif) → les deux
+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>
+5
View File
@@ -105,6 +105,11 @@
{% 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 »).
document.addEventListener('alpine:init', function () { Alpine.data('accountsData', accountsData); });
function accountsData() {
return {
profile: { full_name: '', email: '' },
+52 -15
View File
@@ -115,7 +115,7 @@
</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
@@ -188,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"
@@ -272,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>
@@ -308,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>
@@ -330,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>
@@ -407,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>
@@ -541,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>
@@ -595,7 +590,7 @@
<!-- 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)">
<span class="smi-label">Show 5 items</span>
@@ -1127,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() {
@@ -1568,6 +1569,42 @@
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); });
},
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;
+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) -->
+5
View File
@@ -100,6 +100,11 @@
</div>
<script nonce="{{ csp_nonce() }}">
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: cardDetail »).
document.addEventListener('alpine:init', function () { Alpine.data('cardDetail', cardDetail); });
function cardDetail() {
return {
updateField(field, value) {
+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) -->
+1 -1
View File
@@ -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>
+6 -6
View File
@@ -255,7 +255,7 @@
<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">
@@ -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">
+27 -24
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)"
@@ -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 -->
+7 -7
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>
@@ -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>
@@ -770,7 +770,7 @@
<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;">
@@ -837,7 +837,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>
+5
View File
@@ -100,6 +100,11 @@
</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 »).
document.addEventListener('alpine:init', function () { Alpine.data('tableView', tableView); });
function tableView() {
return {
sortField: '',
+5
View File
@@ -44,6 +44,11 @@
</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 »).
document.addEventListener('alpine:init', function () { Alpine.data('teamLoad', teamLoad); });
function teamLoad() {
return {};
}
+5
View File
@@ -63,6 +63,11 @@
{% 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 »).
document.addEventListener('alpine:init', function () { Alpine.data('trashData', trashData); });
function trashData() {
return {
search: '',
+5
View File
@@ -134,6 +134,11 @@ 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 »).
document.addEventListener('alpine:init', function () { Alpine.data('onboarding', onboarding); });
function onboarding() {
return {
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
+6 -1
View File
@@ -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,11 @@
</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 »).
document.addEventListener('alpine:init', function () { Alpine.data('workspacePage', workspacePage); });
function workspacePage() {
return {
builtinProjects: [],
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.33.0"
"version": "7.40.0"
},
"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();
})();
+139
View File
@@ -0,0 +1,139 @@
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;
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']) {
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');
}
});
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
+167
View File
@@ -0,0 +1,167 @@
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);
});
+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"]
+83 -1
View File
@@ -3,7 +3,89 @@
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;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/static/fonts/inter-400-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;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/static/fonts/inter-500-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: 500;
font-display: swap;
src: url('/static/fonts/inter-500-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;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/static/fonts/inter-600-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: 600;
font-display: swap;
src: url('/static/fonts/inter-600-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;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/static/fonts/inter-700-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: 700;
font-display: swap;
src: url('/static/fonts/inter-700-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.
+13
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.
@@ -395,3 +396,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;
}
+22 -2
View File
@@ -18,6 +18,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 »).
document.addEventListener('alpine:init', function () { Alpine.data('kanbanBoard', kanbanBoard); });
function kanbanBoard() {
return {
collapsedGroups: [],
@@ -33,6 +38,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 »).
document.addEventListener('alpine:init', function () { Alpine.data('filterSystem', filterSystem); });
function filterSystem() {
return {
activeFilters: [],
@@ -67,6 +77,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 »).
document.addEventListener('alpine:init', function () { Alpine.data('sortSystem', sortSystem); });
function sortSystem() {
return {
sorts: [],
@@ -89,12 +104,17 @@ 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 »).
document.addEventListener('alpine:init', function () { Alpine.data('newIssueForm', newIssueForm); });
function newIssueForm() {
return {
title: '', status: 'todo',
create() {
if (!this.title.trim()) return;
const csrf = getCsrf();;
const csrf = getCsrf();
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, {
method: 'POST',
headers: { 'X-CSRF-Token': csrf }
@@ -131,7 +151,7 @@ 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 = getCsrf();;
const csrf = getCsrf();
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, {
method: 'POST',
headers: { 'X-CSRF-Token': csrf }
+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 »).
document.addEventListener('alpine:init', function () { Alpine.data('importWizard', importWizard); });
function importWizard() {
return {
sources: [],
+28 -2
View File
@@ -1,5 +1,11 @@
/* 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 »).
document.addEventListener('alpine:init', function () {
Alpine.data('libraryPage', libraryPage);
});
function libraryPage() {
return {
tab: 'recents',
@@ -160,7 +166,27 @@ 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.
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,7 +389,7 @@ function libraryPage() {
},
_getCsrf() {
var m = getCsrf();;
var m = getCsrf();
return m;
},
+65 -6
View File
@@ -1,7 +1,8 @@
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:[],
@@ -2007,13 +2008,71 @@ 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) ──
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(window._wsInitData).length);
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).
document.addEventListener('alpine:init', 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;
});
});
+5
View File
@@ -2512,5 +2512,10 @@ 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 »).
document.addEventListener('alpine:init', function () { Alpine.data('editorState', editorState); });
window.editorState = editorState;
}
+12
View File
@@ -1,8 +1,20 @@
/* 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 »).
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: '',
+5
View File
@@ -1,5 +1,10 @@
/* 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 »).
document.addEventListener('alpine:init', function () { Alpine.data('workspacesPage', workspacesPage); });
function workspacesPage() {
return {
workspaces: [],
+2 -2
View File
@@ -8,8 +8,8 @@
═══════════════════════════════════════════════════════════ */
'use strict';
const CACHE_NAME = 'flowdeck-v6';
const DATA_CACHE = 'flowdeck-data-v6';
const CACHE_NAME = 'flowdeck-v7'; // v7 : A20 (htmx allowEval off, Inter auto-hébergé)
const DATA_CACHE = 'flowdeck-data-v7';
// App shell (assets versionnés comme référencés dans les templates).
const PRECACHE_URLS = [
+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):
+69 -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
assert "'unsafe-eval'" in script_src # reste A20 phase 3 (Alpine standard,
# build CSP bloqué : 13 exprs non parsables + 24 x-html réactifs → ROADMAP)
assert "script-src-attr 'unsafe-inline'" in csp
tags = [
mm.group(0)
@@ -366,6 +367,73 @@ def test_csrf_server_rendered_no_placeholder(client):
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