Compare commits

...
6 Commits
Author SHA1 Message Date
bruno 6582df3bcb feat: poubelle flexible (sélection groupée, purge, tri/filtres, dates) + 4 bugs (v7.45.5)
FlowDeck CI / docker (push) Successful in 1m52s
FlowDeck CI / lint (push) Successful in 2m1s
FlowDeck CI / test (push) Successful in 15m21s
Page /trash alignée sur ce que fait ce type de section :
- sélection multiple (Select all porté au filtre courant) + barre d'actions
  groupées Restore / Delete / Clear ;
- Empty Trash via POST /board/api/trash/empty (purge en masse, 1 requête) ;
- tri Recently deleted / Oldest first / Name et filtre emplacement RÉELS —
  remplacent les deux boutons décoratifs « Last edited by ▾ » et « In ▾ » qui
  ne faisaient rien ;
- date de suppression + jours restants par élément (rétention 30 j alignée sur
  app/services/trash.py, horodatages UTC/ISO gérés) ;
- compteur de résultats, états vides distincts (vide vs filtre sans résultat +
  Clear filters), toasts sur chaque action.

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

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

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

Gates : probe_nav_perf + regression_partial_nav + regression_editor_mount +
regression_logout_dnd + smoke · pytest 1094 passed · ruff OK ·
OpenAPI 7.45.3.
2026-10-03 00:15:41 -04:00
bruno afbc236cc2 fix: navigation partielle Alpine sans course + scripts de page idempotents (v7.45.2)
FlowDeck CI / docker (push) Successful in 1m51s
FlowDeck CI / lint (push) Successful in 1m57s
FlowDeck CI / test (push) Successful in 15m16s
- app.js : x-ignore sur .main-wrapper au swap fdLoad (nœud remplacé
  uniquement), démontage unique quand tous les <script src> ont exécuté
  (load/error + filet 4s) via Alpine.initTree idempotent (_x_marker) —
  fini les cascades « Undefined variable » (éditeur) et « reading 'has' »
  ($store.fdCtx) : la zone s'initialisait avant composants et stores.
- 5 scripts de page gardés contre la ré-exécution (SyntaxError
  « Identifier 'LW' has already been declared » à la 2e visite
  partielle) : local_workspace, board, settings, database_table,
  page_editor_realtime.
- if (window.Alpine) → Alpine.data immédiat sinon listener alpine:init
  (déjà passé sur swap) : 8 scripts + 7 templates inline.
- app.css : purge des 7 blocs @font-face Inter orphelins (fichiers
  inexistants → 302 HTML → « Failed to decode downloaded font »).
- Gates : e2e/regression_editor_mount + e2e/regression_partial_nav
  (7 pages x complet/1er/2e passage) + regression_logout_dnd + smoke
  = 6/6 verts sur l'image rebuildée · pytest 1094 passed · ruff OK ·
  OpenAPI 7.45.2.
