Compare commits

..
1 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
15 changed files with 623 additions and 51 deletions
+54
View File
@@ -1,5 +1,59 @@
# 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 ## v7.45.4 (2026-10-03) — Anti-FOUC au chargement complet + morts nettoyés
### Fixed ### Fixed
+1
View File
@@ -1007,6 +1007,7 @@ Détails livrés :
- [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). - [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). - 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é. - 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.4 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.4 (anti-FOUC : `x-cloak` sur `.app-layout` = plus de sidebar brute ni de bande rouge hors ligne au chargement complet, classe morte `fd-navigating` supprimée, probe étendu à 4 scénarios avec assertions) | **Statut**: EN COURS 🔄 > **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.4", 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
+208 -33
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;">
<span style="position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--text-dim);">{{ fd_icon("search",14) }}</span> <div style="position:relative; flex:1;">
<input type="text" placeholder="Search pages in Trash" x-model="search" <span style="position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--text-dim);">{{ fd_icon("search",14) }}</span>
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;"> <input type="text" placeholder="Search pages in Trash" x-model="search"
style="width:100%; padding:8px 12px 8px 36px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary); font-size:14px; outline:none; box-sizing:border-box;">
</div>
<!-- Tri -->
<div class="lib-toolbar-wrap">
<button class="lib-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSort()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
</button>
<div class="lib-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition>
<div class="dd-label">Sort by</div>
<div class="dd-item" :class="{active: sort==='recent'}" @click="setSort('recent')"><span class="check" x-text="sort==='recent' ? '✓' : ''"></span> Recently deleted</div>
<div class="dd-item" :class="{active: sort==='oldest'}" @click="setSort('oldest')"><span class="check" x-text="sort==='oldest' ? '✓' : ''"></span> Oldest first</div>
<div class="dd-item" :class="{active: sort==='name'}" @click="setSort('name')"><span class="check" x-text="sort==='name' ? '✓' : ''"></span> Page name A → Z</div>
</div>
</div>
<!-- Emplacement -->
<div class="lib-toolbar-wrap" x-show="locations.length > 1">
<button class="lib-icon-btn" :class="{active: locOpen || locFilter !== 'all'}" title="Filter by location" @click.stop="toggleLoc()">
{{ fd_icon("folder",16) }}
</button>
<div class="lib-dropdown" x-show="locOpen" @click.outside="locOpen=false" x-transition>
<div class="dd-label">Location</div>
<div class="dd-item" :class="{active: locFilter==='all'}" @click="setLoc('all')"><span class="check" x-text="locFilter==='all' ? '✓' : ''"></span> All locations</div>
<template x-for="l in locations" :key="l">
<div class="dd-item" :class="{active: locFilter===l}" @click="setLoc(l)"><span class="check" x-text="locFilter===l ? '✓' : ''"></span> <span x-text="l"></span></div>
</template>
</div>
</div>
<!-- Purge totale -->
<button class="btn-sm btn-sm-danger" x-show="items.length" @click="emptyTrash()">Empty Trash</button>
</div> </div>
<!-- Filters --> <!-- Sélection + compteur + actions groupées -->
<div style="display:flex; gap:8px; margin-bottom:16px;"> <div style="display:flex; align-items:center; gap:14px; margin-bottom:8px; font-size:13px; color:var(--text-dim);" x-show="items.length">
<button class="trash-filter active"> <label style="display:flex; align-items:center; gap:6px; cursor:pointer;">
<span style="color:var(--accent);">{{ fd_icon("user",14) }}</span> Last edited by ▾ <input type="checkbox" style="accent-color:var(--accent); cursor:pointer;" :checked="allSelected" @change="toggleAll()">
</button> <span x-text="selected.length ? selected.length + ' selected' : 'Select all'"></span>
<button class="trash-filter"> </label>
<span>{{ fd_icon("folder",14) }}</span> In ▾ <span x-text="countLabel"></span>
</button> <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,37 +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); } if (window.Alpine) { Alpine.data('trashData', trashData); }
else document.addEventListener('alpine:init', function () { Alpine.data('trashData', trashData); }); else 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';
} }
}; };
} }
+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.4" "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": [
+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);
});
+5 -1
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');}},
+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"