Commit Graph
24 Commits
Author SHA1 Message Date
bvandeusen bc22f8e249 Remove [[wiki-links]], backlinks and the graph
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 31s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 37s
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 6s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 8s
Android / Kotlin + Rust (APK) (push) Failing after 1m56s
Operator, 2026-08-22 (note 2897): ThoughtSync is an intermediary surface. You
write here because it's easy — a notebook in your pocket — and later you recall
the thing and go finish it somewhere else. Recall is the product; organization
is secondary. A linking system is organization, and it isn't what this is for.

So: `[[wiki-links]]`, backlinks, the `[[` autocomplete, the note_links table,
`/api/notes/link-search`, `/api/notes/<id>/backlinks`, the whole graph blueprint
and GraphView. Rust core loses `extract_links`, `backlinks`, `link_search` and
`create_titled`; the desktop loses the three Tauri commands that exposed them.

This subsumes 982d24c rather than reverting it. That commit bound links to a
note id so a rename would stop rewriting other notes' bodies — real infra, but
infra for a feature that is now gone, and nothing it added survives. Alembic
0023 stays in the chain anyway: it shipped in an image and may already be
applied, and deleting an applied revision strands a database's version pointer.
0024 drops the table and takes the column with it. The history stays honest
about the fact that it existed for a day.

Two things deliberately kept, because they were serving recall and only
incidentally serving links:

- `/api/notes/titles` and the titles store. The command palette lists them so
  you can jump to a note by name. `resolve()` — the name→note lookup that only
  linking needed — is gone.
- `display_title`. Every note still has a name for search results and export
  filenames. What that name is FOR changed; that it exists did not.

`notes/links.py` is now `notes/tags.py`, holding the #tag→label reconciliation
it always also owned. A file called links.py with no links in it would have been
exactly the drift this removal is meant to end.

Also swept out on the way: `_escape_like`, whose only caller was link-search,
and the `graph` icon. Nothing lost that a person typed — note_links was always
derived, and the `[[text]]` is still sitting in every body it was written in.
2026-08-22 12:00:57 -04:00
bvandeusen 982d24c83b links: bind a [[link]] to a note, not to a string
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 34s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m18s
Desktop (Tauri) / Update manifest (push) Successful in 5s
A wiki-link was stored only as normalized TEXT, so a note's NAME was the edge.
Renaming it broke every inbound link — and the fix that shipped for that
(task 1848, option b) was `_rename_inbound_links`: rewrite the `[[Old Name]]`
text inside the body of every note that linked to the renamed one.

That works while an explicit title exists to hold still. It stops being
defensible the moment a note's name is just its first body line, which is where
M13 is going: fixing a typo in your opening sentence would silently edit other
notes' words, with nothing to opt out to. So this lands first, before the title
comes out, and that window never ships.

`note_links` gains `target_id`, bound when the link is written. `target_norm`
stays and is what an UNRESOLVED link carries — linking to a note that doesn't
exist yet is a supported way to create one, so a link has to be able to name a
target that isn't there. Resolution reads the id, falling back to the name only
where nothing was bound, which is what lets a forward link connect the moment
its target appears. `_claim_unresolved_links` then binds it, so the fallback is
a transitional state rather than a permanent one.

`_rename_inbound_links` and `rewrite_link_title` are gone. What replaced them
touches link rows only: a note's text is never modified by something happening
to a different note.

The client can no longer resolve links for itself, and that is the point. It
used to look `[[text]]` up in a client-side name index, which only held together
BECAUSE renaming rewrote the text everywhere. Now the written text can name
something the target is no longer called, and only the server holds the binding
— so each note serializes its resolved links (`norm`, `id`, and the target's
name as it stands NOW). A renamed note reads correctly everywhere it is linked
from, without a single body having been edited. Unresolved links are simply
absent and fall through to the create-on-click affordance that already existed;
so does the offline desktop store, which derives links at query time and has no
binding to send.

The name-fallback join is owner-scoped everywhere it appears. Bound ids were
resolved owner-scoped when written, but matching on display_title alone would
have let two users who each have a note called "Groceries" see the other's id
and name through an unresolved link (rule 47).

The new behaviour is all SQL and this suite runs without a database, so the
dead helpers' tests are removed rather than replaced. This repo has no
integration lane to hold that ground — noted, not papered over.
2026-08-22 11:02:39 -04:00
bvandeusenandClaude Opus 4.8 882d4206aa M6 1908a: recurring reminders + complete/snooze (backend, no web-push)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 32s
Per operator: skip Web Push; build the rest of reminder delivery. This is
the client-agnostic half — the model + logic that foreground/native
delivery drives.

- notes.recurrence (migration 0022): daily/weekly/monthly/yearly or null.
  update_note accepts it (cleared when the reminder is cleared); rides
  export/import + sync push. Serialized on the note.
- Pure next_occurrence(remind_at, recurrence, after): the next fire strictly
  after `after`, rolling past missed occurrences; _add_months clamps the day
  to the target month (Jan 31 → Feb 28).
