- Panneau peek: les bindings Alpine (x-data absent du conteneur) rendaient loovverture et le redimensionnement inoperants -> cblage direct sur le document. - Helper unique window.fdWirePeekResize (app.js): pointer capture, 300px-90vw, clic=fermer, largeur persiste fd_peek_width partagee entre les 4 peeks. - database-table-container margin:0 (tableau colle a gauche, marge Library). - .lib-container remonte dans app.css (trash etait pleine largeur), .db-index 1100px. - ObsiGate verifie sans code: creation .xlsx OK (openpyxl, #186).
96 lines
3.4 KiB
Python
96 lines
3.4 KiB
Python
"""FlowDeck — Global trash (v5.4.0): automatic 30-day purge.
|
|
|
|
Pages soft-deleted via ``deleted_at`` stay in the trash for the retention
|
|
window (default 30 days, matching the UI copy in ``trash.html``), then are
|
|
permanently deleted together with their child pages and orphaned versions.
|
|
The purge is run from a background scheduler in ``app.main`` so it is fully
|
|
automatic (no per-request cost).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from app.db import get_conn
|
|
from app.services.collection_lifecycle import (
|
|
collections_hosted_by_page,
|
|
delete_collections,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
ISO_RE = re.compile(r"^\d{4}-\d{2}-\d{2}")
|
|
|
|
|
|
def _parse_dt(value: str) -> datetime | None:
|
|
"""Parse the ISO-ish ``deleted_at`` timestamps (either 'YYYY-MM-DD...' or
|
|
SQLite 'YYYY-MM-DD HH:MM:SS'). Returns None when unparseable."""
|
|
if not value:
|
|
return None
|
|
try:
|
|
s = value[:19].replace("T", " ")
|
|
dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S")
|
|
except ValueError:
|
|
try:
|
|
dt = datetime.fromisoformat(value)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
if not ISO_RE.match(value):
|
|
return None
|
|
return dt
|
|
|
|
|
|
def purge_expired(days: int = 30) -> dict:
|
|
"""Permanently delete pages whose ``deleted_at`` is older than ``days``.
|
|
|
|
Children are re-parented to their grandparent before deletion in order
|
|
to avoid silently dropping whole sub-trees; deleted pages are removed
|
|
together with their ``page_versions`` (FK cascade).
|
|
|
|
Returns a summary of what was purged.
|
|
"""
|
|
cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=days)
|
|
purged: list[int] = []
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT id, deleted_at FROM pages WHERE deleted_at IS NOT NULL"
|
|
).fetchall()
|
|
for row in rows:
|
|
dt = _parse_dt(row["deleted_at"])
|
|
if dt is None or dt >= cutoff:
|
|
continue
|
|
page_id = row["id"]
|
|
# A purged host page takes its database with it, otherwise the
|
|
# workspace kept advertising tasks whose document is gone.
|
|
collections = collections_hosted_by_page(conn, page_id)
|
|
# Promote direct children to the deleted page's parent so no live
|
|
# sub-tree is orphaned (matches the permanent-delete semantics).
|
|
conn.execute(
|
|
"UPDATE pages SET parent_id=(SELECT parent_id FROM pages WHERE id=?) "
|
|
"WHERE parent_id=?",
|
|
(page_id, page_id),
|
|
)
|
|
conn.execute("DELETE FROM pages WHERE id=?", (page_id,))
|
|
delete_collections(conn, collections)
|
|
purged.append(page_id)
|
|
conn.commit()
|
|
if purged:
|
|
logger.info("Trash purge: %d expired page(s) permanently deleted", len(purged))
|
|
return {"purged": purged, "count": len(purged)}
|
|
|
|
|
|
async def trash_purge_scheduler(interval_hours: int = 24):
|
|
"""Background loop purging the trash once a day (idempotent, cheap when
|
|
there is nothing expired)."""
|
|
import asyncio
|
|
|
|
# Run once shortly after startup, then on the configured interval.
|
|
await asyncio.sleep(5)
|
|
while True:
|
|
try:
|
|
purge_expired(days=30)
|
|
except Exception as exc: # pragma: no cover - defensive only
|
|
logger.warning("Trash purge failed: %s", exc)
|
|
await asyncio.sleep(interval_hours * 3600)
|