f5478a0ce046a9a82db0ca5e60b24dfa89963950
103
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1c4bf56058 |
A lone link's preview is looked up where the server filed it
The server unfurls what detect_urls finds, trailing .,;:!? trimmed, and files the preview under that. The web and Android cards looked a lone link's preview up under body.trim(), punctuation included, so a note reading "https://example.com/a." never showed its card. - grammar.json gains a urls section: what the server finds in a body, and the link a lone-link note is filed under. - The web's rule moves out of NoteCard into notes/links.ts loneUrl(); Android's LinkPreviewRow gets the same loneUrl(); both trim like the server. - The server and web suites run the cases; Android's JVM test pins them by hand, as it does the tint. Fixes #5399. DRY pass #2, batch 3 (#5372). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
3766d59b2e |
The server's palette is checked against the shared fixture's hues
The web already checked its tint keys against grammar.json; the server's NOTE_COLORS, which normalize_color accepts, had no such guard. It is now the fixture's hues plus "default". The core has no palette to check: it stores whatever the UI (which only offers palette keys) or the server sends. DRY pass #2, batch 3 (#5372). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
4285026cfe |
The repeat rules are pinned by the shared fixture
daily/weekly/monthly/yearly was written out in the server (REMINDER_RECURRENCES), the core (recur::RECURRENCES), the web editor's <option>s and Android's picker, with nothing holding them together. grammar.json now has a recurrences list; the server, core and web suites each check theirs against it, and the web's options come from notes/recurrence.ts rather than the template. Android's picker pins the list by hand with its localised labels, as it does the tint: its JVM tests do not read the fixture. DRY pass #2, batch 3 (#5372). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
db9e9a2dfb |
A note's name is one rule, pinned by the shared fixture
The server and the core each derived display_title and disagreed twice: the server cut it at 200 characters and the core didn't, and the server split lines with splitlines(), which also breaks on a lone \r or a U+2028, where the core and every other reading of the grammar split on \n alone. - grammar.json gains a display_titles section: blank lines, markers, an empty item, \r\n, a lone \r, U+2028, and a 201-character line of 'é' (the cut is characters, not bytes). - derive::display_title and DISPLAY_TITLE_CAP are the core's half, moved next to strip_marker. The server splits on "\n". Both suites run the cases. Behaviour: a device now names a note with a first line over 200 characters the way the web always has, and the server names a note containing a lone \r or a U+2028 the way devices always have. Fixes #5398. DRY pass #2, batch 3 (#5372). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
7259708f28 |
Session length, trash retention and attachment size have bounds
Each accepted any integer. A session length of 0 expired every session at once, the admin's own included; an attachment limit above the 64 MB body ceiling allowed files no request could carry, and a negative one refused every upload. - session_ttl_days 1..3650, trash_retention_days 0..3650 (0 = keep), and max_attachment_mb 1..MAX_BODY_MB-1, leaving room for the multipart envelope. - MAX_BODY_MB is the one number app.py's MAX_CONTENT_LENGTH and that maximum both read. - A value stored before its bounds existed reads as the nearest bound. Fixes #5384. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
bfab8746ad |
Opening a purged note, or one of its files, is a 404
get_note and get_attachment selected with the ACL inline and skipped the purged filter, so a tombstone came back 200 (#2128 says a purged note reads as absent). Both now go through _get_visible, which cannot skip it. Reorder's batch lookup gains the same filter, so a stale id cannot write a place onto a tombstone. Fixes #5383. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
8eff5f60a6 |
A deleted tag is a tombstone on every path, and the web skips tombstones
The web deleted a tag's row outright (delete, and merge's source), so the change feed never carried it and linked devices kept the tag. A device's delete left a tombstone that the web still listed, matched by name on create and rename, minted #tags onto, and accepted in a picker. - labeling.tombstone_label is the one way a tag is deleted: drop its links, set purged_at. REST delete, merge and sync's op=delete all use it. - labeling.live(owner) is the one definition of a tag that exists; every catalog read uses it (list, lookup, create/rename matching, #tag minting, picker ids, export, and sync's name-clash check). - 0040: (owner_id, name) is unique among live tags only, so a tombstone gives its name back and #grocery can be made again, on the web or from a device. Fixes #5382. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
39b1ebae96 |
Settings → Activity: an audit log of what happened to accounts
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 1m27s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m47s
CI & Build / Build & push image (push) Successful in 45s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m4s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Sign-ins and failed sign-ins, accounts created and sign-ups refused, password changes, resets and reset links, devices linked and unlinked, invites made and revoked. Each is kept in `audit_events` with the address it came from, for `audit_retention_days` (Settings → Security, 90 by default, 0 keeps them forever), and listed newest first for admins under Settings → Activity. The retention loop deletes older events. `audit.record` writes in its own session, so a refusal is kept even when the request's transaction rolls back. A failure to record is logged and swallowed, never the reason a sign-in fails. A throttled attempt (429) is not recorded: a row per refused request would make each request in a flood cost a database write. Throttle trips stay in the app log. Also: the storage-limit test puts `storage_quota_gb` back afterwards, since settings outlive the per-test truncate. #2939 §5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
043c87a8dc |
Each account may store 5 GB of attachments; admins have no limit
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
CI & Build / Python lint (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 1m33s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m12s
CI & Build / Build & push image (push) Successful in 55s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m43s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m32s
Desktop (Tauri) / Update manifest (push) Successful in 5s
#2939 §4. max_attachment_mb capped one file, so any account could fill the
volume. The new Settings → Attachments → storage_quota_gb (default 5, 0 for
no limit) caps an account's total. That total is every attachment on every
note the account owns, trash included, since trashed files stay on disk until
emptied. Admins are exempt.
storage.upload_refusal is now the one check every upload makes: per file, then
per account. It is used by:
- the web upload route;
- the sync PUT, which judges the declared Content-Length before reading the
bytes;
- the importer, which learns the room left up front and refuses the whole
archive if its attachments don't fit (nothing is committed).
Over the limit is answered 507 Insufficient Storage, not 413. The core treats
a 4xx as a permanent refusal it never retries, and a 5xx as worth another try.
So a file refused for want of room syncs by itself once space is freed. The
cost is that an over-limit device re-sends that file each cycle until then.
GET /api/auth/storage returns used and limit, and the Account page shows it as
a Storage row ("1.2 GB of 5 GB used").
docs/public-hosting.md drops the quota gap and gains a section on the limit.
Its Android paragraph still said a public http:// address was only warned
about; since
|
||
|
|
bb591871a4 |
Account page: change your password, or sign out everywhere else
Family idea #5105, practice 4. Either action signs the account out of every other browser and unlinks every device. The browser that made the change stays signed in. - POST /api/auth/password needs the current password. A wrong one returns 403, not 401, so this browser doesn't read as signed out, and it counts against the sign-in throttle. A short new password returns 400. - POST /api/auth/sign-out-elsewhere does the same sign-out without a password change. Called from a device, it keeps that device linked. - _sign_out_elsewhere moves session_epoch on and deletes device tokens. The reset route now uses it too, keeping no device. - The page is renamed from "Linked devices" to "Account", in the router title and both nav entries. Its sections are Linked devices, Password (one short line, then the form) and Sessions (a single "Sign out everywhere else" row in the device rows' style), per preference 188: one line each, no paragraphs. - docs/public-hosting.md says how sessions end, and why a browser session isn't listed the way a device is: it is a signed cookie, ended by moving the epoch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
5d08d8a7a6 |
The first account can only be made in a 30-minute setup window
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 38s
Family idea #5105, practice 8 (Scribe #5113), the operator's choice of setup window over a setup code. Before this, whoever reached /register first on an empty server became its admin. On a fresh server at a public address, that could be a stranger, and a new DNS name is found within minutes. Now the first registration is refused once 30 minutes have passed since the server started (create_app records STARTED_AT). A restart opens the window again. It is a constant rather than a Setting, because there is no admin yet to change one. Once an account exists it no longer matters, so existing servers are unaffected. public-hosting.md says so, and two integration tests cover both sides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
65f004029b |
Signed-in app downloads are cached privately, not publicly
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 49s
Quart's send_file marks every file it sends Cache-Control: public. The app download is behind a login, so a shared cache or proxy could have kept one account's copy and handed it to anyone. send_artifact now marks it private, as the attachment route already does. Family idea #5105, practice 11 (Scribe #5113). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
97b04f9f92 |
Close the gaps family idea #5103 found in how Inkwell distributes its app
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 5s
Android / Build, or is the channel already serving this? (push) Successful in 6s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 7s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 18s
CI & Build / integration (push) Successful in 1m13s
CI & Build / Build & push image (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 12s
Android / Core and FFI clippy and tests (push) Successful in 45s
Android / Kotlin + Rust (APK) (push) Successful in 9m50s
Android / Build the server image (push) Successful in 2s
Three of the idea's practices this project still owed (Scribe #5118): Practice 3, CI fails on the wrong signer. The signing step printed the certificate and went on. It now fails unless the APK has exactly one signer and that signer is the release certificate (SHA-256 408a5835…, pinned from run 8753). The steps that publish come after it in the same job, so a wrongly signed build is never staged or published. Practice 6, app downloads are throttled and carry a sha256 ETag. - The download route counts per account and answers 429 with Retry-After past the limit. The limit is a new Settings → Security value, "App downloads per account per hour" (default 30), live like the sign-in limits. - The ETag is the sidecar's sha256, not Quart's mtime-and-path, so a phone resuming a download across a redeploy is not told its partial copy is stale. Quart's own ETag and conditional handling are off, and the route runs the conditional pass after setting the ETag, so Range and If-Range are judged against the content. Practice 9, the update offer and debug builds. - The install-permission notice re-reads the grant each time the app comes back, as ReminderNotice does. Read once, it stayed up after someone granted the permission in Settings and came back. - A debuggable build says it can't update itself and checks for nothing. Android would refuse the release-signed APK over a debug signature anyway. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b03c9cf81a |
desktop: in-app updates follow the server you installed from
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 1m17s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 1m45s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Build & push image (push) Successful in 40s
Milestone 325 step 6 (Scribe #3254). The server publishes its AppImage in the updater's own format at /api/client/linux-appimage/update.json: the ordering key as `version`, the signature, and an absolute download URL built on the host that was asked, so the token the updater attaches goes nowhere else. Unsigned platforms and a server with no AppImage 404. The desktop's update source is now Fabled-Git (and its channel) or one server: - `read_source` is the one reader. The installer's `install-server` marker feeds the `update_server` pref once per new value, exactly as the channel marker feeds its pref; tauri.conf.json's endpoint is never consulted. - From a server, the check and the download carry the sync link's token when the app is linked to that same server. Without one the update shows and says to link rather than offering a button that 401s. - A server with no build says so. A 404 is "up to date" only on the forge, where it means an unpublished channel. - Sync → App updates offers the source once there is a server to offer (the chosen one, or the linked one), and only shows the channel for the forge. The trust anchor does not move: whatever the source, the updater verifies the AppImage against the public key built into the app. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
871878de41 |
install.sh installs from your own server, not just from the forge
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 1m15s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m5s
CI & Build / Build & push image (push) Successful in 55s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m15s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 2m57s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Milestone 325 step 5 (Scribe #3253). curl -fsSL https://notes.example.com/install.sh | sh The server serves the installer at /install.sh with its own address written into it (installer.py). Settings → Public address when set, the request's own address otherwise. The substitution is one variable, given a value that has passed a strict shape check, and the script checks it again; an address that cannot pass makes the route refuse rather than serve a script pointed elsewhere. `public_url` joins the live settings cache so the route needs no database. From a server, the script: - resolves each Linux bundle from the public /api/client/<platform>, and builds the download URL from the platform id rather than reading it from the reply; - asks for a device token (from the terminal, since stdin is the script), or takes TS_TOKEN, and sends it from a file rather than the command line; - checks the sha256 the server published before anything installs; - revokes a prompted token once the download is done; - records `install-server` for the updater (step 6) instead of the channel. The forge path is unchanged, and stays the default for the copy the forge serves. The Account page's downloads card shows the one-line command whenever the server holds a Linux client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b019172d47 |
all: remove saved views, the Has-reminder filter and the Created range
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 13s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 1m29s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 3m34s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m5s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m49s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 9m29s
Step 18 of the audit follow-through (#5180), on the operator's decisions. Inkwell is for capture and recall (note 2897), and these three duplicated a surface that does the job already: - Saved views. They lived only on the web; the desktop kept its own set that never synced, and Android had none. Tags in the drawer already give one-click recall. Gone from the server (routes, model, migration 0038 drops the table), the core (store functions, schema v13 drops its table), the desktop commands, the web adapters, the drawer's Views list and the "Save view" link. - The "Has reminder" facet. The Reminders page lists them, sorted by due. - The FilterBar's "Created" range. Timeline is the date lens and keeps the created_after/created_before query it builds from local days, which also retires the UTC/local-day disagreement between the two (B3). An old link that still carries the removed keys opens the plain board; a web test pins that. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
646a115701 |
server: one credential check, one coerce_bool, one top-position query, and the shared response helpers
CI & Build / Python lint (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 1m11s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Build & push image (push) Successful in 49s
From the audit (#5179, server half). - auth: login and device-login share _check_credentials (dummy hash for a missing account, throttle bookkeeping, the failure log line) and _bad_credentials. - settings uses common.coerce_bool. Its private copy differed only in treating a non-string as its truthiness, which the shared one now does. - notes: create and import share helpers.top_position. - auth, settings_api, sync and client_dist return errors through responses.json_error / not_found, and parse ids with parse_uuid. - sync: push replies are built by _result(id, entity, status, **extra). Already merged by earlier steps, so nothing to do here: attachment storage (store_attachment), the preview upsert (only unfurl_queue writes one now), and _serialize_note (delegates to _serialize_notes). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b8f13cfc4a |
tests: the share ACL test's final body no longer expects the removed add-item route's eggs
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 1m5s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 47s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
e2e0740b06 |
tests: stop posting to the removed add-item route in the share ACL test
CI & Build / Python lint (push) Successful in 4s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 13s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / integration (push) Failing after 1m7s
CI & Build / Build & push image (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
7eacd0569c |
all: delete the code nothing calls
CI & Build / Python lint (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / integration (push) Failing after 1m21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m47s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m48s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m39s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 8m37s
From the audit (#5178). Each was unreachable from every client: - Checklist add-item and delete-item: REST POST /items and DELETE /items/<id>, the Tauri commands, the store, rest and local adapters, the core's add_item/delete_item, set_item_text and remove_item, and the FFI exports. Adding, rewording and removing an item are body edits in every editor. The checked toggle stays, and its rewriter is simpler without the drop branch. - Manual unfurl: POST /unfurl and its adapters. Previews arrive in the background after a save (unfurl_queue). - The /api/config `android_client` key, android_release() and the APK_NAME/MANIFEST_NAME aliases. Phones poll /api/client/android. - users.email_verified and users.avatar_path (migration 0037). Nothing set the first or read the second; the SMTP reset never checked verification. - derive::extract_tags (only tests used it; the shared fixture now runs through extract_tag_spans), the unused check and link icons, and the unused editor_add_item string. - The blob scheme is renamed tsblob -> inkblob. URLs are built as notes are read, so nothing stored carries the old one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
5f3cfe8bb7 |
groups: admin-managed groups, sharing a note with one, and membership in the feed
/api/groups (admin) creates, renames and deletes groups and adds or removes members. The member directory lists every group, and a share may name a group_id instead of a user_id; a note's shares answer with `member` or `group`. A note shared with a group reaches whoever is in it now, so membership is what the feed follows: joining grants each of the group's notes to the new member's devices, and leaving (or the group being deleted) revokes them unless a direct share or another group still reaches that person. recipients() never counts the note's own owner, who may sit in a group it is shared with. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
63955bbe97 |
sync: recipients keep their own pin, archive and place on shared notes
A note_user_state row per (note, recipient) holds what used to be the owner's columns as far as anyone else could tell. The board filters and orders through the viewer's own state; PATCH and reorder write it for a note shared at any level; the feed's revision for a shared note is the later of the note's and the caller's row, so a recipient's pin reaches their devices and no one else's. Push takes the three with their own `state_at` stamp (protocol 7, `shared_state`), so pinning a copy whose text is behind never makes that text win over the owner's edit. A body is only stamped as an edit when it changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
75928c7afd |
sync: shared notes in the feed, revocations, and text pushes from an edit share
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python tests (push) Successful in 15s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / integration (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 54s
The change feed answers `?shares=1` with every note the caller can see, each saying how it is held, plus a `revoked` list of notes that left them. Granting a share moves the note past the recipient's cursor; ending one, or the owner deleting the note, leaves a revocation on the same cursor. A recipient at edit may push the note's text, and nothing else. Protocol 6, feature `shares`; opt-in, so the floor stays at 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
2e2d8667dd |
password reset by email: Settings → Email, Forgot password?, and a test-email button
CI & Build / Python tests (push) Successful in 15s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / integration (push) Successful in 1m16s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 11s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build & push image (push) Successful in 1m15s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m49s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m26s
Desktop (Tauri) / Update manifest (push) Successful in 4s
The operator asked for self-service reset over SMTP. It reuses #5173's password_resets table, /reset-password page, one-hour single-use token and sign-out-everywhere. - Settings (rule 25, not env): a new Email group (SMTP server, port, encryption as a choice, username, password, from), General → Public address, and Security → Reset emails per account. The registry gains `choices`, `secret` (the value is never sent back, `is_set` says one is saved, an empty save keeps it) and `url` (http(s), trailing slash stripped). - mailer.py: stdlib smtplib on a worker thread, 20 s timeout, starttls | tls | none. mail_settings() is None until a server, a sender and the public address are set. Links are built from the public address because the Host header can be forged. - POST /api/auth/forgot-password: the same answer at the same speed for any address. The link is made and mailed off the request (send_later). It is throttled like a sign-in per visitor address, and capped per typed email by reset_emails_per_account; past the cap it answers the same and sends nothing. - POST /api/settings/test-email: mails the admin with the saved settings and shows the server's error if it fails. - Public config `password_reset_by_email`. Sign-in shows "Forgot password?" only then, linking to a new /forgot-password page. - docs/public-hosting.md: an "Email and forgotten passwords" section. Tests: the secret stays server-side; emailed link → reset; the same answer for unknown addresses; the cap; test email success and failure; validation units. #5266. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
ca242e59a6 |
tests: adding a checklist item answers 201
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 59s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m48s
CI & Build / Build & push image (push) Successful in 1m2s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m7s
Desktop (Tauri) / Update manifest (push) Successful in 4s
The edit-share test expected 200 from POST …/items, which creates. #5174. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
f53d377766 |
sharing: share a note from the web, at view or edit, with anyone on the instance
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Python tests (push) Successful in 14s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / integration (push) Failing after 54s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m6s
Desktop (Tauri) / Update manifest (push) Canceled after 0s
Desktop (Tauri) / Tauri desktop (Linux) (push) Canceled after 2m33s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Canceled after 2m25s
The ACL has gated every read since M0, but nothing could write a share. Server: - shares_api: GET /api/users/directory (everyone but you, signed-in only), GET/POST /api/notes/<id>/shares and DELETE …/shares/<share_id>, owner only. Sharing again with the same person changes the permission (ON CONFLICT on the new unique index). - acl.visible_to_user takes permission=; granted_to and shared_ids feed the serializer. - Edit covers body and checklist (_get_editable). Everything else stays _get_owned. A view share's write is a 404 like a stranger's (#1984). An editor's PATCH naming anything but body is a 403. - Serialized notes carry permission, shared and shared_by. A recipient never gets the owner's labels, and a #tag an editor types files under the owner's (it always went to note.owner_id). - ?shared=with_me, also allowed in saved views. Trash and reminders are the owner's. purge_note drops the note's shares. - Migration 0034: one share per note and person (and per group), permission limited to view and edit, an index for "shared with me". Web: - ShareDialog (one, mounted by the shell): pick a member, Can view or Can edit, change or remove existing shares, with loading, error and empty states. - Card: "Shared by X" or "Shared" chip; owner-only actions and reminder buttons hidden for recipients; checkboxes inert at view. - Editor: read-only at view; text and checklist only at edit; Share button for the owner. - FilterBar: Shared with me. Repo seam gains `shares`; the offline desktop shows none of it (#5175 brings sharing there). Tests: owner, recipient and stranger across reads, every write at view and edit, tag filing, unshare, trash, delete and validation; web unit tests for the facet and permission helpers. #5174. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
f100e5ef85 |
tests: the self-revoke routing check reads the URL map; its 400 moves to Postgres
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 51s
CI & Build / Build & push image (push) Successful in 50s
test_devices signed a fake account into a session and relied on
login_required answering without the database. Since
|
||
|
|
3dd0b44cb9 |
password reset: an admin makes a one-hour link, and using it signs the account out everywhere
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Failing after 12s
CI & Build / integration (push) Successful in 49s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m41s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m5s
Desktop (Tauri) / Update manifest (push) Successful in 5s
There is no mail path, so a forgotten password needed a hand on the database (#2939 §2). Settings → People lists the accounts; Reset password makes a link that works once within an hour, shown once for the admin to hand over. Making another link for the same account closes the earlier one. Using it (/reset-password) sets the password, deletes the account's device tokens, and moves users.session_epoch on. Sessions are signed cookies the server can't delete, so each now carries the epoch it signed in under and login_required reads the account's epoch by primary key. A cookie from before this has no epoch and reads as 0, the starting value, so the upgrade signs nobody out. A deleted account's session now stops working too. The one-time link reveal moves out of InviteList into OneTimeLink, and the link-building into router/links.ts, shared by invites and resets. Migration 0033. #5173. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
28fa8badcb |
invites: an admin lets one person register while registration stays closed
CI & Build / Python lint (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 54s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m8s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m28s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m14s
Until now adding a second person meant re-opening registration to the whole internet while they signed up (#2939 §1). An admin now makes an invite in Settings: a link that works once, expires (7 days by default, 1 to 30), and can be pinned to one email address. Only the token's hash is stored, so the link is shown once. POST /api/auth/register takes `invite`. Redemption is one conditional UPDATE inside the transaction that creates the account, so two people racing one link can't both get in, and a taken email leaves the invite unused. Every refusal says "invalid or expired invite". The register page reads ?invite= and opens even while registration is closed. Refs #5172 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
5989ffc1c6 |
desktop: Import and Export work offline; the menu toggle is reachable; errors say why
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 48s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 4m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m10s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m32s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 12m42s
Export and Import were a link to the server and a reject("needs a server") on the
desktop. Both now run in the core with no server:
- core/src/local/portable.rs builds the same zip the server writes (notes.json,
a Markdown file per note, each attachment this device holds) and reads either
export marker or a Google Keep Takeout zip, with the server's decompression
budget and an all-or-nothing transaction. Export saves to Downloads (no new
plugin) and the sidebar says where; Import takes the archive as raw IPC bytes.
- core/testdata/portable.json pins the format for both copies: the server runs
its Keep and native readers against it (test_portable_fixture.py) and checks
its real export's keys (test_integration.py); the core runs the same cases.
- Found on the way: both importers skipped a Keep note that is only a photo as
"empty". It now imports, on the server and in the core.
- New dependency, approved: `zip` (deflate only) plus `flate2` on its pure-Rust
backend, both already in the lockfile.
The AppImage applications-menu toggle moves from Account, which the desktop
never shows, to the Sync page; the first-run prompt now says so.
errorMessage (#5236) replaces the hand-rolled `.error ?? …` / `.message ?? e`
reads at the remaining catch sites, so a desktop failure shows its real reason.
Task #5170.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
2b2ceaa82e |
attachments sync: attach offline, upload when linked, removals stick (#5168)
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 35s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 9s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 2m23s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m17s
Desktop could not create an attachment at all, and a removed attachment or dismissed preview came back on the next pull. Now: - core: add_attachment keeps the bytes in the blob store and queues the row (schema v10: attachments.uploaded / upload_error). Push uploads it once its note has landed. A refusal that retrying won't fix (too large, id clash, hash mismatch) is recorded on the file and not re-sent every cycle; the editor shows it. - core: removing a synced attachment or dismissing a preview leaves a tombstone in pending_deletes; push sends it as an `attachment`/`preview` delete, and a pull while it waits doesn't put the row back. A pull also keeps files still waiting to upload instead of replacing them wholesale. - server: PUT /api/sync/attachments/<id> (raw body, sha256-checked, idempotent, size-capped) and child deletes in push, which apply regardless of LWW and answer noop for rows the caller can't see. One store_attachment helper for the upload route, the importer and sync. Protocol 5, feature attachment_sync; the client sends neither to a server without it. - server: migration 0031 makes a link preview's insert/delete bump its note, so background-fetched previews and web dismissals reach linked devices. - desktop: Attach and paste-image work offline (raw-bytes IPC command). - SVG is served as a download by the desktop blob scheme too (as #1981 did for the web), and drawn as a file chip on both. - autosync: drop the catch_unwind; release builds abort on panic, so it only ever worked in debug builds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
535331c5b2 |
tests: one fixture for the note grammar, run by the core, the server and the web
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / integration (push) Failing after 27s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 1s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Failing after 12s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 4m25s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m28s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m19s
Desktop (Tauri) / Update manifest (push) Successful in 9s
Android / Kotlin + Rust (APK) (push) Canceled after 11m21s
The checklist grammar and the #tag rule are implemented three times (derive.rs, checklist.py/tags.py, markdown.ts), and the tag colour twice (colors.ts, DerivedTint.kt). Only Rust and Kotlin had tests. core/testdata/grammar.json now holds one set of cases (task lines, rendered items, tags, standalone-tag lifts and the tint hashes), and every suite reads it. - web: vitest, a dev dependency approved for #5166, with `npm test`. grammar.test.ts runs the fixture, and titles.test.ts pins #5165's palette fix. - ci.yml runs the web tests in the job the image build needs. desktop.yml's verify job runs them too, because the installers embed this frontend and can't see ci.yml's verdict (rule 177). - core: derive.rs reads the fixture. server: tests/test_grammar_fixture.py. - Android keeps its hand-written tint values; its doc now points at the fixture. The server is expected red here, on purpose. tags.py only takes a tag after whitespace and lets it start with a digit or `_`, while the core (the definition) takes any non-tag boundary and needs a letter. So `(#todo)` is a label on the phone and plain text on the server. The fix follows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
1a2f71e381 |
tests: every note-text write path is pinned, and two of them fail today
Integration tests for the edit sequence at each door: create, PATCH, ticking an item, restoring a revision and sync push. Two fail on today's code, on purpose: - restoring a revision never queues link previews, so a restored link stays a bare URL (#5164, audit B5); - a pushed note whose standalone #tag gets lifted stores the server's clock as its edit time instead of the client's, because the lift's second flush lets the column's onupdate overwrite it. The fix follows in the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a706644455 |
rename: the server is Inkwell — package, env vars, image, compose, export marker
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 45s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 4m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 7m33s
Desktop (Tauri) / Update manifest (push) Successful in 7s
Android / Kotlin + Rust (APK) (push) Successful in 11m25s
Step 2 of milestone 481. The operator chose a full rename (Scribe note 5071), so this goes past the display strings into the identities: - src/thoughtsync → src/inkwell; every import, the Dockerfile and both compose commands, alembic env, pyproject - THOUGHTSYNC_* → INKWELL_* (database URL, secret key, log level, tag/port/bind) - container data dir /var/thoughtsync → /var/inkwell - image git.fabledsword.com/bvandeusen/inkwell; Postgres user/db default inkwell; CI's integration service follows - the files the image serves are inkwell.*. fetch-clients.sh still fetches the thoughtsync-named release assets, because the lanes that publish them are renamed in steps 3 and 4 - exports are written with app "inkwell" Two deliberate exceptions, both because data rides on them: - compose volumes are now named explicitly and overridable (INKWELL_DB_VOLUME, INKWELL_DATA_VOLUME), so a deployment installed as ThoughtSync points at the volumes and DB identity it already has. .env.example says exactly what to set - import still accepts app "thoughtsync", because exports written before the rename are backups. Tested both ways Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
72968897ab |
channels: the dev channel publishes on dev-rolling, so its tag stops shadowing the branch
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 44s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 3m20s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m20s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 9m13s
The rolling dev release lived on a tag named `dev`, beside the branch named `dev`. Once a clone had fetched tags, `git push origin dev` failed with "src refspec dev matches more than one" (Scribe #2184, note #3042), and every session had to know to spell out refs/heads/dev. The channel is still `dev` everywhere a person sees it: the app's setting, `install.sh --channel dev`, the stored pref. Only the release tag moves, to `dev-rolling`, matching roundtable-android. `stable` has no branch to collide with and keeps its name. - packaging/channel-tag.sh is the one channel -> tag mapping CI reads: the publish steps in android.yml and desktop.yml, the manifest job, fetch-clients.sh and guard-forward.sh. guard-forward exits 2 on an unmapped channel instead of fetching an empty URL and passing. - update.rs and install.sh carry their own copy because neither can run it; update.rs gains a test that no channel feed is named like a branch. - tests/test_channel_tag.py runs the script: no tag is a branch name, dev is exactly dev-rolling, an unknown channel fails with no output. - publish-release.sh titles the release "ThoughtSync dev (rolling)", so the tag name does not leak into what people read. TEMPORARY bridge: desktop apps installed before this have .../download/dev/latest.json compiled in. The dev manifest job sets BRIDGE_TAG=dev, and write-manifest.sh writes the same latest.json to the old `dev` release. Its URLs name dev-rolling assets, so those apps update once into a build that reads the new tag. The bridge, and the old release and tag, are removed once installed apps have crossed over. Until then the push still needs the explicit refspec, as ci-requirements.md now says. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DwoKYuw3qJmUUYsJeNherB |
||
|
|
193dfb9e94 |
tags: renaming onto an existing tag merges them, and the older row survives
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m35s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m46s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 5m37s
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
The three surfaces did not agree on what renaming a tag onto a name another
one already holds should do, and none of the three answers was good.
I described this wrongly first time and the correction matters. The local
store does NOT silently create a duplicate: `idx_labels_name` is unique on
`lower(name)`, so the bare UPDATE in `rename_label` failed, and the user got
a raw SQLite "UNIQUE constraint failed" as their error message. The server
meanwhile answered 409 "a tag with that name already exists" — and only on
an EXACT match, because its constraint is on the raw name while every
client's index is on `lower(name)`.
That last part is the sharper bug. The server would happily hold "Groceries"
beside "groceries"; no synced client can store both. Creating that pair on
the web armed a pull that fails later, on a phone, in a path with no UI.
Operator's call: a rename onto an existing name means merge — typing an
existing tag's name onto this one says they are the same thing.
* `store::rename_label` and the server's PATCH now implement one rule.
THE OLDER ROW SURVIVES and takes the new spelling. Age rather than "the
one that already held the name", so that renaming A→B and B→A land on
the same survivor; otherwise the outcome depends on which way round
someone typed it, and two devices tidying the same pair disagree about
which id still exists. Ties go to the incumbent, so it stays
deterministic.
* The core reuses `merge_labels` rather than reimplementing the move. That
is the only place that knows to mark every affected NOTE dirty before
the delete cascades the membership rows away, which is what makes a
merge reach the server at all.
* The server's rename and its `/merge` route now share one `_merge_into`
helper, for the same reason.
* Both server lookups became case-INSENSITIVE, matching every client. The
create path is included: it was the one actually minting the unstorable
pair, so fixing only the rename would have left the door open.
* The web asks before merging, naming both note counts. A merge cannot be
undone by repeating it and is now reachable by a typo in a text field —
the same reasoning as the delete confirmation in #2116. The confirmation
lives in the shared store, so the desktop gets it too; the FFI does not
ask, because that belongs to the surface with a person in front of it.
* The web store detects the merge from the LIST, not the response: the
survivor may be the row we asked to rename, so an unchanged id proves
nothing.
Tests: three integration tests over a real database (both rename directions
land on the older row; a case-varied create returns the existing tag) and
two through the Android FFI, which is the binding the phone will use.
Also fixes a straggler from
|
||
|
|
d2f9d316cf |
tests: 300 comes back as "300" from a platform whose key is not an integer
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 15s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Build & push image (push) Successful in 41s
The precedence test wrote `version_code=300` for all five platforms and compared the desktop's against the int it wrote. It comes back as `"300"`, because the module preserves each platform's own comparator type instead of flattening both to int — which is the behaviour the change it was testing had just introduced. A `coded()` helper now says which shape to expect and why, and the assertion runs over every non-Android platform rather than spot-checking `linux-deb`. The test caught a real inconsistency in itself precisely because it compared against a concrete value rather than round-tripping what it wrote. |
||
|
|
ff6e99eb62 |
image: bake every client in, not just the phone
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Failing after 15s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m10s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 10s
Android / Kotlin + Rust (APK) (push) Successful in 8m3s
~104 MB on top of ~85 MB, almost all of it the AppImage. That is what the product being complete costs (rule 23): a self-hoster gets a working app for their machine from the server holding their notes, with no account on a forge that is private. The AppImage is not optional within that — it is the only bundle that can replace itself in place, so a server without one cannot serve in-app updates to anybody. `packaging/fetch-clients.sh` replaces the inline fetch and writes the fixed names and sidecars `client_dist.py` reads. It never fails: a platform with nothing published means the server advertises nothing for it and the UI hides that download, and eight fetches must not become eight ways to redden a green lane. THE VERSION IS FETCHED, NOT DERIVED, and this is the part that would have been wrong the easy way. The obvious shortcut is `version.sh display desktop` in the image job — it has the checkout. But this commit may not be the commit the channel is serving: a push touching only `src/` does not rebuild the desktop, so the channel still holds an older build and a locally-derived version would describe those bytes with this commit's number. `client_dist.py`'s size check could not catch it, because size IS measured from the real file — it would sail through and lie about the version alone. So `write-manifest.sh` now publishes `thoughtsync-desktop.json` beside `latest.json`, from the same two values in the same breath, and only size/sha256 are measured at bake time. Which needed the prune's keep-list, or the sidecar would have been uploaded and deleted again in the same run — a fixed name is self-limiting, which is exactly why that list exists. `version_code` is NOT uniformly an integer, and coercing it was a leftover from the days when Android was the only platform. Android's must stay a JSON number: `ClientRelease` in core declares it `i64` and a string fails to deserialize on every phone in the field. The desktop's is Tauri's semver key `1.0.<minutes>` — the value its updater actually compares — and `int()` would have rejected every desktop sidecar CI writes. The table now says which is which, and tests pin both directions. Also retires the comment above the fetch step, which claimed the APK came from "always the rolling dev release" and mentioned `:<version>` images. M314 step 3 made the channel conditional in the code directly below it, and step 6 removed version-shaped image tags entirely. Verified against the live dev channel before pushing: the Android half resolves and exits 0, the desktop half degrades with a warning because the sidecar does not exist yet, and all five constructed bundle filenames return 200. |
||
|
|
ef8aa9340f |
clients: the server hands out five platforms, not "the Android client"
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Successful in 30s
`client_dist.py` was written for one platform and everything structural in it was already right — drop-in beats baked, the pair must describe one build, absence is an ordinary answer, metadata public and bytes authenticated. This widens it to a table rather than building beside it. Its own docstring made the argument years before there was a second platform: a self-hoster should not need an account on someone else's forge to get the app for their own notes. Server side only. CI bakes nothing new until step 3 and the UI reads nothing new until step 4, so this lands green and inert. Five rows — android, linux-deb, linux-pacman, linux-appimage, windows — each naming its artifact, sidecar and mimetype. Fixed filenames, version only in the sidecar: a version-stamped name would force a glob, and a glob over a directory an operator drops files into is how you serve the older of two builds, which is the failure write-manifest.sh already carries a comment about. THE ANDROID NAMES AND ROUTE DO NOT MOVE. The lane publishes those exact filenames, clients in the field poll /api/client/android, and `android_client` stays on /api/config beside the new `clients` map. Renaming them to match the pattern would buy tidiness and strand every installed phone; retiring the key belongs to a later change made when nothing polls it, not to the change introducing its replacement. Fields were added, not moved — `ClientRelease` in core is a plain serde struct and ignores what it does not know. PRECEDENCE IS PER PLATFORM, which is the trap the table introduces. "First directory holding anything wins" would mean dropping in an APK silently retracts the four desktop downloads. Pinned by a test. The AppImage needs a third file. It is the only bundle that replaces itself in place, so the updater verifies a minisign signature before it does — and a bundle that cannot be verified cannot be offered. A missing or empty `.sig` therefore makes it absent rather than merely unsigned, and the signature travels WITH the version so an updater can never pair one build's version with another's signature. The tests parametrize over the table instead of testing Android and trusting the rest. The bugs this module can have are not platform-specific, and a suite that only exercised one platform is how the other four would ship untested. |
||
|
|
f992439588 |
version: every surface can say which build it is, and two of them were lying
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m50s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m59s
Note 3127 §5 removed version tags, so an artifact's self-report is now the only
answer to "which build is this?" — and nothing exists to contradict it when it
is wrong. Three surfaces gain a dim build line: the foot of the web rail, the
login screen, and the foot of Sync on Android.
The login screen because "I can't sign in" is a bug report like any other, and
requiring an account to read a build number withholds it from exactly the people
who can't get past that page. `/api/config` is already public.
Two of the values it was going to show were wrong, which is the part worth
knowing about.
The DESKTOP reported `env!("CARGO_PKG_VERSION")` from `config_get` and from the
startup log. `cargo tauri build --config '{"version": ...}'` overrides
tauri.conf.json, not Cargo's own metadata — so both read the literal `0.2.0` in
Cargo.toml, on every build ever shipped. They now read a display version baked in
by the lane through `option_env!`, hoisted to the crate root because two readers
of one fact is how this repo keeps producing 2181-2183. Not the ordering key
either: `1.0.<minutes>` is the opaque value Tauri's updater compares and must
never be shown to a person, and `update.rs` still reads it because a comparator
is exactly what it is (rule 149).
The SERVER fell back to `__version__` when APP_VERSION was absent, so a server
run from a checkout reported `0.2.0` — a real-looking version naming no build
anybody could obtain. `__init__.py` already asserted the honest answer was
"APP_VERSION being missing, which app.py already handles"; it did not, and a
comment claiming a behaviour two files away is how that stayed true-sounding.
Now an explicit "unknown", with the packaging version left where "unknown" is
not a legal value.
Android reads the INSTALLED package's versionName rather than BuildConfig, so it
reports what is actually on the phone.
Everything renders "unknown" rather than blank when it cannot say. A blank looks
like a layout bug; a plausible default cannot be caught by anything.
build.rs gets `rerun-if-env-changed` for the baked value: cargo does not track an
`option_env!` variable on its own, and the desktop lane having no cache today is
what makes that easy to forget the day one is added.
|
||
|
|
6e524ec616 |
guard: an empty channel killed the lane instead of passing it
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
Android / Build, or is the channel already serving this? (push) Successful in 2s
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 6s
CI & Build / Python tests (push) Successful in 9s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m33s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m14s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m46s
The first merge to `main` took the Android lane down (run 4857): the decide job exited 1 in 0.16 seconds with no output at all, and the image build skipped behind it because a failing lane must not publish. `stable` had never published an APK, which the guard treats as a pass — there is nothing to go backwards from, and `[ -z "$published" ]` says so in a branch of its own. That branch was unreachable. `published="$(published_for ...)"` under `set -e` dies on the substitution before it, and everything the pipeline would have printed goes into the capture rather than the log. What decided which lookups had the bug is the last command in the pipeline. `sed` on empty input exits 0; `grep` exits 1. Three of the four end in `sed`. Android's version_code ends in `grep -oE '[0-9]+$'`, so it was the only one — and only on a channel with nothing on it, which is why a week of dev pushes never saw it. The tests now reach the half of the guard that talks to a feed, with `curl` shadowed on PATH so they stay hermetic: an empty channel passes and builds, a lower published version passes, a higher one fails the lane, and an equal Android code is refused because Android will not install it. |
||
|
|
0ab7d94294 |
versioning: refuse to publish a version below what the channel already serves
CI & Build / Python tests (push) Successful in 17s
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 / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m21s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 9m19s
Step 5 of M314, note 3127 §6.3. Everything else in this milestone derives a number and trusts it; this compares the derived value against what the channel is actually serving and fails the lane if it went down. Too-low is the unrecoverable direction: every installed client reports "up to date" forever, and no later build fixes it until one climbs back above the bad number. #2183 and #2993 are both that symptom. ## Two hazards, two mechanisms A shallow clone is now tested DIRECTLY, in `version.sh`, via `--is-shallow-repository`. The empty-result guard only caught the case where nothing matched — and run 4796 showed the worse one, where a partial match returned a real six-days-stale answer. Asking the question outright costs no network and covers artifacts with nothing published to compare against. `guard-forward.sh` handles the rest: a squash or rebase merge rewriting the committer date, a rebuild of an older commit, and clock skew between runners. ## The comparison is per artifact, and the operator differs desktop derived >= published commit time, so equality is the ORDINARY no-change case and `<=` would fail every build that changed nothing android derived > published build time, so equality means two builds in one minute — and Android refuses to install an APK whose versionCode does not RISE The server is deliberately unguarded: nothing compares its version, `:latest` moves regardless, and rule 145 removed the version tags that would be the published list. A too-low value there is a wrong date in a footer, not a stranded client. It still gets the shallow-clone check. ## Proved to fire, not assumed Cloned the repo, checked out a commit eight back, ran the guard against the LIVE dev feed: at the tip derived 1.0.3502151, published 1.0.3502151 -> pass eight back derived 1.0.3501535, published 1.0.3502151 -> FAILS android tip derived 3502171, published 3502152 -> pass stable derived 1.0.3502151, published 0.2.0 -> pass That last row is worth keeping: stable still advertises the bare `0.2.0` from the old Cargo.toml scheme, so the transition orders upward on BOTH channels, not just the one being exercised. A channel with nothing published passes rather than failing — otherwise the first publish to a new channel could never happen. The guard runs BEFORE the build in all three lanes, so a bad derivation costs seconds rather than a five-minute compile and a publish to undo. `compare` is exposed as an explicit mode so the ordering is testable without a network and inspectable without a push — 16 cases including `1.0.9 < 1.0.10`, which a string compare gets exactly backwards. #3145 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
85ead4d66b |
versioning: anchor at the repo root — a pathspec is relative to the caller's cwd
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 15s
Three failures on
|
||
|
|
c5044339a1 |
versioning: each artifact derives from its own files, with the clock picked per value
CI & Build / Python lint (push) Successful in 4s
CI & Build / Python tests (push) Canceled after 13s
CI & Build / integration (push) Canceled after 13s
CI & Build / Build & push image (push) Canceled after 0s
Android / Kotlin + Rust (APK) (push) Failing after 14s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m19s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m24s
Desktop (Tauri) / Update manifest (push) Failing after 4s
Step 4 of M314. `desktop/packaging/build-version.sh` was one generator feeding the desktop bundles AND the Android APK off `GITHUB_RUN_NUMBER`, so a Kotlin-only commit re-versioned the desktop and a Rust-only commit re-versioned the phone. Note 3127 §3 cites this repo as its example of that failure. It is replaced by `packaging/version.sh` — one definition of HOW to derive, three file sets, and the sets in one place. Lives at the repo root rather than under desktop/, because it serves three artifacts now and a shared thing filed under one consumer ends up owned by it. ## Two values, and the clock chosen per value (§2) desktop key 1.0.<minutes since 2020-01-01> commit time desktop display 2026.08.28.0900 commit time (#3181 shows it) android versionName commit time android versionCode <minutes since 2020> BUILD time server version 2026.08.28.0900 commit time, no ordering key Every human-readable version in the repo is now one shape. The two exceptions are not version names at all — they are bare monotonic integers a comparator reads and nobody quotes. The desktop needs a separate key because Tauri parses `latest.json` with the semver crate and `2026.08.28.0900` fails it twice (four segments, and `08` is a leading zero). `1.0.` and not `0.0.`: the minor has to clear the installed `0.2.466` line or every dev user is stranded on "up to date" permanently. Android's code comes from BUILD time while the desktop's key comes from COMMIT time, deliberately. Android hard-fails a downgrade with INSTALL_FAILED_VERSION_DOWNGRADE and leaves a channel you cannot get out of, so its key must be monotonic by construction; the desktop merely declines to offer an update, which a guard can catch. ## The bug this found in itself The shallow-clone guard `exit 1`-ed inside a function called as `$(...)` — which ends the SUBSHELL, not the script. `display` still failed, but only because `date` then choked on the empty string. `key` printed the error to stderr, emitted `1.0.-26297280`, and exited ZERO. That is precisely the failure the guard exists to prevent: a too-low version on a green lane, and too-low is the direction you cannot recover from. It resolves into a global in the parent shell now. The test is parametrized over both requests, because one path was covered and the other was broken in exactly the way the covered one was meant to rule out. ## Also `fetch-depth: 0` on every job that derives — four of them, and only ci.yml's gate had it. Depth-1 is silently wrong rather than loudly broken (§6.1). The file sets include each artifact's BUILD RECIPE (its workflow, and `packaging/`). A workflow file is not shipped, but change a Gradle flag and the bytes change while the source does not — and once step 6 skips a build whose version already exists, that serves the OLD artifact on a green run. The base images are deliberately NOT resolved at derive time: that is an external lookup, which §7's corollary forbids. `Dockerfile` is already in the server's set, so pinning `FROM` by digest in step 6 puts the base inside the set for free. `build-version.sh` is deleted, its last consumer (the pacman packager) moved over, and the one finding worth keeping out of its header — why not a `-dev.N` prerelease — is preserved in the successor. #3144 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> |
||
|
|
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. |