- POST /api/notes/<id>/reminder/complete — a recurring reminder advances to
  its next occurrence; a one-off clears. POST .../reminder/snooze {minutes}
  → remind_at = now + minutes (1 min .. 30 days).

No VAPID / push-subscription / service-worker — foreground + native delivery
land in the UI commit and the native clients.

Tests (DB-free): normalize_recurrence; next_occurrence (daily/weekly/
monthly-clamp/skip-missed/yearly/none); complete + snooze auth-guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 11:53:07 -04:00
bvandeusenandClaude Opus 4.8 5fdc124c77 M6 1902a: richer facet query + saved-filters storage (backend)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 33s
GET /api/notes gains combinable, AND-ed facets alongside the existing
filter/date/sort: multiple ?label= (notes with ALL), ?color, ?kind,
?has_reminder, ?has_attachment, and ?q (full-text over title+body, ranked)
— so the facet bar's text box searches, not just filters. All optional;
invalid color/kind → 400.

saved_filters table (migration 0021) + /api/saved-filters CRUD (list /
create / rename+repoint / delete, owner-scoped). `params` is a JSON facet
dict mirroring the query surface; clean_params() whitelists facet keys so a
saved view can't accumulate junk.

Tests (DB-free): _truthy, clean_params key-whitelisting, saved-filters
auth-guards. UI (facet bar + saved-views sidebar) lands next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 08:36:49 -04:00
bvandeusenandClaude Opus 4.8 69bf04e948 M6 1901: URL capture with link-preview unfurl (SSRF-hardened)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Build & push image (push) Successful in 33s
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
2026-07-23 08:05:24 -04:00
bvandeusenandClaude Opus 4.8 b5f545f655 M6 1900: any-file attachments + audio memos (broaden beyond images)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 32s
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
2026-07-22 23:37:43 -04:00
bvandeusenandClaude Opus 4.8 0ca244be3d Sync 5: attachment blob sync — client id + content hash (M8)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 44s
Let native clients sync attachment blobs deterministically:
- note_attachments gains sha256 (migration 0018, nullable, no backfill).
  The delta feed's attachment metadata now carries size + sha256 so a
  client knows exactly which blobs it already has (dedupe) and can verify
  integrity after download.
- Upload accepts an optional client-supplied attachment id (multipart form
  field), so a file attached offline keeps its identity across sync;
  re-uploading an id the note already has is an idempotent no-op. The
  server hashes the stored bytes (sha256) on upload. Download by id already
  exists (owner/shared scoped).

Frontend Attachment type carries the new optional size/sha256.

