"""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)