f992439588249399ee9241950d7b4981a495b188
23
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e14d9d340a |
core: the v9 test pinned v8, and a blank line ktlint counted
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> |
||
|
|
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> |
||
|
|
4f351c10ca |
core: rustfmt wraps the chain in the tag-span grammar test
`cargo fmt --all --check`, the only failing gate on
|
||
|
|
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> |
||
|
|
8c22425e91 |
M311 step 3 — the core lifts too, so a note never lifts twice
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> |
||
|
|
a88f7c2dd0 |
core: drop the two helpers the block editor made unnecessary
`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. |
||
|
|
b2435d97b6 |
android: the editor draws the checklist instead of the markup for one
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.
|
||
|
|
68b2a5dc8d |
android: a checklist is lines of the note here too
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. |
||
|
|
32dafca148 |
core: what rustfmt actually wanted
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. |
||
|
|
668f7faf03 |
core: the body is the checklist, and checklist_items is gone
M304 steps 2 and the client half of 4, together — they cannot be separated. A
commit where the store writes items into the body while push.rs still reads them
from a table is one that silently pushes the wrong list, and dev publishes to the
dev channel on every green build.
Store:
* load_items becomes items_of(body) — a parse, not a query. An item's id is its
ORDINAL, which is all it ever amounted to: push.rs sent text and checked and
never an id, and both sides replaced the whole list on every sync.
* add_item / update_item / delete_item route through update_note, so they get
revision snapshotting, #tag re-derivation and the dirty/updated_at bookkeeping
without any of it being written a second time.
* create_note folds its items: input into the body, and syncs tags from the
FOLDED body — an item can carry a #tag too.
* display_title no longer takes items, because items ARE body lines now. It
strips the task marker instead: a list-only note is still named by its first
item, and calling that note "- [ ] milk" would show someone the storage.
Wire: items leave it. A second copy of data already in the body field of the same
message is how the two come to disagree. CLIENT_PROTOCOL_VERSION and
MIN_SERVER_PROTOCOL_VERSION go to 3, which is what makes this safe to land before
the server: a v3 client refuses a v2 server outright rather than pushing a body
whose list the old _apply_note_items would then delete.
Schema v8 folds every existing row into its note's body before dropping the
table. Written in Rust, not SQL: the fold has to produce exactly what
derive::append_item produces, and group_concat only gained a guaranteed ORDER BY
in SQLite 3.44 — a checklist that quietly reordered itself during a migration
would be a poor way to learn that. updated_at and dirty are deliberately left
alone, because the server's migration folds the same rows the same way and both
sides land on identical bodies; marking every note dirty would push a body the
server already has, from every device at once.
NOT deployable yet. The server still speaks v2 and still has note_items, so a
client built from this will refuse to sync until the server half lands.
|
||
|
|
d0e3e48943 |
core: rustfmt splits on fn_call_width, not max_width
Three assertions I had collapsed to one line because they fit inside max_width=100. rustfmt's fn_call_width is 60 and applies to the ARGUMENT list, so a call can sit well under the line limit and still be split vertically. Clippy and the tests were already green; this is the formatter only. |
||
|
|
1045db318b |
core: derive checklist items from the body, the way tags already are
First step of M304. Additive on its own — nothing calls this yet — so it can be read and tested before anything depends on it. A checklist is currently a TABLE, and a table can only ever render after the body, because a row has no idea where in the note it belongs. That is why "inline with the note" is not a styling problem: prose, three checkboxes, then more prose is not expressible at all today. derive.rs already owns "structure derived from body text" for #tags and says so in its module doc. Task lines join it rather than opening a second home for the same idea. The difference between the two is worth stating and now is: tags MATERIALISE into label rows because the board queries by label; items materialise into nothing, because nothing queries them. Their only readers are the card, the editor, and display_title. The grammar is fixed here because three languages will implement it — derive.rs, notes/checklist.py, notes/markdown.ts — and any difference between two of them is a checklist that changes shape when it syncs. `*` is accepted since markdown.ts already takes it for a plain bullet, and a rule that allowed `* item` but not `* [ ] item` would be one nobody could guess. `- [ ]` with nothing after it parses as an empty item: that is what pressing Enter on a list leaves behind, and refusing it would make a half-typed list stop being a list. `- [X]` parses and normalises to lowercase on the first rewrite, so round trips are stable. append_item spaces its output exactly as import_export.py:_note_markdown does. That is not cosmetic — the server migration will fold existing rows into bodies with the same layout, so an export taken before it and one taken after have to agree byte for byte. A stale index is inert rather than fatal: the index comes from a UI that may be a moment behind the store, and a late tap should do nothing rather than panic. |
||
|
|
2707054563 |
A write should not cost a revision
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 12s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m21s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 7m42s
Every body change snapshotted into history — core/src/local/store.rs and notes/__init__.py both — so a write was expensive, and the clients compensated by writing as rarely as they could. BoardViewModel says it outright: "Saved on close rather than per keystroke, so a session of typing costs one write and one revision snapshot." That is durability paying for version history. An app kill mid-session lost everything typed, so that the revision list would stay tidy. The safety property is worth more than the feature it was subsidising, and no comparable product makes this trade: Keep and Apple Notes write continuously with no history, Docs and Notion write continuously and coalesce history behind the scenes, Obsidian debounces and snapshots on an interval. Save-on-close is the outlier, and this coupling is why we had it. A body change now earns a snapshot only if it is the first of an editing session — the body actually differs, and the note carries no revision from the last ten minutes. Session granularity falls out of the window rather than being declared. A snapshot stores the body as it was BEFORE the edit, so the first write of a sitting captures the note as you found it and every write after it inside the window adds nothing. One revision per sitting, with no commit flag for a client to send and no wire surface to carry it. That is why it is a time rule and not a protocol one. sync.py applies pushed bodies through the same check, so a client autosaving every second cannot make the server snapshot every second either — which a client-declared commit point could not have guaranteed without a protocol bump. Restoring a revision still snapshots unconditionally: a considered act, not a keystroke, and it stays undoable. Unblocks idle-debounced autosave, an honest updated_at, and the "Edited just now" line the editor is getting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
95aa10c2c3 |
Remove the title field — a note is named by its first line
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 7s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 31s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m45s
Operator (note 2897): "notes shouldn't have a title field." The concept of a NAME stays — search results, export filenames and the command palette all need one — but nothing is typed into it any more. `display_title` is now the first non-empty line of the body, falling back to the first checklist item. That fallback is what step 2 bought, and the reason this could not go first: a checklist had no body to be named from, so the title was its only name. Now every note has a body, and a note that is only a checklist is named by its first item. Gone everywhere: the column and note_revisions.title (0026), the field on the core's Note/NoteCreateInput/NoteRevision and its SQLite columns (user_version 7), `normalize_title`, the wire field, the FFI record and `NoteEdit::Title` / `ClearTitle`, the web editor's "Title (optional)" input and the card's <h3>, and the Android title field in both the compose sheet and the editor. **The search vector had to be rebuilt, not just left alone.** `notes.search_vector` is a STORED GENERATED column whose expression names `title` — Postgres refuses to drop a column another generated column depends on. It is dropped and recreated over `display_title` at weight A, which keeps the original intent: a note's NAME ranks above the rest of its body. **An imported title becomes the note's first body line.** Keep notes carry one, and so does any ThoughtSync export taken before this. Dropping it would silently lose text someone wrote; folding it in puts it exactly where a name now lives, so the note arrives named as it was. Skipped when the body already opens with that line, so re-importing an export this code produced doesn't stack duplicates. Two smaller things fell out. The Android editor loses its bold first field — one weight throughout, because the first line is the note's name but not a different KIND of text, which is most of step 4 arriving early. And `ClearTitle`'s justification comment moved to `ClearRemindAt`, which is now the surviving example of why NoteEdit is a list rather than a struct of options. Protocol note corrected to say what actually shipped: v2 is "no kind, no title", one bump for the pair. Verified with the local Rust gate this time, not by CI: fmt, clippy and 116 tests all green before pushing. It caught four things — orphaned serde attributes where fields were removed, a `wire::Preview.title` I deleted by mistake (a link preview still has one), nine retention fixtures inserting a dropped column, and four rustfmt diffs. |
||
|
|
6d778f26a7 |
Fix the ktlint and compat-test failures, and start using the Rust gate
Two more from the step-2 removals: **Two unused Kotlin imports** — `FilterChip` (the Note/List switch) and `Icons.Filled.Create` (the "switch to a note" icon), both orphaned when their callers went. ktlint treats them as errors. **`server_info_tolerates_unknown_and_absent_fields`** pinned `sync_protocol_version: 1` as a literal, so bumping the protocol to v2 made it fail for a reason that has nothing to do with what it tests. It is about unknown FIELDS; the versions now come from `CLIENT_PROTOCOL_VERSION`, like every other test in that file already did. The bigger fix is the habit. `ci-requirements.md` has documented since 2026-08-18 that the operator authorised running fmt/clippy/test against the CI image locally, and I had not been doing it. All three now pass here — 116 tests, clippy clean, fmt clean — and every Rust failure in this milestone so far would have been caught by them in under a minute instead of by CI, several commits downstream. Noted in ci-requirements so the next session doesn't relearn it: a removal is exactly the change that looks too safe to check. |
||
|
|
33e9278975 |
Fix three breaks the removals left behind
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m53s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m4s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 4m27s
**`snapshot_revision` was deleted with `create_titled`** ( |
||
|
|
c46a4a7709 |
A checklist is something a note has, not something a note is
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 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 9s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 8s
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 13s
Android / Kotlin + Rust (APK) (push) Failing after 1m43s
`kind` was never a type. A plain TEXT column with no enum and no CHECK behind
it, compared against a hardcoded ("text", "list") tuple in six places;
`note_items` was always an ordinary child table keyed by note_id; serialization
already emitted `items` whatever the kind; and the Android editor already
toggled between the two losslessly, saying so in a comment. The storage has
modelled "a body plus optional checkable items" the whole time. This deletes the
gates that forbade it.
Every surface: the create/PATCH gates, the ?kind= filter and its saved-filter
facet, the three import/export branches, the column (alembic 0025); the core's
`kind` field, its SQLite column (user_version 6), the sync wire, push and pull;
the FFI records and `NoteEdit::Kind`; and on Android `NoteKind.kt`, `DraftKind`,
the compose sheet's Note/List switch, and the branches in the card, the editor
and the chrome.
The editor's note⇄list toggle becomes "Add a checklist" — on both the web and
Android. It is not a conversion any more: nothing moves, nothing is swapped, the
body stays exactly where it is and the note gains somewhere to put items. The
card renders both, in order.
Two things that fell out of the merge rather than being aimed at:
- The Keep importer was DISCARDING `textContent` whenever a note also had
`listContent`, because the target could only hold one. Both survive now, and
the test says so.
- Markdown export wrote the body OR the checklist. It writes both.
Protocol goes to v2, floor included: dropping a field a v1 client sends and
expects back is breaking. `title` leaves in step 3 and lands in the same
generation, so it needs no further bump. This is the change that will make the
0.1.227 build on the operator's phone refuse to sync — the in-app updater is
independent of the handshake and remains the recovery path.
The V1 SQLite schema deliberately KEEPS the kind column. V1 is the historical
schema and every later block alters it, so removing it there would make a fresh
database run V1 without the column and then v6's DROP COLUMN against a column
that never existed — "no such column: kind" on every new install.
|
||
|
|
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
|
||
|
|
81695fa0c8 |
android: update the app from the server it syncs with (2727, M12 step 7)
Closes M12. The phone can now notice that its server has a newer build and install it, instead of the operator copying an APK to a device by hand. **A PackageInstaller session, not an install intent.** The obvious route — ACTION_VIEW on the APK — is exactly what on-device install heuristics are tuned against, and it is what produced the "bypassing Android security" warning on Minstrel (Scribe note 2437). It also never tells the OS that this app is the legitimate updater of its own package, and it returns nothing: a failed install is indistinguishable from someone dismissing the dialog. The session says who is doing what, and on Android 12+ declares no user action required — which, with UPDATE_PACKAGES_WITHOUT_USER_ACTION, removes the confirmation entirely on the UPDATE path. Only there: Android will not let an app quietly put a NEW package on a device, which is right. It also only applies when the new build carries the same signing key as the installed one, which is why signing had to land first. Two things from that research deliberately NOT done: `setRequestUpdateOwnership` was chased and turned out to be a red herring, and REQUEST_INSTALL_PACKAGES is not the differentiator either — Mihon declares it too. The mechanism was the whole difference. **The outcome comes back.** `commit` takes an IntentSender and the result lands at `UpdateReceiver`, so a failure can be shown rather than guessed at, and STATUS_PENDING_USER_ACTION is handled — that is the ordinary path below API 31 and still possible above it, since the OS is entitled to ask anyway. Someone declining is reported as no error at all: calling a deliberate choice a failure is how an app sounds broken when it is not. **The network work stays in Rust.** Two FFI additions — `clientUpdate` and `downloadClientUpdate` — because the device token lives in the core, and pulling it into Kotlin to make an HTTP call would spread the one secret this app holds across two languages for nothing. The core also owns the comparison, so the rule "version CODE decides, never the name" lives in the layer that has to get it right for every surface. The download is streamed to disk, not buffered: 55 MiB in memory on a phone is how an update gets killed halfway through. It lands in `update.apk.part` and is renamed only once size and sha256 both match, so an interrupted download can never be mistaken for a finished one. The digest is not a trust anchor — the signature is, and Android checks it — but it catches a truncated transfer before the installer is bothered with it. The advertised path is joined to the base URL this device is LINKED to rather than followed as given, so a server cannot point the download at a host nobody agreed to. **Updates are linked-only, and it says so.** An unlinked install has no update path, so it gets one sentence explaining where updates come from rather than a Check button that silently finds nothing — the same lesson as the desktop's unlink copy (issue 2110). And the "install unknown apps" grant is asked for BEFORE downloading, so nobody spends 55 MiB to be told no. Every Android API here was read out of `android-36/android.jar` with javap first, and the two new FFI methods out of freshly generated bindings, rather than recalled: `suspend fun clientUpdate(installedVersionCode: Long): ClientUpdate?` and `downloadClientUpdate(destPath: String)`. Also fixes `check-symbols.py`, which reported four false positives on `UpdateOutcome.Result` — its object-member index collected functions and properties but not nested TYPES, and a data class inside an object is an ordinary member. |
||
|
|
f38864088b |
core: a completed recurring reminder advances instead of ending
`complete_reminder` cleared `remind_at` and said so in its own comment — "(Recurrence advancement is a later refinement.)". So Done on a daily reminder was quietly the last time it ever fired. Reminder notifications made that much easier to hit, because Done is now a button in the notification shade. **The server already had this.** `src/thoughtsync/notes/recurrence.py` has done it correctly all along, which means the web behaved one way and the desktop and Android the other, on the same note, in the same account. This is a port of that file rather than a fresh implementation, kept behaviourally identical rather than merely similar: the same reminder can be completed from a browser or a client, and a disagreement would move it depending on which one you happened to use. The seven new tests in `core/src/local/recur.rs` mirror the Python suite case for case, including the one that matters most in practice — 31 January plus a month is 28 February, and the step after that is 28 March rather than back to the 31st. That clamp is sticky, and it is now asserted on both sides so a future "fix" to either has to change both. Advancement is measured from the reminder's own time, never from now, which is what keeps a 09:00 daily reminder at 09:00 when it is dealt with at 09:47. A phone left in a drawer for a fortnight rolls forward to tomorrow rather than arriving at fourteen pending occurrences of the same thing. Also matched from the server, and a latent bug of its own: the non-recurring branch now clears `recurrence` as well as `remind_at`. Before, completing a note that carried a rule left the rule behind with no reminder attached — invisible in every UI, since they only render recurrence when there is a reminder to recur from, and waiting to surprise whoever next set a time on that note. Documented rather than hidden, and shared with the server: the arithmetic is in UTC and a note carries no timezone, so a daily reminder crossing a DST boundary keeps its UTC time and shifts by an hour locally. Fixing that means a zone per note, which is a wire-format change. Verified in the CI image before pushing: fmt, clippy --all-targets -D warnings, and the full suite — core 89 to 96, ffi 11 to 12. The new FFI test walks the path the notification's Done button actually takes. |
||
|
|
f90b9203a7 |
android: bind the core to Kotlin through uniffi (M12 step 4)
`android/ffi` is to Android what `desktop/src-tauri/src/commands/` is to the
desktop: a shim over the shared core holding no logic of its own. Third workspace
member, so the desktop lane's `cargo clippy --all-targets` compiles and lints it
— which until the Android lane lands (step 5) is the only thing that does.
Three decisions worth stating.
MIRRORED RECORDS, NOT DERIVES ON THE CORE. The core's model structs are serde
shapes contracted with the shared Vue frontend, and one of them holds a
serde_json::Value, which has no uniffi representation. Hanging uniffi derives on
them would couple two unrelated consumers to one definition. The cost of
mirroring is drift — an Android client quietly missing a field the desktop
gained — so every conversion destructures the core struct exhaustively. Add a
field to core::local::models::Note and this crate stops compiling until Android
is told what to do with it.
NoteEdit IS A LIST, NOT A STRUCT OF NULLABLE FIELDS. The store's patch format
distinguishes three states: leave alone, set, and clear to null. Kotlin cannot
express the third with a nullable field — `title = null` in a data class is
indistinguishable from `title` unset — so the editor could never clear a title.
Explicit Clear* variants say it out loud and give Kotlin a sealed class.
ASYNC IS TOKIO-BACKED, AND CANCELLATION ALREADY WORKED. Exported async methods
become Kotlin suspend functions. When a coroutine is cancelled uniffi drops the
future, and no async path in the core holds the store lock across an await —
a std MutexGuard isn't Send, so the compiler has been enforcing that all along.
A cancelled sync leaves the store consistent and simply hasn't stamped
last_sync_at, which is only written after both halves of a cycle succeed.
Also here:
* core gains Db::conn(). Every consumer was writing
`db.0.lock().map_err(|e| e.to_string())?` by hand, and worse, any helper
returning the guard had to NAME rusqlite::Connection — which would have made
rusqlite a dependency of a layer whose whole point is not knowing what the
store is made of. Same trap as the update.rs test module in step 1.
* The uniffi `cli` feature is gated behind our own `bindgen` feature. It drags
in clap, askama and goblin for a three-line binary, and the desktop lane
should not compile a code generator it never runs.
* The bindgen binary lives in this workspace on purpose: generated bindings and
the linked uniffi runtime are two halves of one ABI, and compiling the
generator against the same dependency keeps them in step by construction.
That is why ci-rust-android ships no uniffi-bindgen.
Tests cover the round trip the Android skeleton needs (open a store in a
directory that does not exist yet, write a note, read it back), that a body-only
note still has a display_title, that set and clear are genuinely different
edits, and that an unlinked app reports NotLinked rather than an error.
Known and deliberate: the workspace sets panic = "abort", so a panic crossing the
FFI aborts instead of arriving in Kotlin as an exception. Same behaviour the
desktop already has; noted in the crate header rather than silently changed.
Scribe #2733.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
e696b23417 |
core: give consumers an in-memory store instead of a rusqlite dependency
The extraction left update.rs's tests reaching for rusqlite and uuid directly to build a Db — crates that now belong to the core alone, so clippy failed on unresolved imports. The Windows job had already compiled the whole installer, so this was only ever the test module. Adding rusqlite as a dev-dependency of the desktop crate would have fixed it and quietly undone part of the point: the desktop is not supposed to know what the store is made of. So the core exposes open_in_memory() instead, which is what the caller actually wanted, and the Android bindings will want the same thing when they get tests. uuid went the same way. It was generating unique scratch-directory names, which a process id plus a counter does without a dependency — process id separates concurrent cargo test runs, the counter separates tests within a run. The comment right above it already said nothing there was worth a new dependency. Verified the boundary holds in both directions afterwards: the desktop crate references none of rusqlite/uuid/chrono/reqwest/sha2, and the core references no tauri. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0a7480cf9b |
core: extract the store and sync engine into a shared crate (M12 step 1)
Android becomes a native Kotlin client over this same code (Scribe note 2730), so
the local store and sync engine stop being modules of the desktop app and become
`thoughtsync-core`, a crate with no UI framework in it at all.
This is a move, not a rewrite, and the measurement is why: every file in local/
and sync/ already carried ZERO Tauri references — 4,980 of 6,372 lines. The
coupling was 473 lines of command shim, which stays behind in the desktop crate
as src/commands/. Kept as git renames so history follows the files.
The desktop imports them under their old names (`use thoughtsync_core::{local,
sync}`) so every call site reads exactly as before. What moved is where they
live, not what they are.
Two things a workspace changes that are easy to miss, both caught before pushing:
[profile.release] now lives at the workspace ROOT. Cargo silently ignores
profiles declared by a non-root member — leaving it in the desktop crate would
have dropped lto/strip/opt-level from every release build with only a warning.
And a workspace shares ONE target dir, so the bundles moved from
desktop/src-tauri/target to target/. Thirteen references across publish-release,
debundle-graphics, verify.sh, package-prebuilt and the workflow now point there.
Pinning target-dir back would have been the smaller diff, but the Android lane
also produces Rust artifacts and they do not belong under desktop/.
Also retires the Tauri Android lane in the same push rather than leaving a path
that is being replaced: gen/android, android.yml and docs/android-dev.md are
gone, the mobile_entry_point attribute with them, and the lib drops to rlib —
staticlib/cdylib existed for Tauri mobile, and the .so Android loads will be
built from the core crate instead. Rule 22, no parallel path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|