(Still image-only mimes — broadening to any-file is task 1900. Blob sync
behavior is operator-verified on deploy.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-22 23:12:21 -04:00
bvandeusenandClaude Opus 4.8 68abaa0f3f Sync 4: push endpoint POST /api/sync/push (LWW + history snapshot)
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 47s
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
2026-07-22 23:09:23 -04:00
bvandeusenandClaude Opus 4.8 3c76b50a9c Sync 2: device-token bearer auth + linked-devices UI (M8)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 33s
Native clients (Tauri/Android) authenticate sync with a long-lived device
bearer token, alongside the existing web session cookie.

Backend:
- security.py: generate_token() (secrets.token_urlsafe) + hash_token()
  (SHA-256 — device tokens are already high-entropy, so no slow KDF; keeps
  per-request bearer auth cheap). Only the hash is stored.
- device_tokens table (migration 0016): id, user_id, token_hash (unique),
  name, created_at, last_used_at.
- login_required now accepts `Authorization: Bearer <token>` OR the session
  cookie. Session path stays DB-free (fast); bearer path looks up the token
  hash, sets g.user_id, and stamps last_used_at.
- Endpoints: POST /api/auth/device-login (public; email+password → token,
  the native first-link flow), POST /api/auth/devices (session/bearer →
  token, web "link a device"), GET /api/auth/devices (list), DELETE
  /api/auth/devices/<id> (revoke). All owner-scoped; token shown once.

Frontend:
- Per-user (not admin) /account view "Linked devices": create a token
  (one-time reveal + copy), list devices (name, linked/last-synced), revoke
  with confirm. Top-bar device icon for all users; devices Pinia store.

Tests (DB-free): token hash determinism + uniqueness; device endpoints
auth-guard (401 without auth, before DB); device-login input validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-22 22:59:52 -04:00
bvandeusenandClaude Opus 4.8 58b88d2622 Sync 1: revision + tombstone schema (M8 sync hub foundation)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 34s
The delta-sync substrate for the local-first native clients (Tauri,
Android). No API behavior change — pure infrastructure; triggers are
operator-verified on deploy (no Postgres CI lane).

Migration 0015:
- CREATE SEQUENCE sync_revision_seq.
- notes + labels gain sync_revision (bigint) + purged_at (tombstone),
  with existing rows backfilled to distinct increasing revisions.
- Trigger ts_set_sync_revision() BEFORE INSERT OR UPDATE on notes+labels
  stamps a fresh monotonic revision from the sequence, so no mutation
  site can forget to bump it (robustness over app-level bumps).
- Trigger ts_bump_parent_note_revision() AFTER INS/UPD/DEL on note_items,
  note_attachments, note_labels re-bumps the parent note, since a note
  syncs as a whole (items/labels/attachments travel inline).
- Indexes (owner_id, sync_revision) on notes + labels for the delta pull
  WHERE sync_revision > cursor.

purged_at is the hard-delete tombstone (distinct from deleted_at = trash)
so an offline client learns a row is gone instead of resurrecting it.
Model columns added to Note + Label (nullable; trigger populates them).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-22 22:53:34 -04:00
bvandeusenandClaude Opus 4.8 efbf981a2a M6: version history — note revisions + restore
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 30s
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
2026-07-22 16:08:30 -04:00
bvandeusenandClaude Opus 4.8 c4914fe587 m4.5: inline #tags — hashtags in a note become labels (two-way sync)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 37s
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
2026-07-22 08:39:20 -04:00
bvandeusenandClaude Opus 4.8 2b6a353666 m4.5: titles optional — every note gets an auto display name
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 33s
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
2026-07-22 08:00:01 -04:00
bvandeusenandClaude Opus 4.8 f321c2bf70 labels: per-label color
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 32s
Give labels a color (migration 0011 adds labels.color, server_default
'default' so existing labels keep the neutral chip). The PATCH endpoint now
updates name and/or color; note serialization carries each label's color.
Frontend: a swatch picker per label in the Edit-labels modal, colored chips
on cards + in the editor (LABEL_CHIP_CLASSES), and a color dot on each
sidebar label. Reuses the note color vocabulary. (Graph node coloring rides
this in the graph-liveliness task.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 13:11:50 -04:00
bvandeusenandClaude Opus 4.8 c57982d910 M3 reminders: notes.remind_at + Reminders view
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 32s
- Migration 0010: notes.remind_at (nullable tz). PATCH accepts remind_at
  (ISO set / null clear); GET /api/notes/reminders (soonest first, non-trashed);
  serialize includes remind_at.
- Frontend: datetime util (local<->ISO, format, overdue); notes store setReminder;
  editor datetime-local picker + clear; card reminder chip (overdue = red);
  sidebar Reminders entry + /reminders view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 08:12:14 -04:00
bvandeusenandClaude Opus 4.8 2d72dcc7cb M3 wiki-links: [[links]] + backlinks
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 38s
- 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
2026-07-20 08:00:08 -04:00
bvandeusenandClaude Opus 4.8 339cc5c2d2 M2 drag-reorder: notes.position + reorder API + native DnD
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 30s
- 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
2026-07-19 22:28:43 -04:00
bvandeusenandClaude Opus 4.8 e4c898cd1b M2 attachments: image upload + owner-scoped media serving
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 34s
- 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
2026-07-19 22:21:26 -04:00
bvandeusenandClaude Opus 4.8 31be66ac60 M2 checklists: note kind + items backend + editor/card UI
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 27s
- Migration 0006: notes.kind ('text'|'list') + note_items (text, checked,
  position). Item API: add/update(toggle)/delete/reorder; PATCH note kind; note
  responses include kind + items[] (merged in one query alongside labels).
- notes store: kind/items on Note, setKind/addItem/updateItem/deleteItem.
- NoteChecklist component (toggle/add/edit/delete items); rendered read-only-ish
  on cards (checkboxes toggle) and editable in the editor.
- Editor: convert text<->checklist (body lines become items on convert to list).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-19 22:13:25 -04:00
bvandeusenandClaude Opus 4.8 ffc008bf4d M2 search: Postgres FTS backend + top search bar + results
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 31s
- 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
2026-07-19 22:03:19 -04:00
bvandeusenandClaude Opus 4.8 4d1fc1bdf9 M2 labels backend: labels + note_labels, CRUD, note-label set, filter
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 32s
- 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
2026-07-19 21:43:21 -04:00
bvandeusenandClaude Opus 4.8 b46bda38ee M1.5 backend: admin role + DB-backed settings, DB-URL-only install
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 31s
- 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
2026-07-19 18:25:04 -04:00
bvandeusenandClaude Opus 4.8 df59a30cca M1: notes model + migration 0002 + notes API (CRUD, trash/restore)
CI & Build / Python tests (push) Successful in 11s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Build & push image (push) Successful in 36s
- Note model (owner_id, title, body, color-key, pinned, archived, deleted_at
  soft-delete, timestamps) + board index; NOTE_COLORS palette keys.
- Migration 0002 (notes table + ix_notes_owner_board).
- /api/notes blueprint (login_required): list (?filter=active|archived|trash,
  pinned-then-updated, read via visible_to_user ACL), create, get, patch
  (title/body/color/pinned/archived), trash, restore, permanent delete
  (trash-only). Mutations owner-scoped; empty note rejected (400).
- DB-free unit tests: is_empty_note, normalize_color, palette, serialize,
  auth-guard on list/create.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-19 15:59:43 -04:00
bvandeusenandClaude Opus 4.8 04e3ab20cf M0: backend skeleton — Quart factory, async DB, auth, ACL spine, migrations
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
2026-07-19 13:09:37 -04:00