CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 12s
CI & Build / Build & push image (push) Successful in 44s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m45s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m12s
Trash had no end. A note sat in /trash until someone emptied it by hand, and its attachment BYTES sat on disk the whole time — the pile-up the operator asked about. Nothing purged; there was no scheduler at all. Retention is server-owned: `trash_retention_days` (default 30, 0 = keep forever) in the settings registry, so it lands in admin Settings with no migration and takes effect without a restart. A background sweep started in before_serving does the work. Clients learn about a purge the way they learn about any deletion — as a tombstone on the delta feed. An auto-purge nobody can see coming is data loss on a timer, so the window is now visible: /api/config publishes it, notes carry `deleted_at`, Trash leads with the policy, and each card counts down. The countdown rounds DOWN — saying "1 day left" for a note with ten minutes on the clock is the one error here that actually costs someone a note. Three things this turned up on the way: - `DELETE /api/notes/<id>` hard-deleted the row, leaving no tombstone at all. A permanent delete in the web UI never reached a linked device, which would keep its copy forever and push it back on the next edit. It now purges through the same path as everything else. - The purge left `note_revisions` and `note_link_previews` behind. A revision holds the full body, so the text of a "permanently deleted" note was still sitting in the database. - `deleted_at` now SURVIVES a purge instead of being cleared. It's still true, and it means every query that says "not trashed" excludes tombstones for free — without it a content-less row reads as a perfectly normal active note and shows up on the board as a blank card. Desktop keeps its own clock only when there's nobody else to keep one: the sweep runs at startup on an UNLINKED device and refuses otherwise. A linked client that expired notes on its own schedule could destroy something the server was deliberately keeping, then push that delete upstream. Local policy must never outrank the server's — so it also adopts the server's window for the countdown rather than showing its offline default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
106 lines
4.0 KiB
Python
106 lines
4.0 KiB
Python
"""Small shared helpers + constants for the notes package: display-name derivation,
|
|
board-filter narrowing, the owner-scoped fetch, and filename/slug sanitizers used by
|
|
both the attachment routes and the importer."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
|
|
from quart import g
|
|
from sqlalchemy import select
|
|
|
|
from ..models.note import Note
|
|
from ..responses import parse_uuid
|
|
|
|
ALLOWED_IMAGE_MIMES = {"image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/webp": ".webp"}
|
|
|
|
VALID_FILTERS = {"active", "archived", "trash"}
|
|
|
|
DISPLAY_TITLE_CAP = 200
|
|
|
|
|
|
def derive_display_title(title: str | None, body: str | None) -> str:
|
|
"""The note's display NAME: the explicit title if set, else the first non-empty
|
|
line of the body (trimmed, length-capped). Persisted as notes.display_title so a
|
|
body-only note is still nameable/searchable/linkable — the user never has to type
|
|
a title. Deterministic (literal first line, no AI)."""
|
|
if title and title.strip():
|
|
return title.strip()[:DISPLAY_TITLE_CAP]
|
|
for line in (body or "").splitlines():
|
|
stripped = line.strip()
|
|
if stripped:
|
|
return stripped[:DISPLAY_TITLE_CAP]
|
|
return ""
|
|
|
|
|
|
def is_empty_note(title: str | None, body: str | None) -> bool:
|
|
return not (title or "").strip() and not (body or "").strip()
|
|
|
|
|
|
def parse_list_items(raw: object) -> list[str]:
|
|
"""Trimmed, non-empty checklist item texts from a create payload's `items`."""
|
|
if not isinstance(raw, list):
|
|
return []
|
|
return [s.strip() for s in raw if isinstance(s, str) and s.strip()]
|
|
|
|
|
|
def apply_filter(stmt, filter_name: str):
|
|
"""Narrow a notes query to one board view.
|
|
|
|
Every branch excludes purge tombstones — content-less rows kept only so the sync
|
|
feed can tell offline clients a note is gone (see `retention.purge_note`). The
|
|
active/archived branches get that for free from `deleted_at IS NULL`, since a
|
|
tombstone keeps the timestamp; Trash is the one view that has to say so.
|
|
"""
|
|
if filter_name == "archived":
|
|
return stmt.where(Note.deleted_at.is_(None), Note.archived.is_(True))
|
|
if filter_name == "trash":
|
|
return stmt.where(Note.deleted_at.is_not(None), Note.purged_at.is_(None))
|
|
return stmt.where(Note.deleted_at.is_(None), Note.archived.is_(False))
|
|
|
|
|
|
async def _get_owned(db, note_id: str) -> Note | None:
|
|
"""Fetch a note the current user OWNS (mutations are owner-only in M1/M2).
|
|
|
|
A purged note reads as absent: the REST API must treat it as gone, so opening,
|
|
editing or restoring one 404s. The sync push path looks rows up directly rather
|
|
than through here, which is what still lets a client re-create an id it owns.
|
|
"""
|
|
nid = parse_uuid(note_id)
|
|
if nid is None:
|
|
return None
|
|
return await db.scalar(
|
|
select(Note).where(Note.id == nid, Note.owner_id == g.user_id, Note.purged_at.is_(None))
|
|
)
|
|
|
|
|
|
def _escape_like(s: str) -> str:
|
|
"""Escape LIKE wildcards so user input matches literally (escape char = \\)."""
|
|
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
|
|
|
|
def _slugify(text: str) -> str:
|
|
"""A filesystem-safe slug from a note's display name (for the .md filename)."""
|
|
s = re.sub(r"[^\w\s-]", "", (text or "").strip().lower())
|
|
s = re.sub(r"[\s_-]+", "-", s).strip("-")
|
|
return s[:60] or "note"
|
|
|
|
|
|
def _safe_filename(name: str | None) -> str:
|
|
"""The upload's original name reduced to a safe basename (display + download)."""
|
|
base = os.path.basename((name or "").strip().replace("\\", "/"))
|
|
return base[:255] or "file"
|
|
|
|
|
|
def _attachment_ext(filename: str, mime: str) -> str:
|
|
"""Storage extension: the original file's extension, else a known image ext."""
|
|
ext = os.path.splitext(filename)[1].lower()
|
|
if ext and len(ext) <= 12:
|
|
return ext
|
|
return ALLOWED_IMAGE_MIMES.get(mime, "")
|
|
|
|
|
|
def _header_filename(name: str) -> str:
|
|
"""Sanitize a filename for a Content-Disposition header (drop quotes/newlines)."""
|
|
return re.sub(r'[\r\n"]', "", name or "")[:255] or "file"
|