feat: poubelle flexible (sélection groupée, purge, tri/filtres, dates) + 4 bugs (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 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.
This commit is contained in:
@@ -348,6 +348,16 @@ def init_db():
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN deleted_at TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
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:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN share_mode TEXT DEFAULT 'private'")
|
||||
except sqlite3.OperationalError:
|
||||
|
||||
+1
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.45.4",
|
||||
version="7.45.5",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -125,21 +125,41 @@ def unpublish_page(request: Request, page_id: int):
|
||||
# ═══════════ 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")
|
||||
def list_trash(request: Request):
|
||||
_trash_session(request)
|
||||
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()
|
||||
return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows]
|
||||
rows = conn.execute(
|
||||
"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")
|
||||
def restore_page(request: Request, page_id: int):
|
||||
_trash_session(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
@@ -154,6 +174,7 @@ def restore_page(request: Request, page_id: int):
|
||||
|
||||
@router.delete("/api/trash/{page_id}")
|
||||
def permanent_delete(request: Request, page_id: int):
|
||||
_trash_session(request)
|
||||
with get_conn() as conn:
|
||||
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,))
|
||||
@@ -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)
|
||||
def trash_page(request: Request):
|
||||
from app.templating import ENV
|
||||
|
||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
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")
|
||||
def api_trash_page(page_id: int):
|
||||
"""Soft-delete a page (move to trash)."""
|
||||
def api_trash_page(page_id: int, request: Request):
|
||||
"""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:
|
||||
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,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -29,15 +29,16 @@ def trash_page(request: Request, owner: str = Query(default=""), repo: str = Que
|
||||
sidebar = board_sidebar(request, owner, repo)
|
||||
with get_conn() as conn:
|
||||
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:
|
||||
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,),
|
||||
).fetchall()
|
||||
else:
|
||||
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()
|
||||
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
|
||||
|
||||
+208
-33
@@ -12,45 +12,93 @@
|
||||
</div>
|
||||
|
||||
<div x-data="trashData()" style="padding: 0 24px; max-width: 800px;">
|
||||
<!-- Search -->
|
||||
<div style="position:relative; margin-bottom:12px;">
|
||||
<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"
|
||||
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;">
|
||||
<!-- Toolbar : recherche + tri + emplacement + purge -->
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- Filters -->
|
||||
<div style="display:flex; gap:8px; margin-bottom:16px;">
|
||||
<button class="trash-filter active">
|
||||
<span style="color:var(--accent);">{{ fd_icon("user",14) }}</span> Last edited by ▾
|
||||
</button>
|
||||
<button class="trash-filter">
|
||||
<span>{{ fd_icon("folder",14) }}</span> In ▾
|
||||
</button>
|
||||
<!-- 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>
|
||||
|
||||
<!-- Items -->
|
||||
<div style="min-height:200px;">
|
||||
<template x-for="item in filteredItems" :key="item.id">
|
||||
<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>
|
||||
<div class="trash-item-info">
|
||||
<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>
|
||||
<button class="trash-item-btn" title="Restore" @click="restore(item.id)">
|
||||
↩️
|
||||
</button>
|
||||
<button class="trash-item-btn" title="Restore" @click="restore(item.id)">↩️</button>
|
||||
<button class="trash-item-btn" title="Delete permanently" @click="deleteForever(item.id)">
|
||||
{{ fd_icon("trash",14) }}
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
<p>Deleted pages will appear here for 30 days.</p>
|
||||
</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>
|
||||
|
||||
<!-- Info -->
|
||||
@@ -64,37 +112,164 @@
|
||||
{% block scripts %}
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: trashData »).
|
||||
|
||||
if (window.Alpine) { Alpine.data('trashData', trashData); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('trashData', trashData); });
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('trashData', trashData); });
|
||||
|
||||
function trashData() {
|
||||
// Rétention alignée sur app/services/trash.py (purge auto quotidienne).
|
||||
var RETENTION_DAYS = 30;
|
||||
return {
|
||||
search: '',
|
||||
sort: 'recent',
|
||||
locFilter: 'all',
|
||||
sortOpen: false,
|
||||
locOpen: false,
|
||||
selected: [],
|
||||
items: [],
|
||||
|
||||
async init() {
|
||||
const csrf = getCsrf();;
|
||||
const token = csrf;
|
||||
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();
|
||||
} 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() {
|
||||
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) {
|
||||
const csrf = getCsrf();;
|
||||
const r = await fetch(`/board/api/trash/${id}/restore`, { method: 'POST', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
|
||||
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
|
||||
try {
|
||||
if (await this._restore(id)) window.showToast('Page restored', 'success');
|
||||
else window.showToast('Restore failed', 'error');
|
||||
} catch (e) { window.showToast('Restore failed: ' + e.message, 'error'); }
|
||||
},
|
||||
async deleteForever(id) {
|
||||
if (!confirm('Permanently delete this page? This cannot be undone.')) return;
|
||||
const csrf = getCsrf();;
|
||||
const r = await fetch(`/board/api/trash/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
|
||||
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
|
||||
try {
|
||||
if (await this._delete(id)) window.showToast('Page permanently deleted', 'success');
|
||||
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';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user