2026-10-02 21:29:46 -04:00
bruno 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
38 changed files with 1524 additions and 143 deletions
+178
View File
@@ -1,5 +1,183 @@
# Changelog - FlowDeck # Changelog - FlowDeck
## v7.45.5 (2026-10-03) — Poubelle : actions groupées, tri/filtres réels, purge, et 4 bugs
### Added
- **Sélection multiple** sur /trash : case « Select all » (portée au filtre
courant), barre d'actions groupées **Restore / Delete / Clear** avec compteur,
et compteur de résultats (`N pages` / `N of M pages`).
- **Empty Trash** : purge totale en un clic (confirm) via le nouveau
`POST /board/api/trash/empty` — traitement en masse côté serveur (une seule
requête, pas N appels).
- **Tri** : Recently deleted (défaut) / Oldest first / Page name A → Z — remplace
le bouton décoratif « Last edited by ▾ » qui ne faisait rien.
- **Filtre par emplacement** réel (`In ▾` décoratif remplacé) : liste déroulante
construite depuis les workspaces présents dans la poubelle, avec état actif.
- **Dates par élément** : « Deleted 3 days ago · 27 days left » (rétention
30 j alignée sur `app/services/trash.py`), horodatages UTC ou ISO gérés.
- **États vides distincts** : poubelle vide vs aucun résultat pour les filtres
(avec bouton « Clear filters »), et **toasts** sur chaque action (succès et
échec) au lieu d'échecs silencieux.
### Fixed
- **Les boutons Restore / Delete ne marchaient JAMAIS** : `getCsrf()` renvoie la
CHAÎNE du jeton mais le code faisait `csrf?.[1]` → 2ᵉ caractère → 403 CSRF
systématique, avalé par `if (r.ok)`. Corrigé + toasts d'erreur.
- **« Move to Trash » depuis l'éditeur envoyait une 404** : la route appelée
(`/board/api/pages/{id}/trash`) n'existe pas — route réelle
`/api/pages/{id}/trash` — et le `.then()` naviguait quand même, donc la page
partait à l'accueil SANS être mise à la poubelle. URL corrigée + on ne
navigue plus si la réponse n'est pas ok.
- **Une page restaurée disparaissait de Library / Recents / Private** : la
suppression depuis l'éditeur réécrivait `parent_section='Trash'` et la
restauration ne le remettait pas, alors que tous les listings filtrent
`parent_section != 'Trash'`. `deleted_at` est maintenant la seule source de
vérité (écriture retirée) + réparation au boot des lignes déjà cassées
(`db.init_db`, idempotente).
- **Routes trash sans aucune authentification** : `GET /board/api/trash`,
`restore`, `delete` et `POST /api/pages/{id}/trash` ne vérifiaient rien (le
CSRF ne protège pas : cookie lisible + en-tête forgé par l'attaquant) → 401
obligatoire, comme les routes sœurs du même fichier. Vérifié anonyme : 401.
- Le sidebar de /trash listait `parent_section='Trash'` → aligné sur
`deleted_at IS NOT NULL`.
### Tests
- `tests/test_trash_api.py` (5 tests) : auth 401, soft-delete sans marqueur
`Trash`, restauration, suppression définitive, purge en masse (ne touche pas
aux pages vivantes), réparation au boot.
- `e2e/trash_ui.spec.js` : rendu, dates, sélection groupée, restauration
unitaire (le vrai test du fix CSRF), purge — noms uniques par exécution et
garde « on ne vide jamais la poubelle d'autrui ».
- `test_smoke_uncovered.py` : l'assertion qui codait le bug
(`parent_section == 'Trash'`) corrigée.
## 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) ## v7.45.0 (2026-10-01) — Éditeur visuel d'automations + correction CSP (multi-instructions)
### Added ### Added
+7 -1
View File
@@ -1001,7 +1001,13 @@ Détails livrés :
- [x] **Migrations 26** — `automation_steps`, `workers`, `worker_runs`, `automations.trigger_mode`, `collection_properties.button_automation_id` - [x] **Migrations 26** — `automation_steps`, `workers`, `worker_runs`, `automations.trigger_mode`, `collection_properties.button_automation_id`
- [x] **Tests** — `tests/test_v70_automations_workers.py` (**31 tests** : migration, steps CRUD/validation/auth, mode any/all, `form.submitted`, chaînes + interpolation, condition, delay, slack + secret chiffré, email no-SMTP, forge mock + sans-token, agent mock + 404, button press/validation, legacy single-run, workers CRUD/auth/rejet code/run ok/error/timeout/budget/fork/privacy/usage/cron/masquage code) - [x] **Tests** — `tests/test_v70_automations_workers.py` (**31 tests** : migration, steps CRUD/validation/auth, mode any/all, `form.submitted`, chaînes + interpolation, condition, delay, slack + secret chiffré, email no-SMTP, forge mock + sans-token, agent mock + 404, button press/validation, legacy single-run, workers CRUD/auth/rejet code/run ok/error/timeout/budget/fork/privacy/usage/cron/masquage code)
- [x] **Version** — 7.0.0 (`VERSION` + `app/main.py`) · `ruff check` OK - [x] **Version** — 7.0.0 (`VERSION` + `app/main.py`) · `ruff check` OK
- [x] **Éditeur visuel** — **pipeline steps** dans Settings → Automations (v7.45.0) : cartes ordonnées (trigger/condition/delay/action) avec **config typée par type** (8 actions : webhook/set_property/create_page/notify/slack/email/forge_issue/agent_trigger + ops condition + datalist événements), ajout/édition/suppression/réordonnancement (↑↓) via l'API `/steps` + bouton **✨ Convertir le JSON en pipeline** (legacy → steps ordonnés). **Bonus trouvé par le gate** : **51 expressions multi-instructions** (`a=1; b()` — `;` = SEULE expression par directive interdit sous le parseur CSP, non couvert par le scan `tokens`) → converties en méthodes dans **11 fichiers** (nav settings ×8, menu section base ×7, partages/éditeur ×13, breadcrumb ×5, lib/local ×6, board ×3, ctx/agent/workspaces/card/gitea ×6). Nouveau scanner `scan_semi` ajouté au lot. - [x] **Éditeur visuel** — **pipeline steps** dans Settings → Automations (v7.45.0) : cartes ordonnées (trigger/condition/delay/action) avec **config typée par type** (8 actions : webhook/set_property/create_page/notify/slack/email/forge_issue/agent_trigger + ops condition + datalist événements), ajout/édition/suppression/**↑↓** via l'API `/steps` + bouton **✨ Convertir le JSON en pipeline** (legacy → steps ordonnés). **Bonus trouvé par le gate** : **51 expressions multi-instructions** (`a=1; b()` — `;` = SEULE expression par directive interdit sous le parseur CSP, non couvert par le scan `tokens`) → converties en méthodes dans **11 fichiers** (nav settings ×8, menu section base ×7, partages/éditeur ×13, breadcrumb ×5, lib/local ×6, board ×3, ctx/agent/workspaces/card/gitea ×6). Nouveau scanner `scan_semi` ajouté au lot.
- [x] **Fix SW + CSRF uploads** (v7.45.1) — logout ne sert plus la page « hors ligne » (le fetch event de navigation est en `redirect:'manual'` → 302 lue `opaqueredirect` → rejet ; rejoué en `redirect:'follow'` + **302 synthétique vers l'URL finale**, Chromium refusant les responses `redirected` servies à une navigation) et drag & drop de fichiers/dossiers réparé (`_doUpload` + `toggleFavorite` sans `X-CSRF-Token` = oublis A19, derniers appels mutants du front). `timeoutFetch` annule enfin ses requêtes. Gate `e2e/regression_logout_dnd.spec.js` (2 tests verts).
- [x] **Navigation partielle Alpine** (v7.45.2) — les swaps fdLoad initialisaient `.main-wrapper` avant l'exécution des scripts de page (courses : « Undefined variable » sur l'éditeur, `reading 'has'` sur `$store.fdCtx`, `SyntaxError: 'LW' already declared` à la 2ᵉ visite). Fix maison dans `app.js` : `x-ignore` sur la zone swapée (nœud remplacé uniquement) → démontage unique à tous `<script src>` chargés via `Alpine.initTree` idempotent ; garde de fichier sur 5 scripts à `const` de haut niveau ; `if (window.Alpine)` sur 8 scripts + 7 templates inline ; polices Inter orphelines purgées d'`app.css`. Gates : `regression_editor_mount` + `regression_partial_nav` (7 pages × complet/partiel/2ᵉ = 0 erreur).
- [x] **Flashs de navigation** (v7.45.3) — pendant la fenêtre `x-ignore` (swap fdLoad → `Alpine.initTree`), la zone `.main-wrapper` était peinte **non montée** : tous ses `[x-show]` visibles à leur valeur brute (menus/panneaux/états loading+empty+table superposés) puis masqués d'un coup au montage = la rafale de fenêtres qui flashent à chaque changement de page/section. Fix 6 lignes dans `app.js` : `opacity:0` + `pointer-events:none` posés dans `htmx:afterSwap` (même task que le swap → aucun paint intermédiaire), retirés **après** `initTree` sur les 3 chemins de démontage. Mesure avant : 19 éléments bruts/87 ms sur /library (CLS 0.149), 80 éléments/732 ms sur /settings — après : 0 frame de contenu brut. Gate maison `e2e/probe_nav_perf.spec.js` (marques htmx + fenêtre x-ignore + échantillon par frame + layout-shift).
- Suivis restants (mesurés, non traités) : CLS résiduel **0.058** sur /library (états `lib-loading`/`lib-empty` → `lib-table` : le remplacement du spinner par les lignes décale le contenu — nécessiterait un squelette de lignes, pas un réglage CSS) · fenêtre `x-ignore` de **~150–300 ms** sur /settings (coût des scripts de page avant `initTree`, pas de gain sans changer l'ordre de montage) · la **topbar est dans `.main-wrapper`** (base.html) → re-swappée et re-montée à chaque navigation (avec le masquage elle disparaît/revient au lieu de flasher — la sortir demanderait de la swapper séparément).
- Suite v7.45.4 : `x-cloak` posé sur `.app-layout` (racine Alpine) → les **chargements complets** (F5, SW, hors ligne) ne peignent plus la sidebar brute ni la bande rouge hors ligne — le masquage fdLoad ne couvrait que les swaps partiels. `fd-navigating` (classe sans règle CSS) supprimée. Probe étendu à 4 scénarios (nav / clic Home réel / Home hors ligne / plein chargement) avec assertions. **Symptôme Home non rejouable** en navigateur frais (0 frame brut, 0 erreur console) → à confirmer sur le navigateur signalé.
- [x] **Poubelle** (v7.45.5) — page /trash alignée sur ce que fait ce type de section : **sélection multiple** (select all porté au filtre courant + barre Restore/Delete/Clear), **Empty Trash** (`POST /board/api/trash/empty`, purge en masse en 1 requête), **tri** (récents/anciens/nom) et **filtre emplacement** réels (les deux boutons décoratifs « Last edited by ▾ » / « In ▾ » qui ne faisaient rien sont remplacés), **date de suppression + jours restants** par élément (rétention 30 j), compteur de résultats, états vides distincts, toasts sur chaque action. 4 bugs trouvés en route : (1) **Restore/Delete ne marchaient jamais** (`getCsrf()` = chaîne mais code lu `csrf?.[1]` → 403 CSRF silencieux), (2) **« Move to Trash » de l'éditeur = 404** (route `/board/api/pages/{id}/trash` inexistante, `.then()` naviguait quand même → la page n'allait jamais à la poubelle), (3) **page restaurée invisible** en Library/Recents/Private (`parent_section='Trash'` réécrit au delete, jamais remis → `deleted_at` = source de vérité unique + réparation au boot), (4) **routes trash sans auth** (401 ajouté, vérifié anonyme). Gates : `tests/test_trash_api.py` (5) + `e2e/trash_ui.spec.js` (1) + suite 1099 passed.
### v7.1.0 — Calendar sync + Meeting Notes ✅ (2026-09-28) ### v7.1.0 — Calendar sync + Meeting Notes ✅ (2026-09-28)
> **Objectif** : calendrier bidirectionnel + transcription → agents (cf. Notion 07/2026 : Meeting Notes trigger Custom Agents). > **Objectif** : calendrier bidirectionnel + transcription → agents (cf. Notion 07/2026 : Meeting Notes trigger Custom Agents).
+1 -1
View File
@@ -1 +1 @@
7.45.0 7.45.5
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone # WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.45.0 (Éditeur visuel d'automations = pipeline steps CRUD + conversion JSON + **fix CSP : 51 expressions multi-instructions → méthodes** (30 fichiers-tpl)) | **Statut**: EN COURS 🔄 > **Début**: 2026-07-08 | **Version**: v7.45.5 (poubelle : sélection groupée + Empty Trash + tri/filtres réels + dates par élément ; fix CSRF restore/delete, fix 404 « Move to Trash » éditeur, fix pages restaurées invisibles, auth sur les routes trash) | **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` > **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 ## Avancement Global
+10
View File
@@ -348,6 +348,16 @@ def init_db():
conn.execute("ALTER TABLE pages ADD COLUMN deleted_at TEXT") conn.execute("ALTER TABLE pages ADD COLUMN deleted_at TEXT")
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass pass
# v7.45.5 : réparation des pages restaurées marquées 'Trash' — l'ancien
# soft-delete (éditeur) réécrivait parent_section='Trash' et la
# restauration ne le remettait pas → page invisible en Library/Recents/
# Private. Idempotent (WHERE restrictif), rejoué à chaque boot.
# ponytail: un dossier restauré repasse en 'Private' (l'état d'origine
# n'est pas stocké) → icône/dossier à remettre à 'Workspace' si besoin.
conn.execute(
"UPDATE pages SET parent_section='Private' "
"WHERE parent_section='Trash' AND deleted_at IS NULL"
)
try: try:
conn.execute("ALTER TABLE pages ADD COLUMN share_mode TEXT DEFAULT 'private'") conn.execute("ALTER TABLE pages ADD COLUMN share_mode TEXT DEFAULT 'private'")
except sqlite3.OperationalError: except sqlite3.OperationalError:
+1 -1
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="7.45.0", version="7.45.5",
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
lifespan=lifespan, lifespan=lifespan,
+45 -4
View File
@@ -125,21 +125,41 @@ def unpublish_page(request: Request, page_id: int):
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════ # ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
def _trash_session(request: Request):
"""Trash : session obligatoire (les 3 routes mutaient/ lisaient toutes les
pages sans aucune vérification — le CSRF seul ne protège pas, le cookie est
lisible par JS et un attaquant fixe cookie ET en-tête)."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
raise HTTPException(401, "Authentication required")
return user
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
@router.get("/api/trash") @router.get("/api/trash")
def list_trash(request: Request): def list_trash(request: Request):
_trash_session(request)
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute("SELECT id, title, workspace, parent_id, deleted_at FROM pages WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall() rows = conn.execute(
return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows] "SELECT id, title, workspace, parent_section, deleted_at FROM pages "
"WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC"
).fetchall()
return [
{
"id": r["id"],
"name": r["title"] or "Untitled",
"icon": "📁" if r["parent_section"] == "Workspace" else "📄",
"path": r["workspace"] or "Private",
"deleted_at": r["deleted_at"],
}
for r in rows
]
@router.post("/api/trash/{page_id}/restore") @router.post("/api/trash/{page_id}/restore")
def restore_page(request: Request, page_id: int): def restore_page(request: Request, page_id: int):
_trash_session(request)
with get_conn() as conn: with get_conn() as conn:
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,)) conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
conn.commit() conn.commit()
@@ -154,6 +174,7 @@ def restore_page(request: Request, page_id: int):
@router.delete("/api/trash/{page_id}") @router.delete("/api/trash/{page_id}")
def permanent_delete(request: Request, page_id: int): def permanent_delete(request: Request, page_id: int):
_trash_session(request)
with get_conn() as conn: with get_conn() as conn:
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,)) conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,)) conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
@@ -163,6 +184,26 @@ def permanent_delete(request: Request, page_id: int):
@router.post("/api/trash/empty")
def empty_trash(request: Request):
"""Empty Trash : purge en masse (les enfants des pages supprimées passent
en racine, comme permanent_delete — comportement historique conservé)."""
_trash_session(request)
with get_conn() as conn:
n = conn.execute(
"SELECT COUNT(*) FROM pages WHERE deleted_at IS NOT NULL"
).fetchone()[0]
conn.execute(
"UPDATE pages SET parent_id=NULL WHERE parent_id IN "
"(SELECT id FROM pages WHERE deleted_at IS NOT NULL)"
)
conn.execute("DELETE FROM pages WHERE deleted_at IS NOT NULL")
conn.commit()
return {"status": "ok", "deleted": n}
@router.get("/trash", response_class=HTMLResponse) @router.get("/trash", response_class=HTMLResponse)
def trash_page(request: Request): def trash_page(request: Request):
from app.templating import ENV from app.templating import ENV
+13 -4
View File
@@ -6,7 +6,7 @@ from __future__ import annotations
import logging import logging
from fastapi import APIRouter, Body, Request from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.auth.session import SessionManager from app.auth.session import SessionManager
@@ -62,11 +62,20 @@ def api_rename_page(page_id: int, request: Request, body: dict = Body(default={}
@router.post("/api/pages/{page_id:int}/trash") @router.post("/api/pages/{page_id:int}/trash")
def api_trash_page(page_id: int): def api_trash_page(page_id: int, request: Request):
"""Soft-delete a page (move to trash).""" """Soft-delete a page (move to trash).
v7.45.5 : ``parent_section`` n'est plus réécrit en 'Trash' — ``deleted_at``
est la seule source de vérité. L'ancienne écriture marquait définitivement
la page : à la restauration elle restait 'Trash' et disparaissait des
listings Library / Recents / Private (``parent_section != 'Trash'``).
"""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
raise HTTPException(401, "Authentication required")
with get_conn() as conn: with get_conn() as conn:
conn.execute( conn.execute(
"UPDATE pages SET parent_section='Trash', deleted_at=CURRENT_TIMESTAMP WHERE id=?", "UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id=?",
(page_id,), (page_id,),
) )
conn.commit() conn.commit()
+4 -3
View File
@@ -29,15 +29,16 @@ def trash_page(request: Request, owner: str = Query(default=""), repo: str = Que
sidebar = board_sidebar(request, owner, repo) sidebar = board_sidebar(request, owner, repo)
with get_conn() as conn: with get_conn() as conn:
ws_key = f"{owner}/{repo}" if owner and repo else "" ws_key = f"{owner}/{repo}" if owner and repo else ""
# Pages are soft-deleted via parent_section='Trash' # Pages are soft-deleted via deleted_at (parent_section n'est plus
# touché par le trash → source de vérité unique, v7.45.5)
if ws_key: if ws_key:
rows = conn.execute( rows = conn.execute(
"SELECT id, title, workspace FROM pages WHERE parent_section='Trash' AND workspace=? ORDER BY updated_at DESC", "SELECT id, title, workspace FROM pages WHERE deleted_at IS NOT NULL AND workspace=? ORDER BY updated_at DESC",
(ws_key,), (ws_key,),
).fetchall() ).fetchall()
else: else:
rows = conn.execute( rows = conn.execute(
"SELECT id, title, workspace FROM pages WHERE parent_section='Trash' ORDER BY updated_at DESC", "SELECT id, title, workspace FROM pages WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC",
).fetchall() ).fetchall()
sidebar["trash_items"] = [{"id": r["id"], "name": r["title"] or "Untitled", "workspace": r["workspace"]} for r in rows] sidebar["trash_items"] = [{"id": r["id"], "name": r["title"] or "Untitled", "workspace": r["workspace"]} for r in rows]
# Pass active workspace for breadcrumb nav menu # Pass active workspace for breadcrumb nav menu
+2 -1
View File
@@ -109,7 +109,8 @@
// globales window — probe « Undefined variable: accountsData »). // globales window — probe « Undefined variable: accountsData »).
document.addEventListener('alpine:init', function () { Alpine.data('accountsData', accountsData); }); if (window.Alpine) { Alpine.data('accountsData', accountsData); }
else document.addEventListener('alpine:init', function () { Alpine.data('accountsData', accountsData); });
function accountsData() { function accountsData() {
return { return {
profile: { full_name: '', email: '' }, profile: { full_name: '', email: '' },
+1 -1
View File
@@ -130,7 +130,7 @@
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script> <script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
</head> </head>
<body hx-headers='{"X-CSRF-Token":{{ csrf_token() | tojson }}}'{% if embed_mode %} class="embed-mode"{% endif %}> <body hx-headers='{"X-CSRF-Token":{{ csrf_token() | tojson }}}'{% if embed_mode %} class="embed-mode"{% endif %}>
<div class="app-layout" x-data="appState()"> <div class="app-layout" x-data="appState()" x-cloak>
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ --> <!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
<div class="sidebar-overlay" <div class="sidebar-overlay"
+2 -1
View File
@@ -104,7 +104,8 @@
// globales window — probe « Undefined variable: cardDetail »). // globales window — probe « Undefined variable: cardDetail »).
document.addEventListener('alpine:init', function () { Alpine.data('cardDetail', cardDetail); }); if (window.Alpine) { Alpine.data('cardDetail', cardDetail); }
else document.addEventListener('alpine:init', function () { Alpine.data('cardDetail', cardDetail); });
function cardDetail() { function cardDetail() {
return { return {
updateField(field, value) { updateField(field, value) {
+2 -1
View File
@@ -104,7 +104,8 @@
// globales window — probe « Undefined variable: tableView »). // globales window — probe « Undefined variable: tableView »).
document.addEventListener('alpine:init', function () { Alpine.data('tableView', tableView); }); if (window.Alpine) { Alpine.data('tableView', tableView); }
else document.addEventListener('alpine:init', function () { Alpine.data('tableView', tableView); });
function tableView() { function tableView() {
return { return {
sortField: '', sortField: '',
+2 -1
View File
@@ -48,7 +48,8 @@
// globales window — probe « Undefined variable: teamLoad »). // globales window — probe « Undefined variable: teamLoad »).
document.addEventListener('alpine:init', function () { Alpine.data('teamLoad', teamLoad); }); if (window.Alpine) { Alpine.data('teamLoad', teamLoad); }
else document.addEventListener('alpine:init', function () { Alpine.data('teamLoad', teamLoad); });
function teamLoad() { function teamLoad() {
return {}; return {};
} }
+203 -27
View File
@@ -12,45 +12,93 @@
</div> </div>
<div x-data="trashData()" style="padding: 0 24px; max-width: 800px;"> <div x-data="trashData()" style="padding: 0 24px; max-width: 800px;">
<!-- Search --> <!-- Toolbar : recherche + tri + emplacement + purge -->
<div style="position:relative; margin-bottom:12px;"> <div style="display:flex; align-items:center; gap:8px; margin-bottom:12px;">
<div style="position:relative; flex:1;">
<span style="position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--text-dim);">{{ fd_icon("search",14) }}</span> <span style="position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--text-dim);">{{ fd_icon("search",14) }}</span>
<input type="text" placeholder="Search pages in Trash" x-model="search" <input type="text" placeholder="Search pages in Trash" x-model="search"
style="width:100%; padding:8px 12px 8px 36px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary); font-size:14px; outline:none; box-sizing:border-box;"> style="width:100%; padding:8px 12px 8px 36px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary); font-size:14px; outline:none; box-sizing:border-box;">
</div> </div>
<!-- Filters --> <!-- Tri -->
<div style="display:flex; gap:8px; margin-bottom:16px;"> <div class="lib-toolbar-wrap">
<button class="trash-filter active"> <button class="lib-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSort()">
<span style="color:var(--accent);">{{ fd_icon("user",14) }}</span> Last edited by ▾ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
</button> </button>
<button class="trash-filter"> <div class="lib-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition>
<span>{{ fd_icon("folder",14) }}</span> In ▾ <div class="dd-label">Sort by</div>
<div class="dd-item" :class="{active: sort==='recent'}" @click="setSort('recent')"><span class="check" x-text="sort==='recent' ? '✓' : ''"></span> Recently deleted</div>
<div class="dd-item" :class="{active: sort==='oldest'}" @click="setSort('oldest')"><span class="check" x-text="sort==='oldest' ? '✓' : ''"></span> Oldest first</div>
<div class="dd-item" :class="{active: sort==='name'}" @click="setSort('name')"><span class="check" x-text="sort==='name' ? '✓' : ''"></span> Page name A → Z</div>
</div>
</div>
<!-- Emplacement -->
<div class="lib-toolbar-wrap" x-show="locations.length > 1">
<button class="lib-icon-btn" :class="{active: locOpen || locFilter !== 'all'}" title="Filter by location" @click.stop="toggleLoc()">
{{ fd_icon("folder",16) }}
</button> </button>
<div class="lib-dropdown" x-show="locOpen" @click.outside="locOpen=false" x-transition>
<div class="dd-label">Location</div>
<div class="dd-item" :class="{active: locFilter==='all'}" @click="setLoc('all')"><span class="check" x-text="locFilter==='all' ? '✓' : ''"></span> All locations</div>
<template x-for="l in locations" :key="l">
<div class="dd-item" :class="{active: locFilter===l}" @click="setLoc(l)"><span class="check" x-text="locFilter===l ? '✓' : ''"></span> <span x-text="l"></span></div>
</template>
</div>
</div>
<!-- Purge totale -->
<button class="btn-sm btn-sm-danger" x-show="items.length" @click="emptyTrash()">Empty Trash</button>
</div>
<!-- Sélection + compteur + actions groupées -->
<div style="display:flex; align-items:center; gap:14px; margin-bottom:8px; font-size:13px; color:var(--text-dim);" x-show="items.length">
<label style="display:flex; align-items:center; gap:6px; cursor:pointer;">
<input type="checkbox" style="accent-color:var(--accent); cursor:pointer;" :checked="allSelected" @change="toggleAll()">
<span x-text="selected.length ? selected.length + ' selected' : 'Select all'"></span>
</label>
<span x-text="countLabel"></span>
<div style="margin-left:auto; display:flex; gap:6px;" x-show="selected.length">
<button class="btn-sm" @click="restoreSelected()">Restore</button>
<button class="btn-sm btn-sm-danger" @click="deleteSelected()">Delete</button>
<button class="btn-sm" @click="clearSel()">Clear</button>
</div>
</div> </div>
<!-- Items --> <!-- Items -->
<div style="min-height:200px;"> <div style="min-height:200px;">
<template x-for="item in filteredItems" :key="item.id"> <template x-for="item in filteredItems" :key="item.id">
<div class="trash-item"> <div class="trash-item">
<input type="checkbox" style="accent-color:var(--accent); cursor:pointer; flex-shrink:0;"
:checked="selected.includes(item.id)" @change="toggle(item.id)">
<span class="trash-item-icon" x-text="item.icon"></span> <span class="trash-item-icon" x-text="item.icon"></span>
<div class="trash-item-info"> <div class="trash-item-info">
<span class="trash-item-name" x-text="item.name"></span> <span class="trash-item-name" x-text="item.name"></span>
<span class="trash-item-path" x-text="item.path"></span> <span class="trash-item-path">
<span x-text="item.path"></span> · <span x-text="ago(item.deleted_at)"></span><span x-text="leftLabel(item.deleted_at)"></span>
</span>
</div> </div>
<button class="trash-item-btn" title="Restore" @click="restore(item.id)"> <button class="trash-item-btn" title="Restore" @click="restore(item.id)">↩️</button>
↩️
</button>
<button class="trash-item-btn" title="Delete permanently" @click="deleteForever(item.id)"> <button class="trash-item-btn" title="Delete permanently" @click="deleteForever(item.id)">
{{ fd_icon("trash",14) }} {{ fd_icon("trash",14) }}
</button> </button>
</div> </div>
</template> </template>
<div x-show="filteredItems.length === 0" class="lib-empty">
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("trash",14) }}</div> <!-- État vide : aucun contenu -->
<div x-show="items.length === 0" class="lib-empty">
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("trash",24) }}</div>
<h3>Trash is empty</h3> <h3>Trash is empty</h3>
<p>Deleted pages will appear here for 30 days.</p> <p>Deleted pages will appear here for 30 days.</p>
</div> </div>
<!-- État vide : filtres sans résultat -->
<div x-show="items.length > 0 && filteredItems.length === 0" class="lib-empty">
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("search",24) }}</div>
<h3>No pages match</h3>
<p x-text="items.length + ' page(s) in Trash — none matches the current search/location filter.'"></p>
<button class="btn-sm" style="margin-top:12px;" @click="clearFilters()">Clear filters</button>
</div>
</div> </div>
<!-- Info --> <!-- Info -->
@@ -64,36 +112,164 @@
{% block scripts %} {% block scripts %}
<script data-cfasync="false" nonce="{{ csp_nonce() }}"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les // A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: trashData »). // globales window — probe « Undefined variable: trashData »).
if (window.Alpine) { Alpine.data('trashData', trashData); }
else document.addEventListener('alpine:init', function () { Alpine.data('trashData', trashData); });
document.addEventListener('alpine:init', function () { Alpine.data('trashData', trashData); });
function trashData() { function trashData() {
// Rétention alignée sur app/services/trash.py (purge auto quotidienne).
var RETENTION_DAYS = 30;
return { return {
search: '', search: '',
sort: 'recent',
locFilter: 'all',
sortOpen: false,
locOpen: false,
selected: [],
items: [], items: [],
async init() { async init() {
const csrf = getCsrf();;
const token = csrf;
try { try {
const r = await fetch('/board/api/trash', { headers: { 'X-CSRF-Token': token } }); const r = await fetch('/board/api/trash', { headers: { 'X-CSRF-Token': getCsrf() } });
if (!r.ok) throw new Error('HTTP ' + r.status);
this.items = await r.json(); this.items = await r.json();
} catch(e) { this.items = []; } } catch (e) {
this.items = [];
window.showToast('Could not load the Trash: ' + e.message, 'error');
}
}, },
get locations() {
const set = {};
this.items.forEach(function (i) { set[i.path || 'Private'] = true; });
return Object.keys(set).sort();
},
get filteredItems() { get filteredItems() {
const q = this.search.toLowerCase(); const q = this.search.toLowerCase();
return this.items.filter(i => !q || i.name.toLowerCase().includes(q) || (i.path||'').toLowerCase().includes(q)); const loc = this.locFilter;
const out = this.items.filter(function (i) {
const hitQ = !q || i.name.toLowerCase().includes(q) || (i.path || '').toLowerCase().includes(q);
return hitQ && (loc === 'all' || (i.path || 'Private') === loc);
});
const key = function (i) { return i.deleted_at || ''; };
if (this.sort === 'recent') out.sort(function (a, b) { return key(b).localeCompare(key(a)); });
else if (this.sort === 'oldest') out.sort(function (a, b) { return key(a).localeCompare(key(b)); });
else out.sort(function (a, b) { return a.name.localeCompare(b.name); });
return out;
}, },
get countLabel() {
const n = this.filteredItems.length, t = this.items.length;
return n === t ? t + ' page' + (t === 1 ? '' : 's') : n + ' of ' + t + ' pages';
},
get allSelected() {
const list = this.filteredItems;
const self = this;
return list.length > 0 && list.every(function (i) { return self.selected.includes(i.id); });
},
toggle(id) {
const i = this.selected.indexOf(id);
if (i >= 0) this.selected.splice(i, 1); else this.selected.push(id);
},
toggleAll() {
this.selected = this.allSelected ? [] : this.filteredItems.map(function (i) { return i.id; });
},
clearSel() { this.selected = []; },
clearFilters() { this.search = ''; this.locFilter = 'all'; },
toggleSort() { this.sortOpen = !this.sortOpen; },
toggleLoc() { this.locOpen = !this.locOpen; },
setSort(v) { this.sort = v; this.sortOpen = false; },
setLoc(v) { this.locFilter = v; this.locOpen = false; },
// ── API ──────────────────────────────────────────────────────────
// getCsrf() renvoie la CHAÎNE du jeton (base.html) : l'ancien code faisait
// csrf?.[1] → 2e caractère → 403 CSRF sur restore/delete, silencieux.
_headers() { return { 'X-CSRF-Token': getCsrf() }; },
_drop(ids) {
this.items = this.items.filter(function (i) { return !ids.includes(i.id); });
this.selected = this.selected.filter(function (id) { return !ids.includes(id); });
},
async _restore(id) {
const r = await fetch('/board/api/trash/' + id + '/restore', { method: 'POST', headers: this._headers() });
if (r.ok) this._drop([id]);
return r.ok;
},
async _delete(id) {
const r = await fetch('/board/api/trash/' + id, { method: 'DELETE', headers: this._headers() });
if (r.ok) this._drop([id]);
return r.ok;
},
async restore(id) { async restore(id) {
const csrf = getCsrf();; try {
const r = await fetch(`/board/api/trash/${id}/restore`, { method: 'POST', headers: { 'X-CSRF-Token': csrf?.[1] || '' } }); if (await this._restore(id)) window.showToast('Page restored', 'success');
if (r.ok) { this.items = this.items.filter(i => i.id !== id); } else window.showToast('Restore failed', 'error');
} catch (e) { window.showToast('Restore failed: ' + e.message, 'error'); }
}, },
async deleteForever(id) { async deleteForever(id) {
if (!confirm('Permanently delete this page? This cannot be undone.')) return; if (!confirm('Permanently delete this page? This cannot be undone.')) return;
const csrf = getCsrf();; try {
const r = await fetch(`/board/api/trash/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf?.[1] || '' } }); if (await this._delete(id)) window.showToast('Page permanently deleted', 'success');
if (r.ok) { this.items = this.items.filter(i => i.id !== id); } else window.showToast('Delete failed', 'error');
} catch (e) { window.showToast('Delete failed: ' + e.message, 'error'); }
},
async restoreSelected() {
const ids = this.selected.slice();
let ok = 0;
for (const id of ids) {
try { if (await this._restore(id)) ok++; } catch (e) { /* compté en échec */ }
}
window.showToast(ok + ' of ' + ids.length + ' page(s) restored', ok === ids.length ? 'success' : 'error');
},
async deleteSelected() {
const ids = this.selected.slice();
if (!confirm('Permanently delete ' + ids.length + ' page(s)? This cannot be undone.')) return;
let ok = 0;
for (const id of ids) {
try { if (await this._delete(id)) ok++; } catch (e) { /* compté en échec */ }
}
window.showToast(ok + ' of ' + ids.length + ' page(s) deleted', ok === ids.length ? 'success' : 'error');
},
async emptyTrash() {
const n = this.items.length;
if (!confirm('Permanently delete ALL ' + n + ' page(s) in the Trash? This cannot be undone.')) return;
try {
const r = await fetch('/board/api/trash/empty', { method: 'POST', headers: this._headers() });
if (!r.ok) throw new Error('HTTP ' + r.status);
this.items = [];
this.selected = [];
window.showToast(n + ' page(s) purged', 'success');
} catch (e) { window.showToast('Empty Trash failed: ' + e.message, 'error'); }
},
// ── Dates (deleted_at : CURRENT_TIMESTAMP UTC ou ISO sans fuseau) ──
_ts(s) {
if (!s) return null;
let t = String(s).replace(' ', 'T');
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(t)) t += 'Z';
const d = new Date(t);
return isNaN(d.getTime()) ? null : d;
},
ago(s) {
const d = this._ts(s);
if (!d) return 'Deleted';
const sec = (Date.now() - d.getTime()) / 1000;
if (sec < 60) return 'Deleted just now';
if (sec < 3600) return 'Deleted ' + Math.floor(sec / 60) + ' min ago';
if (sec < 86400) return 'Deleted ' + Math.floor(sec / 3600) + ' h ago';
const days = Math.floor(sec / 86400);
return 'Deleted ' + days + ' day' + (days === 1 ? '' : 's') + ' ago';
},
leftLabel(s) {
const d = this._ts(s);
if (!d) return '';
const left = RETENTION_DAYS - Math.floor((Date.now() - d.getTime()) / 86400000);
if (left <= 0) return ' · auto-delete soon';
return ' · ' + left + ' day' + (left === 1 ? '' : 's') + ' left';
} }
}; };
} }
+2 -1
View File
@@ -138,7 +138,8 @@ h1{font-size:22px;font-weight:700;margin-bottom:6px;}
// globales window — probe « Undefined variable: onboarding »). // globales window — probe « Undefined variable: onboarding »).
document.addEventListener('alpine:init', function () { Alpine.data('onboarding', onboarding); }); if (window.Alpine) { Alpine.data('onboarding', onboarding); }
else document.addEventListener('alpine:init', function () { Alpine.data('onboarding', onboarding); });
function onboarding() { function onboarding() {
return { return {
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'], steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
+2 -1
View File
@@ -145,7 +145,8 @@
// globales window — probe « Undefined variable: workspacePage »). // globales window — probe « Undefined variable: workspacePage »).
document.addEventListener('alpine:init', function () { Alpine.data('workspacePage', workspacePage); }); if (window.Alpine) { Alpine.data('workspacePage', workspacePage); }
else document.addEventListener('alpine:init', function () { Alpine.data('workspacePage', workspacePage); });
function workspacePage() { function workspacePage() {
return { return {
builtinProjects: [], builtinProjects: [],
+22 -2
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "FlowDeck", "title": "FlowDeck",
"version": "7.45.0" "version": "7.45.5"
}, },
"paths": { "paths": {
"/auth/register": { "/auth/register": {
@@ -3218,7 +3218,7 @@
"dashboard" "dashboard"
], ],
"summary": "Api Trash Page", "summary": "Api Trash Page",
"description": "Soft-delete a page (move to trash).", "description": "Soft-delete a page (move to trash).\n\nv7.45.5 : ``parent_section`` n'est plus réécrit en 'Trash' — ``deleted_at``\nest la seule source de vérité. L'ancienne écriture marquait définitivement\nla page : à la restauration elle restait 'Trash' et disparaissait des\nlistings Library / Recents / Private (``parent_section != 'Trash'``).",
"operationId": "api_trash_page_api_pages__page_id__trash_post", "operationId": "api_trash_page_api_pages__page_id__trash_post",
"parameters": [ "parameters": [
{ {
@@ -4082,6 +4082,26 @@
} }
} }
}, },
"/board/api/trash/empty": {
"post": {
"tags": [
"board"
],
"summary": "Empty Trash",
"description": "Empty Trash : purge en masse (les enfants des pages supprimées passent\nen racine, comme permanent_delete — comportement historique conservé).",
"operationId": "empty_trash_board_api_trash_empty_post",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/board/trash": { "/board/trash": {
"get": { "get": {
"tags": [ "tags": [
+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));
}
});
+131
View File
@@ -0,0 +1,131 @@
/**
* Page /trash (v7.45.5) : rendu, dates, sélection groupée, restauration, purge.
*
* Couvre aussi le fix CSRF du front : l'ancien code faisait `csrf?.[1]` alors
* que getCsrf() renvoie une CHAÎNE → 403 silencieux, les boutons ne marchaient
* pas. Ici on clique pour de vrai et on vérifie l'état via l'API.
*/
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) {
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 });
}
}
// Appel API depuis la page (cookies + jeton CSRF du document).
async function api(page, method, url) {
return page.evaluate(async ([m, u]) => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
const r = await fetch(u, { method: m, headers: { 'X-CSRF-Token': csrf } });
return { ok: r.ok, status: r.status, body: await r.json().catch(() => null) };
}, [method, url]);
}
async function mkTrashed(page, name) {
const c = await api(page, 'POST', '/board/api/pages?title=' + encodeURIComponent(name));
expect(c.ok, 'create page').toBeTruthy();
const t = await api(page, 'POST', '/api/pages/' + c.body.id + '/trash');
expect(t.ok, 'soft delete').toBeTruthy();
return c.body.id;
}
const trashIds = async (page) => {
const r = await api(page, 'GET', '/board/api/trash');
expect(r.ok).toBeTruthy();
return (r.body || []).map((i) => i.id);
};
test('trash : rendu, dates, sélection groupée, restauration, purge', async ({ page }) => {
test.setTimeout(90000);
const errs = [];
page.on('pageerror', (e) => errs.push('PAGEERROR ' + String(e).slice(0, 160)));
page.on('console', (m) => { if (m.type() === 'error') errs.push('CONSOLE ' + m.text().slice(0, 160)); });
page.on('dialog', (d) => d.accept()); // confirm() des actions destructives
await login(page);
// DB persistante : nommage unique par exécution, on ne dépend jamais des
// restes des runs précédents.
const TAG = 'E2E Trash ' + Date.now().toString(36).toUpperCase();
const nameA = TAG + ' A', nameB = TAG + ' B', nameC = TAG + ' C', nameD = TAG + ' D';
const idA = await mkTrashed(page, nameA);
const idB = await mkTrashed(page, nameB);
await page.goto(`${FD_BASE}/trash`, { waitUntil: 'load' });
await page.waitForTimeout(1000);
// On ne travaille QUE sur nos pages : la poubelle partagée avec l'instance
// de test peut contenir d'autres éléments (jamais on ne touche aux leurs).
await page.fill('input[placeholder="Search pages in Trash"]', TAG);
await page.waitForTimeout(400);
const rowA = page.locator('.trash-item', { hasText: nameA });
const rowB = page.locator('.trash-item', { hasText: nameB });
await expect(rowA).toHaveCount(1);
await expect(rowB).toHaveCount(1);
// Date de suppression + jours restants affichés (standard « trash »)
await expect(rowA.first()).toContainText('Deleted');
await expect(rowA.first()).toContainText('left');
// Compteur + purge totale présents
await expect(page.locator('label input[type=checkbox]')).toHaveCount(1);
await expect(page.getByRole('button', { name: 'Empty Trash' })).toBeVisible();
// Sélection groupée (portée au filtre courant) → restauration
const rowCount = await page.locator('.trash-item').count();
expect(rowCount).toBeGreaterThanOrEqual(2);
await page.locator('label input[type=checkbox]').check();
await expect(page.locator('span', { hasText: rowCount + ' selected' })).toHaveCount(1);
await page.locator('button.btn-sm', { hasText: 'Restore' }).click();
await page.waitForTimeout(900);
await expect(page.locator('.trash-item', { hasText: nameA })).toHaveCount(0);
await expect(page.locator('.trash-item', { hasText: nameB })).toHaveCount(0);
let ids = await trashIds(page);
expect(ids).not.toContain(idA);
expect(ids).not.toContain(idB);
// Restauration unitaire (le vrai test du fix CSRF : avant = 403 silencieux)
const idC = await mkTrashed(page, nameC);
await page.reload({ waitUntil: 'load' });
await page.waitForTimeout(900);
const rowC = page.locator('.trash-item', { hasText: nameC });
await expect(rowC).toHaveCount(1);
await rowC.first().locator('button[title="Restore"]').click();
await page.waitForTimeout(900);
await expect(rowC).toHaveCount(0);
ids = await trashIds(page);
expect(ids).not.toContain(idC);
// Purge totale — seulement si la poubelle ne contient QUE des pages E2E
// (l'instance de test est partagée : on ne vide jamais la poubelle d'autrui).
await mkTrashed(page, nameD);
await page.reload({ waitUntil: 'load' });
await page.waitForTimeout(900);
await expect(page.locator('.trash-item', { hasText: nameD })).toHaveCount(1);
const all = (await api(page, 'GET', '/board/api/trash')).body || [];
if (all.every((i) => i.name.startsWith('E2E Trash'))) {
await page.getByRole('button', { name: 'Empty Trash' }).click();
await page.waitForTimeout(900);
expect(await trashIds(page)).toHaveLength(0);
await expect(page.locator('.lib-empty', { hasText: 'Trash is empty' })).toBeVisible();
} else {
console.log('SKIP purge : la poubelle contient des pages non-E2E ' +
JSON.stringify(all.filter((i) => !i.name.startsWith('E2E Trash')).map((i) => i.name)));
}
const fatal = errs.filter((e) => e.startsWith('PAGEERROR') || e.includes('Alpine Expression Error'));
expect(fatal, fatal.join('\n')).toHaveLength(0);
});
-61
View File
@@ -23,68 +23,7 @@
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; 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) ===== */ /* ===== LIGHT THEME (default) ===== */
+75 -2
View File
@@ -283,10 +283,8 @@
if (!window.htmx) { window.location.href = url; return; } if (!window.htmx) { window.location.href = url; return; }
if (loading) return; if (loading) return;
loading = true; loading = true;
document.documentElement.classList.add('fd-navigating');
function done() { function done() {
loading = false; loading = false;
document.documentElement.classList.remove('fd-navigating');
} }
var p; var p;
try { try {
@@ -326,6 +324,81 @@
fdLoad(window.location.href, false); 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). // Ctrl/Cmd + \ : toggle the sidebar (Notion-style).
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && (e.key === '\\' || e.code === 'Backslash')) { if ((e.ctrlKey || e.metaKey) && (e.key === '\\' || e.code === 'Backslash')) {
+7 -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 */ /* 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 {}}})(); const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
@@ -22,7 +24,7 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
// globales window — probe « Undefined variable: kanbanBoard »). // globales window — probe « Undefined variable: kanbanBoard »).
document.addEventListener('alpine:init', function () { Alpine.data('kanbanBoard', kanbanBoard); }); if (window.Alpine) { Alpine.data('kanbanBoard', kanbanBoard); fdRefreshXData('kanbanBoard'); } else document.addEventListener('alpine:init', function () { Alpine.data('kanbanBoard', kanbanBoard); });
function kanbanBoard() { function kanbanBoard() {
return { return {
collapsedGroups: [], collapsedGroups: [],
@@ -42,7 +44,7 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
// globales window — probe « Undefined variable: filterSystem »). // globales window — probe « Undefined variable: filterSystem »).
document.addEventListener('alpine:init', function () { Alpine.data('filterSystem', filterSystem); }); if (window.Alpine) { Alpine.data('filterSystem', filterSystem); fdRefreshXData('filterSystem'); } else document.addEventListener('alpine:init', function () { Alpine.data('filterSystem', filterSystem); });
function filterSystem() { function filterSystem() {
return { return {
activeFilters: [], activeFilters: [],
@@ -84,7 +86,7 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
// globales window — probe « Undefined variable: sortSystem »). // globales window — probe « Undefined variable: sortSystem »).
document.addEventListener('alpine:init', function () { Alpine.data('sortSystem', sortSystem); }); if (window.Alpine) { Alpine.data('sortSystem', sortSystem); fdRefreshXData('sortSystem'); } else document.addEventListener('alpine:init', function () { Alpine.data('sortSystem', sortSystem); });
function sortSystem() { function sortSystem() {
return { return {
sorts: [], sorts: [],
@@ -111,7 +113,7 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
// globales window — probe « Undefined variable: newIssueForm »). // globales window — probe « Undefined variable: newIssueForm »).
document.addEventListener('alpine:init', function () { Alpine.data('newIssueForm', newIssueForm); }); if (window.Alpine) { Alpine.data('newIssueForm', newIssueForm); fdRefreshXData('newIssueForm'); } else document.addEventListener('alpine:init', function () { Alpine.data('newIssueForm', newIssueForm); });
function newIssueForm() { function newIssueForm() {
return { return {
title: '', status: 'todo', title: '', status: 'todo',
@@ -169,3 +171,4 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
}); });
} }
}); });
}
+3
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}})(); 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() { (function() {
@@ -1309,3 +1311,4 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v=
.catch(function(){ return rowId; }); .catch(function(){ return rowId; });
} }
})(); })();
}
+8 -2
View File
@@ -1,5 +1,5 @@
document.addEventListener('alpine:init', () => { var _regGiteaWs = () => {
Alpine.data('giteaWorkspace', () => { Alpine.data('giteaWorkspace', () => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const owner = params.get('owner') || ''; const owner = params.get('owner') || '';
@@ -637,4 +637,10 @@ document.addEventListener('alpine:init', () => {
}, },
}; };
}); });
}); };
if (window.Alpine) {
_regGiteaWs();
fdRefreshXData('giteaWorkspace');
} else {
document.addEventListener('alpine:init', _regGiteaWs);
}
+1 -1
View File
@@ -4,7 +4,7 @@
// globales window — probe « Undefined variable: importWizard »). // globales window — probe « Undefined variable: importWizard »).
document.addEventListener('alpine:init', function () { Alpine.data('importWizard', importWizard); }); if (window.Alpine) { Alpine.data('importWizard', importWizard); fdRefreshXData('importWizard'); } else document.addEventListener('alpine:init', function () { Alpine.data('importWizard', importWizard); });
function importWizard() { function importWizard() {
return { return {
sources: [], sources: [],
+2 -3
View File
@@ -2,9 +2,8 @@
// A20 phase 3 : enregistrement Alpine.data — le build CSP ne résout que le // A20 phase 3 : enregistrement Alpine.data — le build CSP ne résout que le
// registre (probe : globale window → « Undefined variable: libraryPage »). // registre (probe : globale window → « Undefined variable: libraryPage »).
document.addEventListener('alpine:init', function () { if (window.Alpine) { Alpine.data('libraryPage', libraryPage); fdRefreshXData('libraryPage'); }
Alpine.data('libraryPage', libraryPage); else document.addEventListener('alpine:init', function () { Alpine.data('libraryPage', libraryPage); });
});
function libraryPage() { function libraryPage() {
return { return {
+14 -4
View File
@@ -1,3 +1,5 @@
if (!window.__fdLocalWorkspaceScriptsLoaded) {
window.__fdLocalWorkspaceScriptsLoaded = true;
const LW=(()=>{try{const el=document.getElementById('lw-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})(); 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'); console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData');
@@ -628,7 +630,7 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
if (!node) return; if (!node) return;
try { try {
var method = node.favorited ? 'DELETE' : 'POST'; 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) { if (r.ok) {
node.favorited = !node.favorited; node.favorited = !node.favorited;
if (window.appState && window.appState.refreshFavorites) window.appState.refreshFavorites(); if (window.appState && window.appState.refreshFavorites) window.appState.refreshFavorites();
@@ -1763,7 +1765,7 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
var url = folderHandled ? '/api/local-workspace/upload-folder' : '/api/local-workspace/upload'; var url = folderHandled ? '/api/local-workspace/upload-folder' : '/api/local-workspace/upload';
try { 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; this.uploadProgress = 80;
var d = await r.json(); var d = await r.json();
if (r.ok) { if (r.ok) {
@@ -2065,7 +2067,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(_wsI
// A20 ph3 : registre Alpine.data (le build CSP ne résout que le registre ; // A20 ph3 : registre Alpine.data (le build CSP ne résout que le registre ;
// x-data="wsInitData()" au lieu de l'identifiant global _wsInitData). // x-data="wsInitData()" au lieu de l'identifiant global _wsInitData).
document.addEventListener('alpine:init', function () { var _regWs = function () {
// objet partagé JS↔Alpine via closure LEXICALE (const hors window → // objet partagé JS↔Alpine via closure LEXICALE (const hors window →
// non snapshoté par ji ; probe4 : factory retournant l'objet = accepté). // non snapshoté par ji ; probe4 : factory retournant l'objet = accepté).
Alpine.data('wsInitData', function () { return _wsInitData; }); Alpine.data('wsInitData', function () { return _wsInitData; });
@@ -2089,4 +2091,12 @@ document.addEventListener('alpine:init', function () {
}; };
return t; return t;
}); });
}); };
if (window.Alpine) {
_regWs();
fdRefreshXData('wsInitData');
fdRefreshXData('wsPreview');
} else {
document.addEventListener('alpine:init', _regWs);
}
}
+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 {}}})(); const RT=(()=>{try{const el=document.getElementById('rt-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
/* eslint-disable */ /* eslint-disable */
@@ -530,3 +532,4 @@ window.__fdRT = (function () {
return { start, syncNow }; return { start, syncNow };
})(); })();
}
+10 -2
View File
@@ -1865,7 +1865,11 @@ applyAIBlocks(text){
}) })
.catch(function(){ self.showToast('Move failed'); }); .catch(function(){ self.showToast('Move failed'); });
}, },
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');});}, // Le route était /board/api/pages/{id}/trash → 404 permanent (le .then
// naviguait quand même : la page n'allait JAMAIS à la poubelle depuis
// l'éditeur). Route réelle : /api/pages/{id}/trash, et on ne navigue
// plus si la réponse n'est pas ok.
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf = getCsrf();fetch(`/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf}}).then(r=>{if(!r.ok)throw new Error('HTTP '+r.status);window.location.href='/';}).catch(()=>{window.showToast('Move to Trash 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');}, 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');}}, 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');}},
@@ -2535,6 +2539,10 @@ applyAIBlocks(text){
// globales window — probe « Undefined variable: editorState »). // globales window — probe « Undefined variable: editorState »).
document.addEventListener('alpine:init', function () { Alpine.data('editorState', 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; window.editorState = editorState;
} }
+4 -1
View File
@@ -1,3 +1,5 @@
if (!window.__fdSettingsScriptsLoaded) {
window.__fdSettingsScriptsLoaded = true;
/* exported settingsInit -- appeles depuis les attributs HTML des templates */ /* 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 {}}})(); const ST=(()=>{try{const el=document.getElementById('st-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
@@ -5,7 +7,7 @@ const ST=(()=>{try{const el=document.getElementById('st-config');return el?JSON.
// globales window — probe « Undefined variable: settingsInit »). // globales window — probe « Undefined variable: settingsInit »).
document.addEventListener('alpine:init', function () { Alpine.data('settingsInit', settingsInit); }); if (window.Alpine) { Alpine.data('settingsInit', settingsInit); fdRefreshXData('settingsInit'); } else document.addEventListener('alpine:init', function () { Alpine.data('settingsInit', settingsInit); });
function settingsInit() { function settingsInit() {
return { return {
// ── A20 ph3 : expressions hostiles au parseur CSP (window/Date) ── // ── A20 ph3 : expressions hostiles au parseur CSP (window/Date) ──
@@ -1233,3 +1235,4 @@ function settingsInit() {
}, },
}; };
} }
}
+1 -1
View File
@@ -4,7 +4,7 @@
// globales window — probe « Undefined variable: workspacesPage »). // globales window — probe « Undefined variable: workspacesPage »).
document.addEventListener('alpine:init', function () { Alpine.data('workspacesPage', workspacesPage); }); if (window.Alpine) { Alpine.data('workspacesPage', workspacesPage); fdRefreshXData('workspacesPage'); } else document.addEventListener('alpine:init', function () { Alpine.data('workspacesPage', workspacesPage); });
function workspacesPage() { function workspacesPage() {
return { return {
workspaces: [], workspaces: [],
+18 -5
View File
@@ -156,9 +156,22 @@ async function cacheFirst(request, { cacheName = CACHE_NAME } = {}) {
async function networkFirst(request, { cacheName = CACHE_NAME, fallbackToShell = false, timeoutMs = 4000 } = {}) { async function networkFirst(request, { cacheName = CACHE_NAME, fallbackToShell = false, timeoutMs = 4000 } = {}) {
const cache = await caches.open(cacheName); const cache = await caches.open(cacheName);
try { 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) { 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; return res;
} }
throw new Error('bad status'); throw new Error('bad status');
@@ -176,12 +189,12 @@ async function networkFirst(request, { cacheName = CACHE_NAME, fallbackToShell =
function timeoutFetch(request, ms) { function timeoutFetch(request, ms) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const ctrl = new AbortController();
const timer = setTimeout(() => { const timer = setTimeout(() => {
const controller = new AbortController(); ctrl.abort();
controller.abort();
reject(new Error('timeout')); reject(new Error('timeout'));
}, ms); }, ms);
fetch(request).then( fetch(request, { signal: ctrl.signal }).then(
(res) => { clearTimeout(timer); resolve(res); }, (res) => { clearTimeout(timer); resolve(res); },
(err) => { clearTimeout(timer); reject(err); } (err) => { clearTimeout(timer); reject(err); }
); );
+4 -1
View File
@@ -453,7 +453,10 @@ def test_dashboard_page_content_rename_trash(client):
"SELECT title, parent_section, deleted_at FROM pages WHERE id=?", (pid,) "SELECT title, parent_section, deleted_at FROM pages WHERE id=?", (pid,)
).fetchone() ).fetchone()
assert row["title"] == "Renommée A32" assert row["title"] == "Renommée A32"
assert row["parent_section"] == "Trash" and row["deleted_at"] is not None # v7.45.5 : parent_section n'est PLUS réécrit en 'Trash' (deleted_at est
# la source de vérité) — l'ancienne assertion codait le bug : la page
# restaurée restait 'Trash' et disparaissait de Library/Recents/Private.
assert row["parent_section"] != "Trash" and row["deleted_at"] is not None
finally: finally:
with get_conn() as conn: with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE id=?", (pid,)) conn.execute("DELETE FROM pages WHERE id=?", (pid,))
+123
View File
@@ -0,0 +1,123 @@
"""Trash — v7.45.5.
Trois régressions couvertes :
1. les routes trash n'avaient AUCUNE vérification de session (le CSRF seul ne
protège pas : cookie lisible + en-tête forgé) ;
2. la suppression depuis l'éditeur réécrivait ``parent_section='Trash'`` et la
restauration ne le remettait pas → page invisible en Library/Recents/Private ;
3. ``POST /board/api/trash/empty`` (purge totale) n'existait pas.
"""
from tests.conftest import anon_csrf, login_test_client
def _mk_page(title="To trash", workspace="bruno/flowdeck", section="Private", parent_id=None):
from app.db import get_conn
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format, parent_section, parent_id) "
"VALUES (?, ?, '', 'blocks', ?, ?)",
(workspace, title, section, parent_id),
)
conn.commit()
return cur.lastrowid
def _row(page_id):
from app.db import get_conn
with get_conn() as conn:
return conn.execute(
"SELECT parent_section, deleted_at FROM pages WHERE id=?", (page_id,)
).fetchone()
def test_trash_requires_session(client):
pid = _mk_page()
anon_csrf(client) # anonyme mais CSRF valide → on veut le 401, pas le 403
assert client.get("/board/api/trash").status_code == 401
assert client.post(f"/api/pages/{pid}/trash").status_code == 401
assert client.post(f"/board/api/trash/{pid}/restore").status_code == 401
assert client.delete(f"/board/api/trash/{pid}").status_code == 401
assert client.post("/board/api/trash/empty").status_code == 401
# rien n'a été touché
row = _row(pid)
assert row["deleted_at"] is None
def test_soft_delete_then_restore_keeps_section(client):
login_test_client(client)
pid = _mk_page(title="Restore me")
r = client.post(f"/api/pages/{pid}/trash")
assert r.status_code == 200
row = _row(pid)
assert row["deleted_at"], "la page doit être marquée supprimée"
assert row["parent_section"] != "Trash", "parent_section ne doit plus être réécrit"
items = client.get("/board/api/trash").json()
hit = next((i for i in items if i["id"] == pid), None)
assert hit, "la page doit apparaître dans la poubelle"
assert hit["deleted_at"] and hit["path"] and hit["icon"]
r = client.post(f"/board/api/trash/{pid}/restore")
assert r.status_code == 200
row = _row(pid)
assert row["deleted_at"] is None
assert row["parent_section"] != "Trash"
assert not any(i["id"] == pid for i in client.get("/board/api/trash").json())
def test_permanent_delete(client):
login_test_client(client)
pid = _mk_page(title="Gone for good")
client.post(f"/api/pages/{pid}/trash")
assert client.delete(f"/board/api/trash/{pid}").status_code == 200
from app.db import get_conn
with get_conn() as conn:
assert conn.execute("SELECT COUNT(*) FROM pages WHERE id=?", (pid,)).fetchone()[0] == 0
def test_empty_trash_purges_only_trashed(client):
login_test_client(client)
ids = [_mk_page(title=f"purge-{i}") for i in range(3)]
keep = _mk_page(title="keep-me")
for pid in ids:
client.post(f"/api/pages/{pid}/trash")
r = client.post("/board/api/trash/empty")
assert r.status_code == 200
assert r.json()["deleted"] == 3
from app.db import get_conn
with get_conn() as conn:
placeholders = ",".join("?" * len(ids))
assert conn.execute(
f"SELECT COUNT(*) FROM pages WHERE id IN ({placeholders})", ids
).fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM pages WHERE id=?", (keep,)).fetchone()[0] == 1
def test_boot_repairs_restored_rows_marked_trash(client):
"""Le correctif de db.init_db() remet en 'Private' les pages restaurées que
l'ancien soft-delete laissait marquées 'Trash' (donc invisibles partout)."""
login_test_client(client)
from app.db import get_conn, init_db
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format, parent_section) "
"VALUES ('', 'ghost', '', 'blocks', 'Trash')"
)
conn.commit()
pid = cur.lastrowid
init_db()
with get_conn() as conn:
row = conn.execute("SELECT parent_section FROM pages WHERE id=?", (pid,)).fetchone()
assert row["parent_section"] == "Private"