Paste a link → fetch its OpenGraph/meta preview (title, description, image,
site) and show a rich card. User-triggered + persisted (never auto-fetches;
cached so it never re-fetches). Opt-in via a new admin setting
enable_url_unfurl (default on, rule 26).
Security (the whole point of this task): a new dependency-free unfurl.py
does the fetch with layered SSRF defenses — http/https only; resolve the
host and reject EVERY non-public address (private/loopback/link-local/
reserved/multicast/unspecified — blocks 169.254.169.254 etc.); connect to
the vetted IP with SNI so DNS-rebinding can't slip through; ≤3 redirects
each re-validated; 5s timeout; 512 KB cap; text/html only; blocking IO in a
worker thread. No server-side image fetch — the og:image URL is loaded by
the browser.
- note_link_previews table (migration 0020), one per (note, url); serialized
inline on notes (+ rides the sync pull feed read-only).
- POST /api/notes/<id>/unfurl {url} (owner-scoped, setting-gated, 502 on
fetch failure); DELETE /api/notes/<id>/previews/<id>.
- enable_url_unfurl exposed in public config so the UI hides the affordance
when disabled.
Frontend: LinkPreview.vue card; editor detects URLs in the body and offers a
"Preview <domain>" chip per un-previewed link (ensureDraft first), renders
preview cards with remove; card shows previews read-only. New link icon;
notes-store unfurl()/deletePreview().
Tests (DB-free): is_public_ip range blocking, validate_url scheme/parts,
extract_preview (OG + <title> fallback + relative-image resolve), endpoint
auth-guards.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
A note can now carry any file, not just images — PDFs, documents, audio
memos, etc. "Dump anything" capture.
Backend:
- note_attachments.filename (migration 0019) records the original name for
download + display.
- Upload drops the image-only mime gate: accepts any type, derives the
storage extension from the filename, and enforces a DB-backed per-file
cap — new setting max_attachment_mb (default 25, rule 25). App body
ceiling raised 12→64 MB (also lifts the import-zip / sync-push limits);
the per-file cap is the effective attachment limit.
- Serve sets Content-Disposition: images inline, everything else downloads
with its original (header-sanitized) filename.
- Import (native + Keep Takeout) now brings in ANY attachment, not just
images — completing the Keep audio-memo gap; preserves filename + sha256.
- Attachment metadata (delta feed + REST) carries filename.
Frontend:
- Editor renders attachments by kind: images inline (thumbnail), audio via
an inline <audio> player, any other file as a download chip (paperclip +
filename + size). File picker accepts any type; "Attach a file".
- Card previews the first image; non-image files show as compact chips.
Tests (DB-free): _safe_filename, _attachment_ext, _header_filename.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
The core conflict-resolution step. Applies a batch of client changes,
additive + owner-scoped, with last-write-wins by client edit-time — and a
version-history snapshot on every overwrite so nothing is ever lost.
client_wins(client_edited_at, server_edited_at): apply iff client >= server;
a missing client time never overwrites a real server edit; a missing server
time (new row) yields. Notes compare against updated_at; labels gain an
updated_at (migration 0017, backfilled from created_at) as their LWW field.
Notes:
- upsert with a client-supplied id: create if absent, else LWW-apply the
full note state (title/body/color/kind/pins/trash/remind/position/items/
manual label_ids) with the same ripple as a web edit — derive_display_title,
_rewrite_links, _reconcile_tags (#tags), _rename_inbound_links. Overwriting
an existing title/body snapshots the old version into note_revisions first.
A resurrected tombstone clears purged_at.
- delete: purge tombstone (drop children + attachment files, clear content,
set purged_at), LWW-guarded so a newer server edit survives a stale delete.
Labels: upsert (create/rename/recolor) + delete (detach from notes, tombstone),
LWW-guarded; per-owner name-uniqueness clash on a different id is rejected
rather than raising.
Response: per-item {status: created|applied|kept|noop|rejected, sync_revision};
the client pulls afterward to converge. Whole-note semantics (client sends the
full state, not a partial patch).
Tests (DB-free): client_wins across all edit-time combinations; _parse_client_dt;
push auth-guard. Apply behavior + triggers operator-verified on deploy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
Delta pull for native clients: returns every note + label the caller owns
whose sync_revision advanced past ?since=<cursor>, ascending by revision,
paginated (?limit, default 500 / max 1000), with the next cursor +
has_more. since=0 is a full initial sync. Web app unaffected (new blueprint).
Notes and labels share one revision sequence, so the cursor is a single
watermark. _page_cursor() handles the two-stream paging: when either stream
fills its page, it advances only to the SMALLER of the two page boundaries
so nothing between the cursor and the next pull is skipped. Notes reuse
_serialize_notes (items/labels/attachments inline) + sync_revision +
purged_at (tombstone); labels carry name/color/purged_at/sync_revision.
Returns ALL of the owner's notes regardless of state (active/archived/
trash/purged) — a client mirrors everything. Registered sync blueprint.
Tests (DB-free): changes auth-guard; _parse_since / _clamp_limit
validation; _page_cursor across empty / drained / one-full / both-full.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
Complete the export/import pair (task 1907). POST /api/notes/import takes
an uploaded .zip and appends its notes — never overwriting existing ones.
Two formats, auto-detected:
- ThoughtSync export: recognized by its notes.json (app == thoughtsync);
round-trips title/body/color/kind/pinned/archived/remind_at/timestamps/
labels/items and re-attaches image media from the zip.
- Google Keep Takeout: each Keep <note>.json → a note. Maps title,
textContent/listContent (+ checked), labels, Keep color enum (nearest
palette match), isPinned/isArchived, isTrashed (→ trash), created/edited
microsecond timestamps; folds annotation URLs into the body; resolves
attachment filePaths relative to the note's folder.
Imported notes reuse create_note's derivation + reconciliation:
display-title derive, #tag reconcile, [[wiki-link]] rewrite. Explicit
labels attach as manual (via_tag=false); inline #tags reconcile as tags.
Image attachments copied into media storage; non-image types (e.g. Keep
audio) skipped until any-file attachments land.
Frontend: an Import control in the sidebar (next to Export) — hidden file
input + FormData POST + result toast ("Imported N notes (M skipped)"),
reloading the board + labels. New upload icon; notes-store importNotes().
Tests: import auth-guard + pure-helper coverage (_usec_to_dt, _keep_spec
list/text/color/annotation/attachment mapping, _native_spec round-trip).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
Data portability / no lock-in (task 1907, export half). GET /api/notes/export streams a zip of the caller's notes: a machine-readable notes.json (notes + labels + items + reminders + attachment refs), a human-readable Markdown file per note (frontmatter + body / checklist), and the attachment media. Sidebar 'Export' link (same-origin GET, session cookie) downloads it. _slugify unit-tested.
Import (Google Keep Takeout) is the follow-up increment of this task.
Pure backend + small frontend; no migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
A note's title+body is snapshotted on each edit that changes either, so an accidental overwrite can be viewed and restored (task 1906). Underwrites 'dump freely, nothing is lost'.
Backend: note_revisions table (migration 0014) + NoteRevision model; update_note records a revision of the PRE-edit state whenever title/body changes; GET /api/notes/<id>/revisions (newest 50) and POST /api/notes/<id>/revisions/<rev_id>/restore (snapshots the current state first so restore is itself undoable, then applies the revision with the usual title/body ripple — display name, links, #tags, backlinks). Title+body only in v1.
Frontend: a History toggle in the modal editor opens a panel of past versions (timestamp + preview) with per-row Restore. Store gains fetchRevisions/restoreRevision.
Migration 0014 runs on deploy; DB behavior operator-verified (no Postgres CI lane).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
Extends the existing label manager (create/rename/color/delete) with the two missing maintenance tools (task 1904), so the label list stays clean — which matters more now that #tags mint labels automatically.
Backend: GET /api/labels returns a per-label note count (one grouped query); new POST /api/labels/<id>/merge moves the source label's notes onto a target and deletes the source (repoint via delete+reinsert to avoid mutating the composite PK; preserves via_tag; dedupes notes already on the target). Body #tags are NOT rewritten, so a tag-sourced label re-mints on next edit if its #tag text remains — a documented nuance.
Frontend: LabelsModal shows each label's note count and a 'merge into…' picker; also restores the previously-missing close (x) and merge icons.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
A temporal recall path — find a note by WHEN it was captured, not just what it contains (task 1903, first of the M6 recall items).
Backend: list_notes gains an optional created_at range (created_after / created_before, half-open interval) + sort=created; also lays groundwork for the richer-search facets (task 1902). New _parse_iso_dt helper with a DB-free unit test.
Frontend: a Timeline view (sidebar nav + 'g t' + command palette) grouping active notes newest-first into local-time buckets (Today / Yesterday / Earlier this week / this month / Month YYYY), plus an optional From/To date filter. Built as a lens on the same NoteCard masonry, consistent with the existing Reminders/Search views.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
Fast, cross-device labelling: type #groceries in a note and it becomes
the "groceries" label. The body is the source of truth for tag-labels;
manual picker labels stay independent (rule 28 — additive).
- note_labels.via_tag (migration 0013) marks tag-sourced attachments.
- parse_tags(): #tag at start-of-body or after whitespace, needs a
letter (so #2024, URL #frags, mid#word are ignored). unit-tested.
- _reconcile_tags() on create + body-update: attach labels for current
#tags (find-or-create, case-insensitive), detach tag-labels whose tag
was removed; never touches manual (via_tag=false) rows.
- label picker (set_note_labels + editor onLabelsChange) now preserves
tag-labels on save, so a picker action can't strip a label the #tag
still mandates.
- serialize via_tag; card/editor chips render tag-labels as "#name",
and the editor hides the × on them (remove by editing the tag text).
- LabelPicker builds manual NoteLabels (via_tag:false).
Fourth and final item of M4.5.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
Checklist notes existed (M2) but could only be made by creating a text
note and toggling it in the editor — so they were undiscoverable. Now
the board's quick-add can make one in one shot.
- create endpoint accepts kind + items: POST /api/notes with
kind:"list" and items:[...] creates a checklist note and its items
atomically. A list note is non-empty when it has a title or ≥1 item.
- quick-add gets a checklist toggle (checkbox icon): flip it and each
body line becomes an item on save; placeholder switches to
"One item per line"; resets to a plain note after close.
- notes store create() accepts kind + items; parse_list_items() helper
(trims, drops blanks) with a unit test.
- title placeholders now read "Title (optional)" in quick-add too.
Third item of M4.5. Display/editing of checklists was already built in
M2; this closes the creation gap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
The [[ autocomplete only matched note names, so you could only link a
note you could name. Now it searches note NAME *and* body, so you can
link by recalling any phrase.
- new GET /api/notes/link-search?q= — owner-scoped, non-trashed;
substring ILIKE on display_title OR body; ranked name-first, then
name-prefix, then recency; empty q returns recent notes as
suggestions. Deterministic (no semantic/AI search); the FTS index
still powers the heavier /search. LIKE wildcards in q are escaped.
- editor [[ autocomplete now calls link-search (debounced 120ms)
instead of filtering the cached titles index; excludes the note
itself; inserts the matched note's display name as [[Name]].
- unit tests for the LIKE-escaping + a link-search auth guard.
Second item of M4.5; builds on the display_title work (every note has
a name to link to). Command-palette content search is a natural
follow-on, left out to keep this focused.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
Capture starts in the body, so forcing a title feels odd and body-only
notes had no name — which made them unlinkable. Fix both: persist a
display_title = explicit title if set, else the note's first non-empty
body line (deterministic, no AI). The title field stays optional.
- migration 0012: notes.display_title (NOT NULL, best-effort backfill;
the app recomputes precisely on next save)
- derive_display_title() helper, set on create + update
- drive the /titles index, backlinks, graph edges + node labels, and
[[wiki-link]] resolution off display_title so body-only notes are
nameable, findable (command palette / [[ autocomplete), and linkable
- rename-repoint generalized: inbound [[Old Name]] links now survive a
name change via the first body line too, not just an explicit title
- unit tests for the derivation (explicit wins, first non-empty line,
blank/empty, length cap)
- frontend: display_title on the Note type; title field placeholder now
reads "Title (optional)"
First item of M4.5 (frictionless input & recall); unblocks the linking
work. Card rendering unchanged (no first-line duplication).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
The media/data location is no longer configurable — DATA_DIR is a fixed
constant (/var/thoughtsync) and THOUGHTSYNC_MEDIA_ROOT is removed, so a
mutable path can't drift from where the volume is mounted. media_root() =
DATA_DIR/media. Both compose files drop THOUGHTSYNC_DATA_DIR and mount the
data volume at /var/thoughtsync (was the contradictory /data). conftest
drops the stale MEDIA_ROOT monkeypatch (create_app never reads DATA_DIR).
Net env surface: THOUGHTSYNC_DATABASE_URL (required) + THOUGHTSYNC_SECRET_KEY
(optional break-glass).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
Renaming a note now rewrites [[Old Title]] references (and their note_links
rows) in every note that links to it, so backlinks survive the rename
instead of silently orphaning. Pure/case-only renames are skipped since they
still resolve. New pure helper rewrite_link_title() (DB-free unit tests) does
the token rewrite; _rename_inbound_links() applies it across owner-scoped,
non-trashed sources.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
- graph blueprint: GET /api/graph resolves note_links to target notes by
normalized title (owner-scoped, non-trashed, self-excluded) → {nodes, edges}
of connected notes.
- GraphView: hand-rolled force simulation (repulsion + edge springs + centering,
cooling over ~400 frames), SVG nodes/edges, click a node to open it (reuses the
editor with link navigation). Sidebar Graph entry + route; empty state.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
- Migration 0009: note_links (source_id, target_norm). Parse [[...]] from body on
create/update and rewrite the source's links. GET /api/notes/titles (owner
{id,title} index for client-side resolution); GET /api/notes/<id>/backlinks.
- Frontend: titles store; LinkedText renders [[Title]] styled on cards; editor
shows Links (outgoing, resolve/create-on-click) + Linked-from (backlinks),
clicking navigates the editor to the target note (board + search).
- notes store: fetchOne, createTitled. DB-free link-parser tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
- Migration 0008: notes.position (int). Board orders pinned -> position ->
updated_at; new notes created at top (max position + 1). POST /api/notes/reorder
assigns positions from the given order (owner-scoped).
- notes store: position on Note, position-aware sort, optimistic reorder().
- NoteCard reorderable (native HTML5 draggable + dragstart/drop); BoardView moves
the dragged note before the drop target and persists.
Note: drag on a CSS-columns masonry has imperfect during-drag visuals (columns
reflow); order persists correctly. Candidate for a polish pass / layout tweak.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
- Migration 0007: note_attachments (path/mime/size). Upload POST
/api/notes/<id>/attachments (multipart, png/jpeg/gif/webp, 12MB cap via
MAX_CONTENT_LENGTH) stored under Config.media_root() (first use of DATA_DIR);
owner/ACL-scoped GET serves the file (nosniff); DELETE removes row + file.
Note responses include attachments[].
- Frontend: notes store uploadAttachment (FormData)/deleteAttachment; editor
image button + paste-to-upload + thumbnail grid with remove; card shows the
first image as a cover.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
- Migration 0005: generated tsvector column (title A + body B) + GIN index on
notes; GET /api/notes/search?q= (websearch_to_tsquery, ts_rank, ACL-scoped,
excludes trash), labels merged into results.
- Persistent AppShell layout (parent route + <RouterView> children) so the new
top search box keeps focus across board/search/label navigation.
- SearchView (debounced live search from the shell → /search?q=, results masonry,
no-match empty state); BoardView/SearchView render inside the shared shell.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
- Label + NoteLabel models; migration 0004 (labels unique per owner + note_labels
join, cascade).
- /api/labels: list/create(idempotent)/rename(clash-checked)/delete, owner-scoped.
- PUT /api/notes/<id>/labels to set a note's labels (validated against owned).
- Note responses now include labels[] (merged via one explicit join query — no
lazy relationship); GET /api/notes?...&label=<id> filters by label.
- DB-free auth-guard tests for labels endpoints.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
- users.is_admin; first registered user becomes admin; registration gated by the
allow_registration setting (first account always allowed). is_admin in
/api/auth/* responses; require_admin guard (live DB check).
- settings table + code registry (site_name, allow_registration, session_ttl_days)
with typed defaults — empty table = all defaults (rule 26). get/set/validate
service; GET /api/config (public) + GET/PATCH /api/settings (admin), live
session-TTL apply with no restart (rule 25).
- Cookie-signing secret now persisted in the DB (before_serving load-or-create),
so sessions survive restarts with no volume. Config: DATABASE_URL is the only
required env; SECRET_KEY + DATA_DIR are optional break-glass items.
- Migration 0003; DB-free tests for settings validation + admin guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
Foundation & Identity backend for ThoughtSync:
- Quart app factory (create_app) with /api/health + SPA history-fallback
- async SQLAlchemy 2.0 + asyncpg engine/session (lazy; boots without a DB)
- native email+password auth via signed-cookie session (register/login/logout/me
+ login_required guard); bcrypt password hashing (72-byte safe)
- multi-user sharing-ACL spine (rule 47): users, groups, group_members, and a
polymorphic shares table + visible_to_user() SQL predicate (owner OR direct
share OR group share) that M1's notes will scope through
- Alembic async env (adapted from family pattern) + 0001 foundation migration
- DB-free unit tests (app/health/auth-guard, password roundtrip, ACL compile)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm