85ead4d66be01de3c45a057aaec3b268ae37eedc
34
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
9810a75564 |
M311 step 2 — the migration that lifts the notes already written
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) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 17s
CI & Build / Build & push image (push) Successful in 19s
Step 1 made new saves lift; this does the ones already on disk, so a note stops showing its tag twice without having to be opened. Same rule, and a FROZEN copy of it — `split_body_tags` is deliberately not imported, on 0027's principle that a migration has to keep producing what it produced the day it ran. If the app's rule is ever loosened, this file must not loosen with it and start eating prose it previously left alone. `_display_title` is inlined for the same reason, and recomputed only for a note whose body actually moved: a note named after its `#todo` line needs a new name. The label rows graduate in the same transaction, and that is not cosmetic. A `via_tag` row claims "backed by text still in the body", and reconcile detaches any row it cannot find a `#tag` for — so leaving them true would lose every lifted tag on the note's next save. Flipping them to false is also what makes the chip's × appear, which is now the only way to remove a tag whose text is gone. `updated_at` is left alone so a client holding an unpushed edit still wins under LWW. The `sync_revision` trigger does fire, which is wanted here: unlike 0027 the clients do NOT yet apply this rule locally, so the server's copy is the only correct one until step 3. The downgrade is empty and says why. It cannot restore the deleted lines — nothing distinguishes one this migration removed from one that was never there — and flipping the rows back would be actively harmful, since the text that flag claims backs them is gone and the next save would then detach the label for real. Tested on the ten cases that matter, three of which are prose that must come back byte-identical. The test pins the frozen copy against fixed expectations rather than against the app's rule — they are allowed to diverge later, which is the whole point of freezing one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ad48d30c68 |
M311 step 1 — lift a tag that is standing on its own
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) Successful in 6s
CI & Build / Python tests (push) Failing after 8s
CI & Build / integration (push) Failing after 9s
CI & Build / Build & push image (push) Skipped
A tag was shown twice: once as the `#todo` you typed and once as a chip. The
chip moved to the top of the card in 23fd2da; now the text goes — but only
when the tag was the whole line.
THE RULE: a line containing nothing but tags and whitespace is removed.
Anything else is untouched.
That is the conservative reading of "standalone" and it is the operator's:
"only lift standalone tags, leave mid-sentence ones alone". The looser
reading, also stripping a trailing tag off a prose line, is rejected because
the text does not say which kind it is — `buy milk #grocery` is filing,
`remember to call #mom` is the sentence's object, and lifting the second
leaves "remember to call". Mangling a sentence to save a duplicate chip is a
bad trade.
Two guards. A line inside a ``` fence is never touched: a `#tag` there is a
shell comment in somebody's snippet, and deleting it would eat a line of
their example. And a note that is NOTHING but tags keeps its text rather than
being blanked — a duplicated chip beats an empty card.
WHY THIS IS NOT JUST A TEXT EDIT. `via_tag` labels are DERIVED from the body:
reconcile detaches any row no longer backed by a `#tag`, and the picker only
manages `via_tag=False` rows. So a naive lift deletes every tag on the next
save, and leaves them unremovable until then.
Resolved by giving `via_tag` a sharper meaning — backed by text still in the
body — rather than deleting it:
standalone lifted, attached as an ORDINARY label. Nothing derives it any
more because nothing is left to derive it from.
inline left in place, still derived, still detached when its text goes.
Which costs nothing elsewhere, because both editors already gate their remove
button on `!via_tag` (NoteEditor.vue:618, EditorChrome.kt:349). A lifted tag
gets its × for free — and needs it, since deleting the text is no longer a
way to remove one. No wire change, no column drop, no UI change.
A tag that GRADUATES from inline to standalone is the sharp edge: its row has
to be flipped before the detach pass, or the same row is dropped for no longer
being in the body. That is the bug, and there is a test on it.
The lift and the display_title re-derivation both live inside the function,
which is renamed to admit it mutates the body. All seven call sites derive
display_title BEFORE calling, so anywhere else and every note would be named
after a line that had just been deleted. Spreading a derived-value update
across seven write paths is the failure #2965 named: "easy to miss, and it is
the common one".
Existing notes lift lazily, on their next save. The migration that does the
rest is step 2, and the core's own copy of the rule is step 3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
1a66d9c3a8 |
tests: pin the export against writing every checklist twice
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 21s
CI & Build / Python tests (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 15s
M304 step 7. The code change landed with the server half — _note_markdown's `if items:` branch went, and the export payload stopped carrying an items array — but neither had a test, and the failure mode is quiet: every list appears twice in an export, then twice again when that export is imported back. Three cases, and the third is the one worth having. An export taken BEFORE this milestone has a body with no task lines and a separate items array, so importing one still has to fold the checklist in. That is the same fold the Keep importer does, and the reason _insert_note still accepts items at all — asymmetric on purpose: the export stopped writing them, the import did not stop reading them. |
||
|
|
fe1f72ae1b |
tests: the display-title tests still passed the argument that went away
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 18s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build & push image (push) Successful in 30s
Three of them called derive_display_title(body, first_item). I updated the call sites in src/ and not these — the integration lane and the linter both passed, because a stale keyword argument is only a TypeError at the moment it runs. Rewritten rather than deleted. The property the fallback existed to protect is still real — a note that is only a checklist has to have a name — it is just reached differently now: an item IS a body line, so the first one is simply the first line with its marker stripped. The new cases pin the two edges that rule introduces: an empty item must not name a note "", and a list of nothing but empty items still has no name. |
||
|
|
761c3b5e82 |
server: the body is the checklist here too, and note_items is dropped
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 8s
CI & Build / integration (push) Successful in 20s
CI & Build / Build & push image (push) Skipped
M304 steps 3 and the server half of 4. The client half landed in 668f7fa; these belong in one deploy, and the protocol floor below is what enforces that. notes/checklist.py is the Python half of a grammar that now exists three times — here, core/src/local/derive.rs, and (next) frontend/src/notes/markdown.ts. That triplication is the deliberate cost: the alternative is a round trip to the server before a phone can draw a checkbox. Each copy names the other two, and each is tested against the same table of cases, including the near-misses that must stay prose: `-[ ] x`, `- []`, `- [ ]x`, a `[ ]` mid-sentence. Routes: add/update/delete items stop touching rows and rewrite note.body, all through one _rewrite_body that runs the same sequence the PATCH route runs for a body change — because it IS a body change. Revisions, #tag reconciliation, the name, and link unfurls therefore happen in one place rather than three routes each remembering to. The reorder route is gone (rule 22). Reordering a checklist is moving a line, and no client ever called it — the only reference in the tree was a test asserting the route existed. The API still returns `items`, DERIVED from the body on the way out. That is not a second source of truth and it cannot disagree with the body it came from; it keeps the web client working across the rest of this milestone and saves any consumer that only wants to draw checkboxes from carrying a parser. Export drops its separate items block, in both formats. The body already ends with those exact lines, so writing them again would double every checklist in an export and then double it again on re-import. Import still ACCEPTS items, because a Keep takeout has a list and not a blob; it folds them in before the Note is built, so display_title and _reconcile_tags both see the finished text. Protocol 3 on both sides now. A v2 client is refused rather than half-served — which matters more than I first said: _apply_note_items returned early on an absent `items` key, so an un-bumped v3 client against a v2 server would not have LOST the rows, it would have kept them and then had the migration fold them a second time. Duplicated lists rather than missing ones. The floor prevents both. Migration 0027 folds every existing row into its note's body and drops the table. It inlines its own copy of the fold on purpose — a migration has to keep producing what it produced the day it ran — and a test pins that copy against the app's until they are allowed to diverge. updated_at is deliberately untouched: a client holding an unpushed edit keeps the newer timestamp, so last-write-wins keeps its work instead of the migration silently winning. The downgrade is honest rather than faithful. It recreates an empty note_items and leaves the bodies alone, because once items are lines nothing distinguishes one this migration wrote from one somebody typed, and a downgrade that guessed would eat hand-written lists. Recreating the table is still necessary: 0015's downgrade drops a trigger ON note_items, and IF EXISTS covers the trigger, not the table. |
||
|
|
7033995975 |
search is a facet on the board, not a place you go
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 11s
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Successful in 37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m13s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Operator (note 2930): tags exist so you can *"filter during a search"*. The server has always been able to do that — `GET /api/notes` composes `?q=` with `?label=` and the rest into one AND-ed query. The frontend never reached it. The header search box navigated to `/search`, and that view called a DIFFERENT endpoint — `GET /api/notes/search?q=`, full text only, no facets at all. So the one screen you landed on when you searched was the one screen where you could not narrow by tag. Tag filtering lived on the board's FilterBar, which is where you weren't searching. Two search boxes, two endpoints, and only the hidden one did what tags are for. Now the header box writes `?q=` into the board's URL beside whatever labels are already there, and stays on the lens you're in — searching while looking at Trash searches Trash. The box READS from the URL rather than holding its own copy, so it stays in step with the Filters panel's Clear and with a saved view opened from the sidebar. Deleted: `SearchView.vue`, its route, `GET /api/notes/search`, `repo.notes.search` and both adapter implementations, and the `notes_search` Tauri command whose only caller was the adapter entry. FilterBar loses its own "Search text…" input — it was the same facet, hidden behind a collapsed panel, duplicating a box that is always on screen. Filters now does what its name says: narrowing. The header does searching. `core::store::search` STAYS. Android calls it through the FFI (`search_notes`) and has its own search surface — which has the same no-tag-filter gap the web just lost, and deserves the same fix on its own terms rather than as a rider here. |
||
|
|
de72d27bd4 |
URLs unfurl on their own, and a lone link becomes the note
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / integration (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m16s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Operator: *"I'd like for URLs to unfurl. To be the whole note when the note is a single URL, and to be a compact slot on the bottom of the note when the URL is inline. We also need to support multiple URLs in a single note."* Less new machinery than it sounds: `unfurl.py` already fetched and parsed OG tags, SSRF-hardened, and `note_link_previews` was already `UNIQUE(note_id, url)` — so several URLs per note has worked at the storage layer all along. What was missing was that it needed a button, had one size, and drew that size in the wrong place. **Automatic, and never in the way.** New `unfurl_queue.py` detects a body's URLs and fetches them on a background task AFTER the note is committed. Capture speed is the product: an unfurl is a five-second timeout against a host nobody controls, and a note has to persist the instant someone stops typing. Scheduled from create, from a body edit, and from a synced push — so a linked desktop or Android client gets previews too, on its next pull. An unlinked one has no server to ask and simply has none, which is the honest consequence of being offline. Safe to call on every save: it re-reads what's cached and does nothing when nothing is new. Capped at five URLs per note, silent on every failure (a link that won't fetch isn't an error the person needs — the note is fine, the link is still there), and it re-checks before storing, so a slow fetch can't resurrect a preview for a URL that was deleted while it was in flight. **Two presentations.** A note whose body is nothing but a URL renders as its preview and nothing else — printing the raw URL under a card that already says where it goes is saying the same thing twice, badly. Until the fetch lands, or if it never does, the URL stands in, so the card is never blank. Anything else gets a compact strip. **And the strip moved.** Previews were rendered ABOVE the body, which put a stranger's headline where the note's own first line should be — worse now that the first line IS the note's name. They sit at the foot of the card now, under the note's own words. The editor's "Preview example.com" button is gone with the manual path; removing an unwanted preview stays, and stays editor-only. Nine tests: three on detection (order, dedupe, sentence-punctuation trimming, non-http rejection) in the unit lane, and three in the integration lane for what only a real database shows — the upsert landing on the right row, a second pass fetching nothing, and a preview NOT being stored for a URL that left the body. |
||
|
|
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. |
||
|
|
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
|
||
|
|
982d24c83b |
links: bind a [[link]] to a note, not to a string
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 34s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m18s
Desktop (Tauri) / Update manifest (push) Successful in 5s
A wiki-link was stored only as normalized TEXT, so a note's NAME was the edge. Renaming it broke every inbound link — and the fix that shipped for that (task 1848, option b) was `_rename_inbound_links`: rewrite the `[[Old Name]]` text inside the body of every note that linked to the renamed one. That works while an explicit title exists to hold still. It stops being defensible the moment a note's name is just its first body line, which is where M13 is going: fixing a typo in your opening sentence would silently edit other notes' words, with nothing to opt out to. So this lands first, before the title comes out, and that window never ships. `note_links` gains `target_id`, bound when the link is written. `target_norm` stays and is what an UNRESOLVED link carries — linking to a note that doesn't exist yet is a supported way to create one, so a link has to be able to name a target that isn't there. Resolution reads the id, falling back to the name only where nothing was bound, which is what lets a forward link connect the moment its target appears. `_claim_unresolved_links` then binds it, so the fallback is a transitional state rather than a permanent one. `_rename_inbound_links` and `rewrite_link_title` are gone. What replaced them touches link rows only: a note's text is never modified by something happening to a different note. The client can no longer resolve links for itself, and that is the point. It used to look `[[text]]` up in a client-side name index, which only held together BECAUSE renaming rewrote the text everywhere. Now the written text can name something the target is no longer called, and only the server holds the binding — so each note serializes its resolved links (`norm`, `id`, and the target's name as it stands NOW). A renamed note reads correctly everywhere it is linked from, without a single body having been edited. Unresolved links are simply absent and fall through to the create-on-click affordance that already existed; so does the offline desktop store, which derives links at query time and has no binding to send. The name-fallback join is owner-scoped everywhere it appears. Bound ids were resolved owner-scoped when written, but matching on display_title alone would have let two users who each have a note called "Groceries" see the other's id and name through an unresolved link (rule 47). The new behaviour is all SQL and this suite runs without a database, so the dead helpers' tests are removed rather than replaced. This repo has no integration lane to hold that ground — noted, not papered over. |
||
|
|
36b8f65dc6 |
M9 S1d: split the notes.py monolith into a cohesive package
The 1574-line notes.py becomes a `notes/` package. The heavy shared logic moves
into focused modules; the route handlers + blueprint registration stay together
in __init__ so registration is trivially correct (most routes have no CI
auth-test that would otherwise catch a route silently dropping out):
- notes/_bp.py — the Blueprint (isolated so route modules could import it
without a cycle; also the seam for a later route split).
- notes/serialize.py — note (+labels/items/attachments/previews) serialization.
- notes/links.py — [[wiki-link]] + #tag parsing and reconciliation.
- notes/recurrence.py — recurring-reminder next-occurrence math.
- notes/helpers.py — display-title/empty/filter/owner-fetch + filename/slug utils.
- notes/import_export.py — export markdown + Keep/native import specs + zip budget.
- notes/__init__.py — the `/api/notes` routes + re-exports the external surface
(app.py imports `bp`; sync.py + tests import helpers).
Pure reorganization — no behavior change (routes/helpers moved verbatim). Callers
(app.py, sync.py, test_notes.py) are unchanged: `from thoughtsync.notes import X`
resolves via the package __init__ (rule 22 — the package replaces the module).
No import cycle (nothing in the package's dep chain imports notes; only app.py +
sync.py consume it). New test_all_note_routes_registered asserts all 29 route
endpoints are attached, so CI catches any module that fails to register. Runtime
DB behavior operator-verified on deploy (no Postgres CI lane).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
|
||
|
|
2abed7132c |
S1: shared value helpers (parse_dt/coerce_bool) + auto-Secure session cookie
M9 hardening/DRY pass — section S1, commit 1 (the shared-toolkit foundation): - Add src/thoughtsync/common.py with parse_dt() and coerce_bool(): one home for the ISO-date and truthy-flag coercions that were duplicated across modules. notes.py adopts them and deletes _parse_iso_dt, _iso_to_dt and _truthy (rule 22 — old copies removed; callers, incl. tests, updated). - Security: the session cookie is now marked Secure automatically on any request that arrived over HTTPS (directly or via a proxy's X-Forwarded-Proto), via a SecureCookieSessionInterface override. Hardens HTTPS deployments without breaking plain-HTTP LAN installs — no config. Behavior-preserving refactor + one security hardening. The backend serialization layer, the json_error sweep, and the notes.py split follow as their own commits. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
882d4206aa |
M6 1908a: recurring reminders + complete/snooze (backend, no web-push)
Per operator: skip Web Push; build the rest of reminder delivery. This is
the client-agnostic half — the model + logic that foreground/native
delivery drives.
- notes.recurrence (migration 0022): daily/weekly/monthly/yearly or null.
update_note accepts it (cleared when the reminder is cleared); rides
export/import + sync push. Serialized on the note.
- Pure next_occurrence(remind_at, recurrence, after): the next fire strictly
after `after`, rolling past missed occurrences; _add_months clamps the day
to the target month (Jan 31 → Feb 28).
- POST /api/notes/<id>/reminder/complete — a recurring reminder advances to
its next occurrence; a one-off clears. POST .../reminder/snooze {minutes}
→ remind_at = now + minutes (1 min .. 30 days).
No VAPID / push-subscription / service-worker — foreground + native delivery
land in the UI commit and the native clients.
Tests (DB-free): normalize_recurrence; next_occurrence (daily/weekly/
monthly-clamp/skip-missed/yearly/none); complete + snooze auth-guards.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
|
||
|
|
5fdc124c77 |
M6 1902a: richer facet query + saved-filters storage (backend)
GET /api/notes gains combinable, AND-ed facets alongside the existing filter/date/sort: multiple ?label= (notes with ALL), ?color, ?kind, ?has_reminder, ?has_attachment, and ?q (full-text over title+body, ranked) — so the facet bar's text box searches, not just filters. All optional; invalid color/kind → 400. saved_filters table (migration 0021) + /api/saved-filters CRUD (list / create / rename+repoint / delete, owner-scoped). `params` is a JSON facet dict mirroring the query surface; clean_params() whitelists facet keys so a saved view can't accumulate junk. Tests (DB-free): _truthy, clean_params key-whitelisting, saved-filters auth-guards. UI (facet bar + saved-views sidebar) lands next. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
b5f545f655 |
M6 1900: any-file attachments + audio memos (broaden beyond images)
A note can now carry any file, not just images — PDFs, documents, audio memos, etc. "Dump anything" capture. Backend: - note_attachments.filename (migration 0019) records the original name for download + display. - Upload drops the image-only mime gate: accepts any type, derives the storage extension from the filename, and enforces a DB-backed per-file cap — new setting max_attachment_mb (default 25, rule 25). App body ceiling raised 12→64 MB (also lifts the import-zip / sync-push limits); the per-file cap is the effective attachment limit. - Serve sets Content-Disposition: images inline, everything else downloads with its original (header-sanitized) filename. - Import (native + Keep Takeout) now brings in ANY attachment, not just images — completing the Keep audio-memo gap; preserves filename + sha256. - Attachment metadata (delta feed + REST) carries filename. Frontend: - Editor renders attachments by kind: images inline (thumbnail), audio via an inline <audio> player, any other file as a download chip (paperclip + filename + size). File picker accepts any type; "Attach a file". - Card previews the first image; non-image files show as compact chips. Tests (DB-free): _safe_filename, _attachment_ext, _header_filename. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
333ab9ce74 |
Import: ThoughtSync-native round-trip + Google Keep Takeout
Complete the export/import pair (task 1907). POST /api/notes/import takes
an uploaded .zip and appends its notes — never overwriting existing ones.
Two formats, auto-detected:
- ThoughtSync export: recognized by its notes.json (app == thoughtsync);
round-trips title/body/color/kind/pinned/archived/remind_at/timestamps/
labels/items and re-attaches image media from the zip.
- Google Keep Takeout: each Keep <note>.json → a note. Maps title,
textContent/listContent (+ checked), labels, Keep color enum (nearest
palette match), isPinned/isArchived, isTrashed (→ trash), created/edited
microsecond timestamps; folds annotation URLs into the body; resolves
attachment filePaths relative to the note's folder.
Imported notes reuse create_note's derivation + reconciliation:
display-title derive, #tag reconcile, [[wiki-link]] rewrite. Explicit
labels attach as manual (via_tag=false); inline #tags reconcile as tags.
Image attachments copied into media storage; non-image types (e.g. Keep
audio) skipped until any-file attachments land.
Frontend: an Import control in the sidebar (next to Export) — hidden file
input + FormData POST + result toast ("Imported N notes (M skipped)"),
reloading the board + labels. New upload icon; notes-store importNotes().
Tests: import auth-guard + pure-helper coverage (_usec_to_dt, _keep_spec
list/text/color/annotation/attachment mapping, _native_spec round-trip).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
|
||
|
|
cc828559ff |
M6: export — download all your notes as a zip
Data portability / no lock-in (task 1907, export half). GET /api/notes/export streams a zip of the caller's notes: a machine-readable notes.json (notes + labels + items + reminders + attachment refs), a human-readable Markdown file per note (frontmatter + body / checklist), and the attachment media. Sidebar 'Export' link (same-origin GET, session cookie) downloads it. _slugify unit-tested. Import (Google Keep Takeout) is the follow-up increment of this task. Pure backend + small frontend; no migration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
efbf981a2a |
M6: version history — note revisions + restore
A note's title+body is snapshotted on each edit that changes either, so an accidental overwrite can be viewed and restored (task 1906). Underwrites 'dump freely, nothing is lost'. Backend: note_revisions table (migration 0014) + NoteRevision model; update_note records a revision of the PRE-edit state whenever title/body changes; GET /api/notes/<id>/revisions (newest 50) and POST /api/notes/<id>/revisions/<rev_id>/restore (snapshots the current state first so restore is itself undoable, then applies the revision with the usual title/body ripple — display name, links, #tags, backlinks). Title+body only in v1. Frontend: a History toggle in the modal editor opens a panel of past versions (timestamp + preview) with per-row Restore. Store gains fetchRevisions/restoreRevision. Migration 0014 runs on deploy; DB behavior operator-verified (no Postgres CI lane). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
95b0e30fc7 |
M6: browse notes by creation date (Timeline lens)
A temporal recall path — find a note by WHEN it was captured, not just what it contains (task 1903, first of the M6 recall items). Backend: list_notes gains an optional created_at range (created_after / created_before, half-open interval) + sort=created; also lays groundwork for the richer-search facets (task 1902). New _parse_iso_dt helper with a DB-free unit test. Frontend: a Timeline view (sidebar nav + 'g t' + command palette) grouping active notes newest-first into local-time buckets (Today / Yesterday / Earlier this week / this month / Month YYYY), plus an optional From/To date filter. Built as a lens on the same NoteCard masonry, consistent with the existing Reminders/Search views. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
c4914fe587 |
m4.5: inline #tags — hashtags in a note become labels (two-way sync)
Fast, cross-device labelling: type #groceries in a note and it becomes the "groceries" label. The body is the source of truth for tag-labels; manual picker labels stay independent (rule 28 — additive). - note_labels.via_tag (migration 0013) marks tag-sourced attachments. - parse_tags(): #tag at start-of-body or after whitespace, needs a letter (so #2024, URL #frags, mid#word are ignored). unit-tested. - _reconcile_tags() on create + body-update: attach labels for current #tags (find-or-create, case-insensitive), detach tag-labels whose tag was removed; never touches manual (via_tag=false) rows. - label picker (set_note_labels + editor onLabelsChange) now preserves tag-labels on save, so a picker action can't strip a label the #tag still mandates. - serialize via_tag; card/editor chips render tag-labels as "#name", and the editor hides the × on them (remove by editing the tag text). - LabelPicker builds manual NoteLabels (via_tag:false). Fourth and final item of M4.5. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
80324fba3b |
m4.5: lists first-class — create a checklist straight from quick-add
Checklist notes existed (M2) but could only be made by creating a text note and toggling it in the editor — so they were undiscoverable. Now the board's quick-add can make one in one shot. - create endpoint accepts kind + items: POST /api/notes with kind:"list" and items:[...] creates a checklist note and its items atomically. A list note is non-empty when it has a title or ≥1 item. - quick-add gets a checklist toggle (checkbox icon): flip it and each body line becomes an item on save; placeholder switches to "One item per line"; resets to a plain note after close. - notes store create() accepts kind + items; parse_list_items() helper (trims, drops blanks) with a unit test. - title placeholders now read "Title (optional)" in quick-add too. Third item of M4.5. Display/editing of checklists was already built in M2; this closes the creation gap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
0ae02858f7 |
m4.5: content-aware [[ linking — autocomplete searches note body
The [[ autocomplete only matched note names, so you could only link a note you could name. Now it searches note NAME *and* body, so you can link by recalling any phrase. - new GET /api/notes/link-search?q= — owner-scoped, non-trashed; substring ILIKE on display_title OR body; ranked name-first, then name-prefix, then recency; empty q returns recent notes as suggestions. Deterministic (no semantic/AI search); the FTS index still powers the heavier /search. LIKE wildcards in q are escaped. - editor [[ autocomplete now calls link-search (debounced 120ms) instead of filtering the cached titles index; excludes the note itself; inserts the matched note's display name as [[Name]]. - unit tests for the LIKE-escaping + a link-search auth guard. Second item of M4.5; builds on the display_title work (every note has a name to link to). Command-palette content search is a natural follow-on, left out to keep this focused. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
2b6a353666 |
m4.5: titles optional — every note gets an auto display name
Capture starts in the body, so forcing a title feels odd and body-only notes had no name — which made them unlinkable. Fix both: persist a display_title = explicit title if set, else the note's first non-empty body line (deterministic, no AI). The title field stays optional. - migration 0012: notes.display_title (NOT NULL, best-effort backfill; the app recomputes precisely on next save) - derive_display_title() helper, set on create + update - drive the /titles index, backlinks, graph edges + node labels, and [[wiki-link]] resolution off display_title so body-only notes are nameable, findable (command palette / [[ autocomplete), and linkable - rename-repoint generalized: inbound [[Old Name]] links now survive a name change via the first body line too, not just an explicit title - unit tests for the derivation (explicit wins, first non-empty line, blank/empty, length cap) - frontend: display_title on the Note type; title field placeholder now reads "Title (optional)" First item of M4.5 (frictionless input & recall); unblocks the linking work. Card rendering unchanged (no first-line duplication). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
f3145a5e3f |
links: rename repoints inbound backlinks (fix orphaning)
Renaming a note now rewrites [[Old Title]] references (and their note_links rows) in every note that links to it, so backlinks survive the rename instead of silently orphaning. Pure/case-only renames are skipped since they still resolve. New pure helper rewrite_link_title() (DB-free unit tests) does the token rewrite; _rename_inbound_links() applies it across owner-scoped, non-trashed sources. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
c57982d910 |
M3 reminders: notes.remind_at + Reminders view
- Migration 0010: notes.remind_at (nullable tz). PATCH accepts remind_at (ISO set / null clear); GET /api/notes/reminders (soonest first, non-trashed); serialize includes remind_at. - Frontend: datetime util (local<->ISO, format, overdue); notes store setReminder; editor datetime-local picker + clear; card reminder chip (overdue = red); sidebar Reminders entry + /reminders view. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
ad006ccb58 |
M3 graph view: /api/graph + force-directed SVG
- graph blueprint: GET /api/graph resolves note_links to target notes by
normalized title (owner-scoped, non-trashed, self-excluded) → {nodes, edges}
of connected notes.
- GraphView: hand-rolled force simulation (repulsion + edge springs + centering,
cooling over ~400 frames), SVG nodes/edges, click a node to open it (reuses the
editor with link navigation). Sidebar Graph entry + route; empty state.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
|
||
|
|
2d72dcc7cb |
M3 wiki-links: [[links]] + backlinks
- Migration 0009: note_links (source_id, target_norm). Parse [[...]] from body on
create/update and rewrite the source's links. GET /api/notes/titles (owner
{id,title} index for client-side resolution); GET /api/notes/<id>/backlinks.
- Frontend: titles store; LinkedText renders [[Title]] styled on cards; editor
shows Links (outgoing, resolve/create-on-click) + Linked-from (backlinks),
clicking navigates the editor to the target note (board + search).
- notes store: fetchOne, createTitled. DB-free link-parser tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
|
||
|
|
339cc5c2d2 |
M2 drag-reorder: notes.position + reorder API + native DnD
- Migration 0008: notes.position (int). Board orders pinned -> position -> updated_at; new notes created at top (max position + 1). POST /api/notes/reorder assigns positions from the given order (owner-scoped). - notes store: position on Note, position-aware sort, optimistic reorder(). - NoteCard reorderable (native HTML5 draggable + dragstart/drop); BoardView moves the dragged note before the drop target and persists. Note: drag on a CSS-columns masonry has imperfect during-drag visuals (columns reflow); order persists correctly. Candidate for a polish pass / layout tweak. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
e4c898cd1b |
M2 attachments: image upload + owner-scoped media serving
- Migration 0007: note_attachments (path/mime/size). Upload POST /api/notes/<id>/attachments (multipart, png/jpeg/gif/webp, 12MB cap via MAX_CONTENT_LENGTH) stored under Config.media_root() (first use of DATA_DIR); owner/ACL-scoped GET serves the file (nosniff); DELETE removes row + file. Note responses include attachments[]. - Frontend: notes store uploadAttachment (FormData)/deleteAttachment; editor image button + paste-to-upload + thumbnail grid with remove; card shows the first image as a cover. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
31be66ac60 |
M2 checklists: note kind + items backend + editor/card UI
- Migration 0006: notes.kind ('text'|'list') + note_items (text, checked,
position). Item API: add/update(toggle)/delete/reorder; PATCH note kind; note
responses include kind + items[] (merged in one query alongside labels).
- notes store: kind/items on Note, setKind/addItem/updateItem/deleteItem.
- NoteChecklist component (toggle/add/edit/delete items); rendered read-only-ish
on cards (checkboxes toggle) and editable in the editor.
- Editor: convert text<->checklist (body lines become items on convert to list).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
|
||
|
|
ffc008bf4d |
M2 search: Postgres FTS backend + top search bar + results
- Migration 0005: generated tsvector column (title A + body B) + GIN index on notes; GET /api/notes/search?q= (websearch_to_tsquery, ts_rank, ACL-scoped, excludes trash), labels merged into results. - Persistent AppShell layout (parent route + <RouterView> children) so the new top search box keeps focus across board/search/label navigation. - SearchView (debounced live search from the shell → /search?q=, results masonry, no-match empty state); BoardView/SearchView render inside the shared shell. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
df59a30cca |
M1: notes model + migration 0002 + notes API (CRUD, trash/restore)
- Note model (owner_id, title, body, color-key, pinned, archived, deleted_at soft-delete, timestamps) + board index; NOTE_COLORS palette keys. - Migration 0002 (notes table + ix_notes_owner_board). - /api/notes blueprint (login_required): list (?filter=active|archived|trash, pinned-then-updated, read via visible_to_user ACL), create, get, patch (title/body/color/pinned/archived), trash, restore, permanent delete (trash-only). Mutations owner-scoped; empty note rejected (400). - DB-free unit tests: is_empty_note, normalize_color, palette, serialize, auth-guard on list/create. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |