Commit Graph
62 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 01b53118b9 Core and desktop comments describe the code to a stranger (#6224)
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 12s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Android / Core and FFI clippy and tests (push) Successful in 1m3s
CI & Build / integration (push) Successful in 1m37s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m14s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m3s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m42s
Android / Build the server image (push) Successful in 1s
Milestone 546 "Bring the repo to the comment standard (rule 32) and
compose defaults (rule 198)", step #6224 "Comment standard sweep: Rust
core and desktop client".

Doc comments and comments across inkwell-core, the Tauri app and
desktop/packaging now say what the code does and the non-obvious why in
the present tense. Gone: milestone tags (M10.x, M11.3, M13, M304, M315,
M314, milestone 325), issue and Scribe-note numbers, family-idea and
family-rule citations, the run numbers and commit hashes behind past
bugs, and the narration of what used to be (the stable channel before it
held its own bundles, CARGO_PKG_VERSION as the shown version, the
MANIFEST_TAG split, the store's missing tests, the old PKGBUILD). The
schema's migration notes keep what each step does and why, without
"until now". The forge is called the forge rather than Forgejo.

Hazards stay as facts: the dev-rolling tag that must not shadow the
branch, the manifest matched on this build's version, the APK's fixed
name surviving the prune, the LIKE guard in v9, -wal/-shm moving with
the renamed store, panic = abort ending the app.

34 files, comments only: a comment-stripping diff of every changed file
against HEAD is identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:30:37 -04:00
bvandeusenandClaude Opus 5.5 ea831d1857 Rail and drawer: only the 5 tags most recently used or viewed (#5931)
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 14s
Android / Core and FFI clippy and tests (push) Successful in 1m9s
CI & Build / integration (push) Successful in 2m10s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 3m25s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m20s
Android / Kotlin + Rust (APK) (push) Failing after 6m3s
Android / Build the server image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m5s
Desktop (Tauri) / Update manifest (push) Successful in 4s
A long tag list pushed the drawer's Archive, Trash and Sync off screen on
Android, where Tags still sat above them (#5915 only reordered the web rail).
Tags now come last on Android too, and both clients list the five tags most
recently used or viewed, by name, with "All tags (N)" opening the rest in
place.

Used: the newest untrashed note carrying the tag. GET /api/labels and the
core's list_labels return it as last_used, and each client also folds in the
notes it holds, so a tag applied a moment ago counts before the next listing.
Viewed: opening the tag's lens, remembered on the device (per user on the web).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:41:33 -04:00
bvandeusenandClaude Opus 5.5 0ecff7eeb9 Core: tag names fold every letter, as the server does (#5385)
SQLite's lower() folds ASCII only, so a device could hold "Café" and
"CAFÉ" as two tags while the server held one. derive::fold (to_lowercase)
is now the one comparison. It is registered as the deterministic SQL function
fold() on every connection (schema::migrate), and find_or_create, the rename
clash, the pull clash and the unique index all use it. derive's push_unique
used eq_ignore_ascii_case and now folds the same way.

v14 merges pairs a device already holds before rebuilding the index. The
older row survives, as in rename_label. Memberships move with via_tag kept,
affected notes go dirty, and the merged-away row is a pending delete. It is
written out rather than calling store::merge_labels so the migration doesn't
depend on store code that later versions may change.

rusqlite gains its "functions" feature (operator-approved, 2026-10-08).
grammar.json gains "#café and #CAFÉ" -> ["café"], which all three
implementations run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:01:42 -04:00
bvandeusenandClaude Opus 5.5 78810d8832 DRY pass #3: core tests open their store and seed a label one way (#5373)
Five test modules wrote the same db() over memory_conn, and portable.rs
unwrapped it inline; local::test_db() is that. Four pull/push tests wrote the
same label INSERT; local::seed_label(conn, id, name, dirty) is it. Both are
cfg(test), beside memory_conn, as wire::sample_note is beside wire::Note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:17:30 -04:00
bvandeusenandClaude Opus 5.5 91ec677025 DRY pass #3: checklist near-misses live in the grammar fixture (#5373)
test_notes.py and derive.rs each kept the same six lines of prose that look
like a checklist item. Four were already task_lines cases in grammar.json;
the other two (empty brackets, no bullet) join them, so the web suite now
tests them too, and both hand lists go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:15:17 -04:00
bvandeusenandClaude Opus 5.5 63abff8681 DRY pass #2, batch 8: the Rust and ffi docs (#5372)
sync/mod.rs listed 5 of its 9 modules; migrate's doc sat above the v9 SQL;
client.rs had items after its test module; the ffi's sync_now doc had fused
into client_update's; complete_reminder (ffi and EditorAction) still said
recurrence advancement was to come, though the core does it; NoteQuery.view
listed views the core never matched and claimed it validated. Test scratch
dirs drop the old ts-/iw- prefixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:05:43 -04:00
bvandeusenandClaude Opus 5.5 4285026cfe The repeat rules are pinned by the shared fixture
daily/weekly/monthly/yearly was written out in the server (REMINDER_RECURRENCES),
the core (recur::RECURRENCES), the web editor's <option>s and Android's picker,
with nothing holding them together. grammar.json now has a recurrences list; the
server, core and web suites each check theirs against it, and the web's options
come from notes/recurrence.ts rather than the template. Android's picker pins the
list by hand with its localised labels, as it does the tint: its JVM tests do not
read the fixture.

DRY pass #2, batch 3 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:28:22 -04:00
bvandeusenandClaude Opus 5.5 db9e9a2dfb A note's name is one rule, pinned by the shared fixture
The server and the core each derived display_title and disagreed twice: the
server cut it at 200 characters and the core didn't, and the server split lines
with splitlines(), which also breaks on a lone \r or a U+2028, where the core and
every other reading of the grammar split on \n alone.

- grammar.json gains a display_titles section: blank lines, markers, an empty
  item, \r\n, a lone \r, U+2028, and a 201-character line of 'é' (the cut is
  characters, not bytes).
- derive::display_title and DISPLAY_TITLE_CAP are the core's half, moved next to
  strip_marker. The server splits on "\n". Both suites run the cases.

Behaviour: a device now names a note with a first line over 200 characters the
way the web always has, and the server names a note containing a lone \r or a
U+2028 the way devices always have.

Fixes #5398. DRY pass #2, batch 3 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:27:32 -04:00
bvandeusenandClaude Opus 5.5 2dec89bf8c Core tests make a throwaway blob store with blobs::scratch
blobs, store and portable each built a BlobStore in a temp directory their own
way: pid+tag twice, a uuid once. blobs::scratch(tag) is that, with a counter, so
two tests can never share a directory even if they pick the same tag. The
desktop's and ffi's temp-dir helpers stay, one per crate: sharing them would
need a test-util feature on the core crate.

DRY pass #2, batch 2, F9 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:24:52 -04:00
bvandeusenandClaude Opus 5.5 3ea7c817bf Core tests open their store through local::memory_conn
Seven test modules each built a migrated in-memory store by hand: open, migrate,
and (in sharing) wrap it in a Db. local::memory_conn() is that, and
open_in_memory uses it too. Each module's db() is now one line, and the schema,
Connection and Mutex imports it needed are gone.

DRY pass #2, batch 2, F9 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:24:04 -04:00
bvandeusenandClaude Opus 5.5 c35e7fd589 cargo fmt: three chains rustfmt splits
Formatting only. portable::instant (from F5), and the ffi's link and unlink
(F2/F3), were laid out by hand without a toolchain; rustfmt splits each chain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:23:57 -04:00
bvandeusenandClaude Opus 5.5 ee771e3734 Every lock of the store goes through Db::conn
Db::conn is documented as the one way to take the lock, yet five production and
test sites reached past it with db.0.lock(): the startup summary, the desktop's
trash sweep and config_get, and tests in sharing and update. All five now call
conn().

DRY pass #2, batch 2, F8 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:23:07 -04:00
bvandeusenandClaude Opus 5.5 63dd206b91 Access and entity words are core constants: access::*, entity::*
"owner"/"edit"/"view" and the entity names "note"/"label"/"attachment"/"preview"
were literals at about thirty sites across store, pull and push, including match
arms whose spelling had to agree with the rows a different module wrote.
models::access and models::entity now name them, and push names its two ops.
SQL text keeps its literals; Rust-side comparisons and writes read the constants.

DRY pass #2, batch 2, F7 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:22:49 -04:00
bvandeusenandClaude Opus 5.5 8aa854d60b The store's migrations are a table of steps, applied in order
schema::migrate was thirteen hand-copied blocks of "if version < N, apply,
stamp N". The versions are now a STEPS list (SQL, or code for v8). migrate walks
the list, applies each step a store hasn't had and stamps it. A new version is a
new entry at the end; there is no block to copy.

DRY pass #2, batch 2, F7 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:21:47 -04:00
bvandeusenandClaude Opus 5.5 61658946d7 Which reminders are this device's is one predicate: OWN_REMINDERS
reminders and due_reminders each wrote out "owner's, not trashed, has a time".
The predicate is now one constant both queries read, carrying the reason a
shared note's reminder is not ours.

DRY pass #2, batch 2, F7 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:21:05 -04:00
bvandeusenandClaude Opus 5.5 dc9cf85cdc rename_label writes its rename once, merge or not
The rename UPDATE appeared twice: once for a merge's survivor and once for a
plain rename. The branch now picks which row is renamed, and one UPDATE follows.

DRY pass #2, batch 2, F7 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:20:45 -04:00
bvandeusenandClaude Opus 5.5 695efb3112 The store reads a note's body through note_body everywhere
should_snapshot and snapshot_revision each wrote out the SELECT that note_body
already is.

DRY pass #2, batch 2, F7 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:20:36 -04:00
bvandeusenandClaude Opus 5.5 fe84924f78 The opaque content type is one core constant: local::OPAQUE_MIME
"application/octet-stream" was written out in the blob server, its test, the
store's normalize_mime and the wire default. All four now read OPAQUE_MIME; the
schema's SQL column default names the same string and says so.

DRY pass #2, batch 2, F7 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:20:01 -04:00
bvandeusenandClaude Opus 5.5 0101b05487 Stored timestamps have one writer: local::iso and local::now
CI & Build / Build now, or wait for Android? (push) Successful in 6s
Android / Build, or is the channel already serving this? (push) Successful in 6s
CI & Build / Python tests (push) Successful in 13s
CI & Build / Python lint (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 13s
CI & Build / integration (push) Successful in 2m6s
CI & Build / Build & push image (push) Skipped
Android / Core and FFI clippy and tests (push) Successful in 1m18s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 3m19s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 9m29s
Android / Build the server image (push) Successful in 2s
The store's time format (RFC 3339, UTC, milliseconds, Z) is what makes lexical
order chronological. It was spelled out ten times as
to_rfc3339_opts(SecondsFormat::Millis, true), with two private now() copies
(store, pull). local::iso(t) and local::now() now hold it; store, pull, engine
and portable call them.

DRY pass #2, batch 2, F5 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:18:33 -04:00
bvandeusenandClaude Opus 5.5 ef839ba0cd The store's layout names live in the core: local::DB_FILE, BLOBS_DIR
"inkwell.db" and "blobs" were spelled out in the ffi and the desktop (whose copy
of DB_FILE sat in the crossover shim). The layout is the core's, the same on
every client, so the names are now core constants and both clients read them.

DRY pass #2, batch 1, F4 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:17:26 -04:00
bvandeusenandClaude Opus 5.5 1173c1cf12 A device's snooze is clamped to the server's range, 1 minute to 30 days
The server clamped; the core took any i64, so 0 or less set a reminder in the past
and a huge value overflowed Duration::minutes. Every caller passes 60 or 1440
today, so this was latent. Both sides now name the range, SNOOZE_MAX_MINUTES,
and point at each other.

Fixes #5386.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:11:53 -04:00
bvandeusenandClaude Opus 5.5 87725ecab7 android: search narrows the board in view, and combines with its filters
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 9s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 7s
CI & Build / Web typecheck and unit tests (push) Successful in 11s
CI & Build / Python tests (push) Successful in 17s
Android / Core and FFI clippy and tests (push) Successful in 1m5s
CI & Build / integration (push) Successful in 1m42s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m24s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m59s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m14s
Android / Build the server image (push) Successful in 1s
As on the web, the search box is now one more facet on the board you are
looking at, rather than a separate unfiltered search. "These words, in
notes tagged grocery" works: on the main board the text is sent to the
core together with the Filters sheet's tags, attachment and shared
switches, and the core ANDs them in list_notes. Archive, Trash and a
tag's view take the text alone. A search typed on Reminders, which is not
a board view, moves to the main board, as the web does.

The Filters chip stays while you search; it was hidden before, on the
mistaken claim that the web hides its filters too. Drag-to-reorder stays
off during a search, since a filtered subset can't be renumbered against
notes it can't see.

store::search and the FFI's search_notes had no other callers, and are
removed. They also searched archived notes and ignored pinning, which the
board's query does not.

Scribe #2942.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:19:50 -04:00
bvandeusenandClaude Opus 5.5 c614e6859a Revert the deliberately failing core test
CI & Build / Python lint (push) Successful in 2s
Android / Core and FFI clippy and tests (push) Successful in 57s
CI & Build / integration (push) Successful in 1m17s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m9s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m10s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m56s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 10m1s
Android / Build the server image (push) Successful in 1s
It proved the APK gate (#5237): run 8667 failed at the core's tests, skipped
the APK job and still dispatched the server image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:46:39 -04:00
bvandeusenandClaude Opus 5.5 a682d6d225 core: a deliberately failing test, to prove the APK gate (reverted next)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 12s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 2m31s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / integration (push) Successful in 1m22s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 3m39s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:35:06 -04:00
bvandeusenandClaude Opus 5.5 b019172d47 all: remove saved views, the Has-reminder filter and the Created range
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 13s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 1m29s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 3m34s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m5s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m49s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 9m29s
Step 18 of the audit follow-through (#5180), on the operator's decisions.
Inkwell is for capture and recall (note 2897), and these three duplicated a
surface that does the job already:

- Saved views. They lived only on the web; the desktop kept its own set that
  never synced, and Android had none. Tags in the drawer already give
  one-click recall. Gone from the server (routes, model, migration 0038 drops
  the table), the core (store functions, schema v13 drops its table), the
  desktop commands, the web adapters, the drawer's Views list and the
  "Save view" link.
- The "Has reminder" facet. The Reminders page lists them, sorted by due.
- The FilterBar's "Created" range. Timeline is the date lens and keeps the
  created_after/created_before query it builds from local days, which also
  retires the UTC/local-day disagreement between the two (B3).

An old link that still carries the removed keys opens the plain board; a
web test pins that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:29:28 -04:00
bvandeusenandClaude Opus 5.5 05c82c2362 core, desktop: Db::conn everywhere, Change::default, notes_where, and shared row mappings
Android / Kotlin + Rust (APK) (push) Canceled after 12m20s
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 15s
CI & Build / Web typecheck and unit tests (push) Successful in 16s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / integration (push) Successful in 1m22s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 4m6s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 4m20s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m21s
Desktop (Tauri) / Update manifest (push) Successful in 5s
From the audit (#5179, core and desktop half).

- Every `db.0.lock().map_err(|e| e.to_string())?` (about 50 sites in core and
  the desktop) is now `db.conn()?`. The few sites that deliberately handle
  a poisoned lock differently, and the tests, keep their own spelling.
- push::Change derives Default, so its four constructors name only the
  fields they set.
- store: list_notes, reminders, titles and search share notes_where (ids
  from a query, each loaded through load_note). Labels share
  LABEL_SELECT/label_row, and saved filters share
  SAVED_FILTER_SELECT/saved_filter_row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:57:24 -04:00
bvandeusenandClaude Opus 5.5 7eacd0569c all: delete the code nothing calls
CI & Build / Python lint (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / integration (push) Failing after 1m21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m47s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m48s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m39s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 8m37s
From the audit (#5178). Each was unreachable from every client:

- Checklist add-item and delete-item: REST POST /items and DELETE /items/<id>,
  the Tauri commands, the store, rest and local adapters, the core's
  add_item/delete_item, set_item_text and remove_item, and the FFI exports.
  Adding, rewording and removing an item are body edits in every editor. The
  checked toggle stays, and its rewriter is simpler without the drop branch.
- Manual unfurl: POST /unfurl and its adapters. Previews arrive in the
  background after a save (unfurl_queue).
- The /api/config `android_client` key, android_release() and the
  APK_NAME/MANIFEST_NAME aliases. Phones poll /api/client/android.
- users.email_verified and users.avatar_path (migration 0037). Nothing set
  the first or read the second; the SMTP reset never checked verification.
- derive::extract_tags (only tests used it; the shared fixture now runs
  through extract_tag_spans), the unused check and link icons, and the
  unused editor_add_item string.
- The blob scheme is renamed tsblob -> inkblob. URLs are built as notes are
  read, so nothing stored carries the old one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:00:44 -04:00
bvandeusenandClaude Opus 5.5 2e2714a50b core: clippy — a one-element slice from a reference in the store test
CI & Build / Python lint (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
CI & Build / Build & push image (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / integration (push) Successful in 1m16s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 3m26s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m37s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m25s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Android / Kotlin + Rust (APK) (push) Successful in 10m54s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:18:53 -04:00
bvandeusenandClaude Opus 5.5 89cd1c76bb core, web: pin, archive and reorder a note someone shared with you
Schema v12 adds notes.state_at: a recipient's own pin, archive and order are
stamped there instead of on updated_at, which stays the text's time. Push sends
them only to a server advertising `shared_state` (push::Accepts), a view share
included; the first pull at that level starts the feed over once so held copies
drop their owner's pins. The client speaks protocol 7 and lists `shares` and
`shared_state` among the features a server may lack.

The web card and editor offer pin, archive and drag on shared notes; share and
trash stay the owner's, and the board's trash key skips notes you don't own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:13:58 -04:00
bvandeusenandClaude Opus 5.5 2e7db21b21 android: share a note, and read or edit one shared with you
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 18s
CI & Build / integration (push) Successful in 1m25s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m39s
Android / Kotlin + Rust (APK) (push) Failing after 4m53s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m42s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m40s
Desktop (Tauri) / Update manifest (push) Successful in 5s
The editor's menu has Share…, which opens a sheet of who the note is shared
with and lets you add someone at view or edit, change it, or stop sharing;
unlinked, it says sharing needs a server. A note shared to view opens
read-only; at edit only its text can change. Cards say who shared a note
("From Robin") or that yours is shared, and a view-only note's boxes don't
tick.

Also: two core store tests used unwrap_err on a Result<Note>, which needs
Note: Debug; they use err().expect() now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:42:30 -04:00
bvandeusenandClaude Opus 5.5 aa36b43dc3 core: shared notes on the desktop and phone, and Share from the desktop
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 1m14s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 1m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 9m20s
The core pulls with shares from a server offering them (protocol 6): a note
says how it is held (owner, edit, view) and who shared it, and a revoked note
leaves the device. The first such pull starts the feed over once, so notes
shared before this build arrive. The store refuses what a share doesn't allow
(view: everything; edit: anything but the text), push sends only the text of
someone else's note, and their notes stay out of trash, reminders and
reordering. Unlinking drops them.

The Share dialog's calls go to the linked server over the device token, as
Tauri commands and through the FFI. The desktop now offers Share and "Shared
with me"; unlinked, the dialog says sharing needs a server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:33:16 -04:00
bvandeusenandClaude Opus 5.5 df85535ea2 desktop: reminders reach you when the window isn't in front
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 36s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m30s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m38s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m38s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 8m45s
A Rust worker reads due reminders from the local store every 15 s and
announces each occurrence once: always to the main window as a toast, and
as a system notification (tauri-plugin-notification) when that window
isn't focused. The page no longer polls on the desktop; its Notification
went nowhere in WebKitGTK and its timer stopped with the window. The
Reminders page says what each surface actually does.

Core gains store::due_reminders, compared by instant, not by string.

Refs #5171

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:46:02 -04:00
bvandeusenandClaude Opus 5.5 5989ffc1c6 desktop: Import and Export work offline; the menu toggle is reachable; errors say why
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 48s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 4m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m10s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m32s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 12m42s
Export and Import were a link to the server and a reject("needs a server") on the
desktop. Both now run in the core with no server:

- core/src/local/portable.rs builds the same zip the server writes (notes.json,
  a Markdown file per note, each attachment this device holds) and reads either
  export marker or a Google Keep Takeout zip, with the server's decompression
  budget and an all-or-nothing transaction. Export saves to Downloads (no new
  plugin) and the sidebar says where; Import takes the archive as raw IPC bytes.
- core/testdata/portable.json pins the format for both copies: the server runs
  its Keep and native readers against it (test_portable_fixture.py) and checks
  its real export's keys (test_integration.py); the core runs the same cases.
- Found on the way: both importers skipped a Keep note that is only a photo as
  "empty". It now imports, on the server and in the core.
- New dependency, approved: `zip` (deflate only) plus `flate2` on its pure-Rust
  backend, both already in the lockfile.

The AppImage applications-menu toggle moves from Account, which the desktop
never shows, to the Sync page; the first-run prompt now says so.

errorMessage (#5236) replaces the hand-rolled `.error ?? …` / `.message ?? e`
reads at the remaining catch sites, so a desktop failure shows its real reason.

Task #5170.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:36:42 -04:00
bvandeusenandClaude Opus 5.5 2b2ceaa82e attachments sync: attach offline, upload when linked, removals stick (#5168)
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 35s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 9s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 2m23s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m17s
Desktop could not create an attachment at all, and a removed attachment or
dismissed preview came back on the next pull. Now:

- core: add_attachment keeps the bytes in the blob store and queues the row
  (schema v10: attachments.uploaded / upload_error). Push uploads it once its
  note has landed. A refusal that retrying won't fix (too large, id clash, hash
  mismatch) is recorded on the file and not re-sent every cycle; the editor
  shows it.
- core: removing a synced attachment or dismissing a preview leaves a tombstone
  in pending_deletes; push sends it as an `attachment`/`preview` delete, and a
  pull while it waits doesn't put the row back. A pull also keeps files still
  waiting to upload instead of replacing them wholesale.
- server: PUT /api/sync/attachments/<id> (raw body, sha256-checked, idempotent,
  size-capped) and child deletes in push, which apply regardless of LWW and
  answer noop for rows the caller can't see. One store_attachment helper for
  the upload route, the importer and sync. Protocol 5, feature attachment_sync;
  the client sends neither to a server without it.
- server: migration 0031 makes a link preview's insert/delete bump its note, so
  background-fetched previews and web dismissals reach linked devices.
- desktop: Attach and paste-image work offline (raw-bytes IPC command).
- SVG is served as a download by the desktop blob scheme too (as #1981 did for
  the web), and drawn as a file chip on both.
- autosync: drop the catch_unwind; release builds abort on panic, so it only
  ever worked in debug builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:07:52 -04:00
bvandeusenandClaude Opus 5.5 38da5160a0 grammar: a #tag starts after whitespace and with a letter, on every surface
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / integration (push) Successful in 38s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m7s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m38s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 10m35s
The shared fixture went red on the server (run 8468: 5 failed), because the three
tag rules disagreed:
- core and web: any non-tag character counts as a boundary, so `(#todo)`
  and `end.#tag` are tags, and so is the `/#section` of a pasted URL;
- server: only whitespace counts, but `#1st` and `#_x` are tags.

A note's labels could therefore change every time it synced.

All three now share the strict rule: start of line or whitespace, then a
letter, then letters, digits, `_` and `-`. Nothing becomes a tag that wasn't
already one everywhere, and URL anchors stop becoming labels on desktop and
Android. The server's existing `http://x/#nope` test already expected this.

derive.rs's boundary, markdown.ts's lookbehind and tags.py's regex change
together; `_is_tag` goes because the regex now requires the letter. The
fixture flips `(#todo)`, `end.#tag` and its lift case, and adds the URL case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:22:53 -04:00
bvandeusenandClaude Opus 5.5 535331c5b2 tests: one fixture for the note grammar, run by the core, the server and the web
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / integration (push) Failing after 27s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 1s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Failing after 12s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 4m25s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m28s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m19s
Desktop (Tauri) / Update manifest (push) Successful in 9s
Android / Kotlin + Rust (APK) (push) Canceled after 11m21s
The checklist grammar and the #tag rule are implemented three times (derive.rs,
checklist.py/tags.py, markdown.ts), and the tag colour twice (colors.ts,
DerivedTint.kt). Only Rust and Kotlin had tests. core/testdata/grammar.json now
holds one set of cases (task lines, rendered items, tags, standalone-tag lifts
and the tint hashes), and every suite reads it.

- web: vitest, a dev dependency approved for #5166, with `npm test`.
  grammar.test.ts runs the fixture, and titles.test.ts pins #5165's palette fix.
- ci.yml runs the web tests in the job the image build needs. desktop.yml's
  verify job runs them too, because the installers embed this frontend and
  can't see ci.yml's verdict (rule 177).
- core: derive.rs reads the fixture. server: tests/test_grammar_fixture.py.
- Android keeps its hand-written tint values; its doc now points at the fixture.

The server is expected red here, on purpose. tags.py only takes a tag after
whitespace and lets it start with a digit or `_`, while the core (the
definition) takes any non-tag boundary and needs a letter. So `(#todo)` is a
label on the phone and plain text on the server. The fix follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:11:26 -04:00
bvandeusenandClaude Opus 5.5 be200641cd core: notes show their real created and edited times, and desktop search works
CI & Build / Build now, or wait for Android? (push) Canceled after 0s
CI & Build / TypeScript typecheck (push) Canceled after 0s
CI & Build / Python lint (push) Canceled after 0s
CI & Build / Python tests (push) Canceled after 0s
CI & Build / integration (push) Canceled after 0s
CI & Build / Build & push image (push) Canceled after 0s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 4m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 7m41s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 10m41s
Both bugs were caught red by the tests in 732fd7a (run 8418: 5 failed, 147
passed) before this fix.

- load_note reads columns by NAME. Dropping `color` (fa89da1) shifted every
  column after it and the two timestamps were missed, so created_at showed the
  last edit and updated_at showed the trash time — null on any live note. Only
  the read was wrong; nothing stored is, so no data needs repairing.
- The board's text facet binds its pattern once for its one placeholder. It
  pushed it twice after the title column went (95aa10c), and rusqlite refused
  every query with InvalidParameterCount — every desktop search failed.
  Android searches through store::search and was never affected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:42:51 -04:00
bvandeusenandClaude Opus 5.5 732fd7a827 core: the store tests pass clippy and rustfmt, so they reach the test step
CI & Build / Build now, or wait for Android? (push) Canceled after 0s
CI & Build / TypeScript typecheck (push) Canceled after 0s
CI & Build / Python lint (push) Canceled after 0s
CI & Build / Python tests (push) Canceled after 0s
CI & Build / integration (push) Canceled after 0s
CI & Build / Build & push image (push) Canceled after 0s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 3m48s
Desktop (Tauri) / Update manifest (push) Canceled after 0s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Canceled after 3m55s
Android / Kotlin + Rust (APK) (push) Canceled after 4m29s
7bc8e04 never got as far as running them: clippy's cloned_ref_to_slice_refs
rejected four `&[x.clone()]` slices, and the file wasn't rustfmt-formatted.
Still tests only — the expected RED is the two timestamp tests and the
text-search tests, ahead of the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:35:10 -04:00
bvandeusenandClaude Opus 5.5 7bc8e04518 core: the local store has tests, and two of them fail on today's code
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 33s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m45s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m46s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 5m43s
store.rs had none, which is how two bugs reached desktop and Android unseen.
These exercise every board facet, the timestamps, tags, revisions, items,
trash, reminders and label merges against a real migrated schema.

Two are expected RED on this commit, on purpose, so CI shows they catch what
they were written for:
- each_timestamp_comes_from_its_own_column / a_new_note_carries_both_timestamps:
  load_note reads created_at and updated_at one column too far right since
  fa89da1 dropped `color`.
- text_search_*: the text facet binds its LIKE pattern twice for one `?`,
  left over from title+body (95aa10c).

The fix follows in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:29:33 -04:00
bvandeusenandClaude Opus 5.5 a706644455 rename: the server is Inkwell — package, env vars, image, compose, export marker
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 45s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 4m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 7m33s
Desktop (Tauri) / Update manifest (push) Successful in 7s
Android / Kotlin + Rust (APK) (push) Successful in 11m25s
Step 2 of milestone 481. The operator chose a full rename (Scribe note 5071), so
this goes past the display strings into the identities:

- src/thoughtsync → src/inkwell; every import, the Dockerfile and both compose
  commands, alembic env, pyproject
- THOUGHTSYNC_* → INKWELL_* (database URL, secret key, log level, tag/port/bind)
- container data dir /var/thoughtsync → /var/inkwell
- image git.fabledsword.com/bvandeusen/inkwell; Postgres user/db default inkwell;
  CI's integration service follows
- the files the image serves are inkwell.*. fetch-clients.sh still fetches the
  thoughtsync-named release assets, because the lanes that publish them are
  renamed in steps 3 and 4
- exports are written with app "inkwell"

Two deliberate exceptions, both because data rides on them:

- compose volumes are now named explicitly and overridable (INKWELL_DB_VOLUME,
  INKWELL_DATA_VOLUME), so a deployment installed as ThoughtSync points at the
  volumes and DB identity it already has. .env.example says exactly what to set
- import still accepts app "thoughtsync", because exports written before the
  rename are backups. Tested both ways

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:18:49 -04:00
bvandeusenandClaude Opus 5 193dfb9e94 tags: renaming onto an existing tag merges them, and the older row survives
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m35s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m46s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 5m37s
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
The three surfaces did not agree on what renaming a tag onto a name another
one already holds should do, and none of the three answers was good.

I described this wrongly first time and the correction matters. The local
store does NOT silently create a duplicate: `idx_labels_name` is unique on
`lower(name)`, so the bare UPDATE in `rename_label` failed, and the user got
a raw SQLite "UNIQUE constraint failed" as their error message. The server
meanwhile answered 409 "a tag with that name already exists" — and only on
an EXACT match, because its constraint is on the raw name while every
client's index is on `lower(name)`.

That last part is the sharper bug. The server would happily hold "Groceries"
beside "groceries"; no synced client can store both. Creating that pair on
the web armed a pull that fails later, on a phone, in a path with no UI.

Operator's call: a rename onto an existing name means merge — typing an
existing tag's name onto this one says they are the same thing.

  * `store::rename_label` and the server's PATCH now implement one rule.
    THE OLDER ROW SURVIVES and takes the new spelling. Age rather than "the
    one that already held the name", so that renaming A→B and B→A land on
    the same survivor; otherwise the outcome depends on which way round
    someone typed it, and two devices tidying the same pair disagree about
    which id still exists. Ties go to the incumbent, so it stays
    deterministic.

  * The core reuses `merge_labels` rather than reimplementing the move. That
    is the only place that knows to mark every affected NOTE dirty before
    the delete cascades the membership rows away, which is what makes a
    merge reach the server at all.

  * The server's rename and its `/merge` route now share one `_merge_into`
    helper, for the same reason.

  * Both server lookups became case-INSENSITIVE, matching every client. The
    create path is included: it was the one actually minting the unstorable
    pair, so fixing only the rename would have left the door open.

  * The web asks before merging, naming both note counts. A merge cannot be
    undone by repeating it and is now reachable by a typo in a text field —
    the same reasoning as the delete confirmation in #2116. The confirmation
    lives in the shared store, so the desktop gets it too; the FFI does not
    ask, because that belongs to the surface with a person in front of it.

  * The web store detects the merge from the LIST, not the response: the
    survivor may be the row we asked to rename, so an unchanged id proves
    nothing.

Tests: three integration tests over a real database (both rename directions
land on the older row; a case-varied create returns the existing tag) and
two through the Android FFI, which is the binding the phone will use.

Also fixes a straggler from 8c7553d — the delete confirmation still said
"the label".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-08-31 16:46:15 -04:00
bvandeusenandClaude Opus 5 e14d9d340a core: the v9 test pinned v8, and a blank line ktlint counted
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m33s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m48s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 7m35s
Two CI failures from the colour removal, both mine.

`a_fresh_database_reaches_v8` asserted the version the migration no longer
stops at. Renamed to say what it actually guards — the LATEST version — so the
next migration updates a number instead of a name that has quietly become
wrong.

While there, two tests the migration deserved and did not have. One asks
SQLite whether `notes.color` is gone rather than reading a row back, because a
SELECT that omits the column passes either way; it also asserts `labels.color`
is still there, since getting that wrong would take every tag's colour with it.
The other seeds three saved views and checks the sweep: one loses its colour
key and keeps its query, one without the key is untouched, and one holding
text that is not JSON at all comes out unchanged rather than NULL.

Writing that third case is what found a real bug in the migration. The guard
was `json_valid(params) AND json_extract(params, '$.color') IS NOT NULL`, which
is the obvious way to write it and is a trap: SQLite does not promise to
short-circuit AND, so `json_extract` can be evaluated against the very rows
`json_valid` was there to exclude — and on malformed input it does not return
NULL, it RAISES, which would have aborted the whole migration over one corrupt
blob. It is a LIKE now, which is total over any text.

The ktlint failure is a doubled blank line where `EditorAction.SetColor`'s
branch used to be.

#3041

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:15:33 -04:00
bvandeusenandClaude Opus 5 fa89da1fab notes: color leaves the model, the wire and all three surfaces
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 14s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m28s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 4m1s
Step 3 of M315, and the destructive half. Steps 1 and 2 stopped every read of
this field: a card is one neutral surface per theme, and the only coloured
thing on a board is a tag. What was left was a column written by a picker and
read by nothing.

Rule 22 — the old path comes out completely. No flag, no fallback, no
"override if set".

Server: the column, the `?color=` facet, the create/update/serialise paths,
the sync assignment, the front-matter line, and Keep's colour map. Alembic
0029 drops it and sweeps `"color"` out of stored saved-filter params — a view
that silently filtered on a field the app no longer has would return nothing
and never say why. That sweep is Python, not `params::jsonb - 'color'`,
because Postgres has no try-cast and one malformed blob would abort a
migration that is running over somebody's saved views.

`NOTE_COLORS` moves from `models/note.py` to `colors.py`. A palette defined on
the model that lost one is an invitation to put the column back; labels still
name a colour, so the vocabulary belongs where the normalizer already is.

Core: the field, the facet, the `NoteCreateInput`, and every read and write in
store/push/pull. Local schema v9 drops the column and does the same
saved-filter sweep, guarded on `json_valid` so a corrupt blob loses a key
rather than becoming NULL. The uniffi layer drops `NoteEdit::Color` and
`NoteDraft.color` with it.

Web: `ColorPicker.vue`, the per-card swatch popover and its stylesheet rule,
the FilterBar colour row, the facet in the query round-trip, and the colour
half of the editor's baseline-and-save. Android: the `ColorSheet`, the
`Picker.COLOR` case, the toolbar's swatch dot, `EditorAction.SetColor`.

## The protocol: v4, and the floor deliberately stays at 3

Checked against `compat.rs` and the push handler rather than trusting the
`#[serde(default)]` annotation, because the v2 precedent points the other way:
v2 dropped `kind` and `title` and DID raise both floors, on the rule that
dropping a field a client sends and expects back is breaking.

`color` fails the second half of that test. A v3 client reading a v4 note gets
`"default"` from its own serde default and draws the colour it derives
locally — the board it drew yesterday. A v3 client pushing `color` has the key
ignored, since `_assign_note_fields` reads its payload key by key and never
validates the shape. Neither direction errors and neither shows anything
wrong. `title` was the note's NAME; this is a field that no longer renders.

So `SYNC_PROTOCOL_VERSION` and `CLIENT_PROTOCOL_VERSION` go to 4, and both
floors stay at 3. `docs/sync.md` carries the reasoning and the per-version
history, and its push example is brought back in line — it still listed
`title`, `kind` and `items`, all gone before this.

Import stays tolerant: a pre-M315 export or a Keep takeout carrying `color:`
imports fine, the key simply read past. Old exports must still import.

#3041

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:07:03 -04:00
bvandeusenandClaude Opus 5 4f351c10ca core: rustfmt wraps the chain in the tag-span grammar test
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m10s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m29s
Android / Kotlin + Rust (APK) (push) Successful in 7m44s
`cargo fmt --all --check`, the only failing gate on d9e5753 — clippy, all 148
tests and every other lane were green. The line was 96 characters, under the
100 max_width, but a chain is held to `chain_width` (60% of it).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:23:46 -04:00
bvandeusenandClaude Opus 5 d9e5753dc2 board: a tag in the prose is coloured where it sits, not printed twice
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) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m53s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 3m25s
A tagged note was showing its tag twice — once where it was typed, once as a
chip — and the duplicate was the loud copy. Now the chip row carries only what
the body cannot say (a tag lifted off its own line, a label from the picker),
and a `#tag` left mid-sentence is tinted in place.

Which characters are a tag is asked of the CORE, the way the card already asks
it which lines are checklist items: `extract_tag_spans` keeps the spans
`extract_tags` throws away, and `body_tags` hands them to Kotlin. Offsets are
UTF-16 code units, because `AnnotatedString` and JS both index that way and a
char index lands mid-token the first time somebody writes an emoji. The web
keeps its own matcher in markdown.ts, mirroring `line_tags` case for case.

The inline ink is its own table, one Tailwind step deeper than the chip's. A
chip brings its own -100 fill and reads against that alone; inline text sits on
whatever the card is, including a gray-tagged card at neutral-200 — where the
chip's -700 measured 3.98 (green), 4.11 (orange) and 4.34 (teal), under the 4.5
body text needs. At -800/-300 every hue lands 5.63-12.01 light and 7.20-10.84
dark across every palette and generated fill.

Chips now carry the `#` on every surface. The via_tag branch that used to
decide it is gone from the card, and Android's row said no hash at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:20:17 -04:00
bvandeusenandClaude Opus 5 8c22425e91 M311 step 3 — the core lifts too, so a note never lifts twice
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m58s
Android / Kotlin + Rust (APK) (push) Successful in 7m56s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m13s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Step 2 rewrote the notes already on disk and, because the sync_revision
trigger fires, every client pulls them. So this is not about existing notes.
It is about the ones typed from now on.

Without it: you type `#todo` on its own line, the core stores it as written,
and a second later the push comes back and the text disappears under you.
Offline it never lifts at all until you reconnect. Two surfaces disagreeing
about what a note says is the thing this codebase mirrors rules to avoid.

`lift_standalone_tags` in derive.rs is the mirror of `split_body_tags`, case
for case, with the same two guards — a fenced line is code and is never
touched, and a note that is nothing but tags keeps its text.

ONE SCANNER, not two. `extract_tags` is rewritten over the same `line_tags`
the lift uses, so the two cannot disagree about what a tag is. Line-by-line
changes nothing, since a line start and a `\n` are both boundaries, and the
existing tag tests still pin it.

Char indices rather than byte offsets for the spans, because they are used to
cut the tags back out of the line and a byte offset can land mid-codepoint.

`sync_tags` becomes `lift_and_sync_tags` and is named for the mutation: it
now rewrites notes.body, and all three callers write the body immediately
before calling, so it overwrites what they wrote on purpose. The graduation
case is handled the same way as on the server — flip the row before the
delete pass, or the same row is dropped for no longer being in the body and
the tag is silently lost.

One thing the server needed and this does not: display_title. The core
derives it on READ rather than storing it, so there is no persisted copy to
go stale.

The rename was done with a lookbehind rather than a plain substitution, after
the same operation an hour ago turned the function it had just written into
`_lift_and_lift_and_reconcile_tags`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 20:01:32 -04:00
bvandeusen a88f7c2dd0 core: drop the two helpers the block editor made unnecessary
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m56s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m15s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m30s
`toggle_at` existed to map a tap on `[ ]` inside a plain text field to an item, and
`continuation` to make Return start the next one. The block editor needs neither: a
checkbox is a real Checkbox, so it is tapped rather than located, and Return is the
field's own IME action rather than a shape recognised in a string.

Removed rather than kept for later (rule 22). Both were exported over the FFI with
no Kotlin caller, which is API surface promising something nothing does — and their
tests were weight on code nothing runs.

The section comment above them described the tap-in-a-text-field problem, which is
no longer the problem this pair solves. Rewritten to say what is actually there:
one function to read a body apart, one to put a line back together, and between them
Kotlin renders checkboxes without owning the grammar.
2026-08-24 10:42:26 -04:00
bvandeusen b2435d97b6 android: the editor draws the checklist instead of the markup for one
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m30s
Android / Kotlin + Rust (APK) (push) Failing after 4m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m53s
Desktop (Tauri) / Update manifest (push) Successful in 4s
2992. A checklist item is a real Checkbox with its text beside it, so a box can be
ticked while looking at the note — which is what M304 left undone. It changed where
a checklist is STORED and never changed what the editor draws.

The body is split into blocks and joined back on every edit, so the note underneath
is the same markdown string it was this morning. Nothing below the editor can tell
this exists: no migration, no protocol change, no new shape on the wire.

A run of prose lines is ONE block, not one per line. Typing a paragraph has to feel
like typing a paragraph, and a separate field under every sentence would break the
caret mid-sentence. Only a checklist item earns a block, because only a checklist
item needs a widget.

Two things that look like detail and are not:

  * A block carries its own TextFieldValue, and an ID that survives insertion.
    Compose keys fields by position unless told otherwise, so adding an item would
    otherwise move every caret below it up a row. Content cannot be that key —
    two empty items are identical and neither is the other.
  * Focus is hoisted to the screen rather than kept inside BlockBody, because the
    toolbar's checklist button also asks for one. Two owners of one cursor is one
    too many.

Return on an item makes the next item and puts the caret in it; on an EMPTY item
the block becomes prose, which is how a list ends and how you get a paragraph after
one — the same rule the plain text field used, now with somewhere to land. It
appends rather than splitting at the caret: splitting an item in two is a rarity,
and the caret is at the end for every ordinary use of that key.

The core gains `render_item` and `DerivedItem.line`; `item_lines` and
`checklist_lines` are gone, subsumed. Every renderer that walks a body line by line
needs the text, the state and the position TOGETHER — asking for them separately is
how two calls come to disagree about a body that changed between them. The card now
reads its items from the body for the same reason, instead of from note.items,
which is the same list by a longer route and one save behind.

WANTS A DEVICE PASS, and the focus behaviours are what to look at: return making a
row and landing in it, return twice at the end of a list getting you a paragraph,
and rotation restoring the right field. CI can only prove this compiles.
2026-08-24 10:14:53 -04:00
bvandeusen 68b2a5dc8d android: a checklist is lines of the note here too
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m22s
Android / Kotlin + Rust (APK) (push) Failing after 4m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m3s
Desktop (Tauri) / Update manifest (push) Successful in 4s
M304 step 6, and the surface with the least room to hide: Android has no markdown
renderer at all, so the card was about to show every list twice — once as literal
`- [ ] milk` in the body preview, and again as the glyph rows underneath. Same bug
the web had, one commit later.

The card now renders the body LINE BY LINE and draws a checkbox where one belongs,
which is what puts a list between two paragraphs instead of always after them. The
glyphs became tappable while they were being rewritten: ticking something off from
the board without opening the note is the common gesture, and the web just gained
it. The tap target is the glyph, not the row — tapping the TEXT still opens the
note, the way tapping anywhere else on a card does.

Kotlin gets no parser. Three implementations of the grammar is the price already
paid; a fourth in Compose would be a fourth place for a checklist to change shape
when it syncs. So the core exposes three pure functions instead —
`checklist_lines`, `checklist_continuation`, `checklist_toggle_at` — and Kotlin
does the caret arithmetic around them.

Those are FREE functions, not methods, and that is the interesting constraint. The
editor's body field is LOCAL state on an idle-debounced autosave, so anything that
edits a checklist there has to rewrite the text the field is holding, not a row the
store would hand back a moment later. Going through the store would overwrite
whatever was being typed. The BOARD has no such problem — nothing there is holding
a half-typed body — so the card's toggle goes through the store as usual.

`toggle_at` addresses an item by LINE and COLUMN rather than a text offset, because
the two sides do not count the same way: Compose measures in UTF-16 units and Rust
in bytes, so the same number means different places in a note with an emoji in it. A
line number is identical in every encoding, and so is a column inside the marker,
which is ASCII at the start of its line.

In the editor: the toolbar button inserts `- [ ] ` at the caret — the only toolbar
action needing no saved note, so it works on an empty compose box the moment it
opens — and Enter continues the list, or ends it on an empty item. Continuation is
recognised by SHAPE inside onValueChange (exactly one more character, and it is a
newline) rather than by a key event, so a paste or an autocorrect falls through
untouched.

EditorChecklist.kt and the four item actions are gone (rule 22). Adding, renaming,
ticking or deleting an item is editing text now, and the editor already does that —
through SaveText, with the same autosave and the same revision window as any other
edit.

KNOWN GAP, not an oversight: tapping a checkbox inside the EDITOR does nothing yet.
Material3's TextField does not expose onTextLayout, so mapping a tap to a character
offset means either moving the body to BasicTextField or intercepting pointer events
ahead of the field — both real changes to the surface this operator uses most, and
neither verifiable without a device. `checklist_toggle_at` lands here, tested, so
that task is pure UI. Ticking from the board works today.
2026-08-24 08:16:57 -04:00
bvandeusen 32dafca148 core: what rustfmt actually wanted
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m59s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m14s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m11s
Three from the checker's own diff. Two are the rule I had guessed at: when a
call overflows and its last argument is a closure, rustfmt keeps the earlier
arguments on the line and expands the closure into a block, rather than putting
every argument on its own line.

The third is a stray double blank line before the test module.
2026-08-24 00:49:05 -04:00