99 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5 cf2854a029 ktlint: a multiline .border() left the next '.' orphaned, exactly as #3110 records
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 5s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 25s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m2s
`standard:chain-method-continuation` on `LinkPreviewRow.kt:83`. The `.border(…)`
call took three arguments across four lines, and the `.padding(…)` after it then
began a line with a `.` — which the rule only accepts glued to the closing
paren, `).padding(…)`.

Issue #3110 hit this same rule in `NoteCard.kt` and recorded the fix: do not
write the multiline element. Naming `shape`, `padH` and `padV` first collapses
`.border` back to one line and removes the duplicated RoundedCornerShape at the
same time, which is better than what ktlint was willing to accept.

Also did what #3110's verification note says to do rather than fixing only the
line the linter named: scanned every Kotlin file this branch touched for the
same shape — a multiline chain element followed by a `.` on a new line — and
found no others. ktlint reports one violation and stops, so a second would have
cost another full Android lane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-09-01 19:01:09 -04:00
bvandeusenandClaude Opus 5 62338bb0a4 android: a link in a note renders as a link card, not a bare URL
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
CI & Build / TypeScript typecheck (push) Successful in 6s
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 22s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 11s
Android / Kotlin + Rust (APK) (push) Failing after 3m33s
The web and desktop have shown link previews since #2898; the phone showed the
raw address. The data was already on the device — `Note.previews` is populated
by the core and carried through the FFI — and nothing under `app/src/main` read
the field.

The three presentation rules are copied from `NoteCard.vue` rather than
re-decided, so the same note reads the same way on every surface:

  * A note that is NOTHING but a URL renders as its preview and nothing else.
    Printing the address under a card that already says where it goes is saying
    the same thing twice, badly.
  * Links mentioned INSIDE a note get a compact strip at the FOOT of the card.
    Above the body would put a stranger's headline where the note's first line
    should be; the web learned that in M13.
  * Several stack.

`LONE_URL` mirrors the web's `LONE_URL_RE` including the tolerated whitespace —
if the two regexes disagree, one note reads as a card here and a paragraph
there.

Falling back to the URL is deliberate in all three of the cases that produce no
preview: not a lone URL, not unfurled yet, or never unfurlable. A note written
on the phone and not yet synced is permanently in the middle one, because the
unfurl is server-side (`unfurl_queue.py`) and arrives on a later pull — so that
state has to look deliberate, and showing the link does.

No unfurl fetch was added here, and none should be: a phone fetching OG tags
would be a second SSRF-hardened fetcher on the surface least able to afford the
call.

## No image, and that is a question rather than an omission

`LinkPreview.image_url` is a REMOTE third-party address — the web renders it
straight from whatever host the link points at. Matching that here would have
this app fetch images from arbitrary hosts, on a phone, on possibly metered
data, and would make it the first image loading anywhere in this client: there
is no loader, no cache, and not one `Image(` in the whole app today. That is a
decision about privacy and data use, not a rendering detail, so the text card
ships and the image is asked about rather than assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-09-01 18:51:51 -04:00
bvandeusenandClaude Opus 5 1a41373347 board: a note trashed from search results now leaves the results
Search for something, long-press a hit, Move to trash: the snackbar said it
happened and the card sat there until the query next ran. Reachable from the
editor's overflow too — both go through `mutate`.

`mutate` kept the existing list whenever a search was running, with the
reasoning recorded in place: search results are the answer to a query, not a
live view, and running the BOARD query underneath them would replace the hits
with the whole board.

That is right about the board query and wrong about the note. A hit that no
longer matches has left the answer, not just moved within it — pinning one and
watching it not re-sort is fine; trashing one and watching it stay is not.

So the search is re-run instead of the destination loaded. The results are
still the answer to the query, just a current one, and it costs one local
SQLite query — the same argument the surrounding comment already makes for
reloading the board.

Creating a note while searching still leaves the list alone: a new note that
does not match the query has no business appearing in its results.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-09-01 18:51:51 -04:00
bvandeusenandClaude Opus 5 729d0dadf1 editor: collect the refund — the web editor autosaves on an idle pause
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python tests (push) Successful in 10s
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 / TypeScript typecheck (push) Successful in 6s
CI & Build / integration (push) Successful in 22s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m59s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m29s
Desktop (Tauri) / Update manifest (push) Successful in 5s
#2971's engine work was already done and its benefit was never taken up here.

Both engines coalesce revision snapshots to one per editing session —
`src/thoughtsync/revisions.py::should_snapshot` and `store.rs`'s namesake, the
server's applied on the PATCH path AND in `sync.py`, with four integration
tests covering it. So a write has cost a write, not a write plus a revision,
for some time.

But this editor still wrote only on `close()`. That save-on-close existed
BECAUSE writes were expensive; with the reason gone, all that was left was the
cost — a tab closed mid-paragraph lost the paragraph, which is the one thing a
notes app must not do. Android already debounces (`BoardViewModel`); the shared
Vue editor did not, so web and desktop kept paying for a trade that had been
cancelled.

Now: a 1s idle pause writes.

EDIT MODE ONLY, deliberately. In compose, `dismiss` discards a note that was
never persisted so an accidental keystroke or a type-to-compose never litters
the board. An autosave there would create the row and quietly take that
behaviour away. Materialising a compose on first keystroke is a separate
decision (#2967), not a side effect of this one.

Three details that decide whether it is safe rather than merely present:

  * `flush` returns without writing while a save is in flight, so an autosave
    landing there would silently drop everything typed since that save began.
    It RE-ARMS instead of skipping.
  * Errors are swallowed and retried on the next pause. An autosave that
    interrupts typing with a message is worse than one that waits, and `close`
    still surfaces a real failure where the person is looking.
  * The timer is cancelled by `close`, by `dismiss` and on unmount, so nothing
    fires through a component during its leave animation or after it is gone.

Checked and found harmless rather than assumed: `notes.reconcile` replaces the
store's item but never touches `useNoteEditor`'s `editing` ref, so the
`watch(() => props.note)` that calls `setBody` does not fire on a save. Were
that not true, autosaving would have reset the field and the caret every
second.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-09-01 18:26:05 -04:00
bvandeusenandClaude Opus 5 23a61365da capture: the suggested shortcut is a UI affordance, so it lives in the UI
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 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 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Successful in 17s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m31s
Desktop (Tauri) / Update manifest (push) Successful in 5s
`-D warnings` failed the Linux lane on `constant SUGGESTED is never used`, and
it was right — the suggestion is implemented in `bridge.ts` as
SUGGESTED_CAPTURE_SHORTCUT, and nothing in Rust ever read the copy here.

Deleted rather than exposed through a command. This side accepts any
combination the OS will take; picking one to put in front of someone as a
starting point is a UI decision, and a constant here would only be a second
copy of a string one layer reads and the other does not.

Worth noting what this run DID prove, since the previous one proved nothing:
the lockfile gate passed and the Windows job built the NSIS installer end to
end. So `tauri-plugin-global-shortcut`'s handler signature — the thing I could
not verify without a toolchain — is correct, and the feature compiles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-09-01 18:13:31 -04:00
bvandeusenandClaude Opus 5 6c0153be1e desktop: a global hotkey opens a small window to write in, now with its lockfile
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 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 29s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m5s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m52s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m22s
Restores 42e06da, which was reverted only because Cargo.lock had not been
updated for the new crate and every cargo invocation in CI passes `--locked`.
Both desktop jobs failed on that line before compiling anything, so nothing
about the code had been judged.

The lockfile was generated in CI's own `ci-tauri:1.97` image — one container,
`cargo fetch`, nothing built. `cargo fetch` and NOT `generate-lockfile`: the
latter re-resolves from scratch and would have churned versions across the
whole workspace to add one dependency. The diff is 67 insertions, zero
deletions, six packages — tauri-plugin-global-shortcut plus global-hotkey,
x11rb, x11rb-protocol, xkeysym and gethostname. Nothing existing moved.

The feature itself, unchanged from 42e06da:

Press the combination anywhere and a small window arrives over whatever you
were doing; type, Ctrl/Cmd+Enter, gone. The board never comes forward.

There is no default shortcut on purpose — any default is a key combination
taken away from something else on somebody's machine, silently, at install
time. CommandOrControl+Shift+N is offered as a one-click suggestion.

Stored and live are separate fields because they disagree: a combination
another app holds is saved and does nothing when pressed, and a Wayland
compositor may refuse global grabs outright. `capture_shortcut_set` registers
before storing, so a refused combination is never written down as if it worked.

The window hides rather than closes and keeps its text, so an interrupted
capture is still there next press — which is what makes Escape safe. A failed
save keeps it open too, rather than discarding the only copy of something just
written in order to report a retryable problem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-09-01 18:05:20 -04:00
bvandeusenandClaude Opus 5 10ea15bef0 Revert the desktop hotkey: a new crate needs a Cargo.lock this machine cannot write
Android / Build, or is the channel already serving this? (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 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 19s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m52s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m7s
Desktop (Tauri) / Update manifest (push) Successful in 4s
`42e06da` added `tauri-plugin-global-shortcut` to Cargo.toml without updating
Cargo.lock, and every cargo invocation in CI passes `--locked`. Both desktop
jobs failed on the same line before compiling anything:

    error: cannot update the lock file ... because --locked was passed

So this says nothing about whether the code is right — clippy never ran. The
gate did exactly its job.

There is no Rust toolchain on this workstation (rule 10 — CI verifies), and a
lockfile is the one artifact CI is deliberately forbidden to generate. Hand-
writing the entries is not a real option: it needs the exact checksum and the
whole transitive tree, and a wrong checksum fails harder than a missing one.

Reverted rather than left red, because a red `dev` blocks everything behind it
and the Android half of #1899 is green and unaffected at c8318c3. The work is
intact in 42e06da and comes back with `git revert 5e0c...` once the lockfile
exists — nothing here needs rewriting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-09-01 09:10:33 -04:00
bvandeusenandClaude Opus 5 42e06da576 desktop: a global hotkey opens a small window to write in, and nothing else
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 30s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 5s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 35s
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Build & push image (push) Successful in 35s
The other half of #1899. Press the combination anywhere and a 520x220 window
arrives over whatever you were doing; type, Ctrl/Cmd+Enter, it is gone. The
board never comes forward, which is the whole point — bringing the app up to
write one line is the friction this removes.

## There is no default shortcut, deliberately

A global shortcut is the one setting here that can collide with software this
app knows nothing about. Any default is a key combination taken away from
something on somebody's machine, silently, at install time. So the feature is
OFF until a combination is chosen, and choosing one is how it turns on.
CommandOrControl+Shift+N is offered as a one-click suggestion, never applied
on the user's behalf.

## Stored and live are reported separately

`CaptureShortcut` carries both `shortcut` and `registered`, because they
genuinely disagree: a combination another app grabbed first is saved and does
nothing when pressed, and on Wayland a compositor may refuse global grabs
outright. Saying only "your shortcut is X" would be a lie with a keystroke
attached, so the settings row says "saved but isn't active — something else is
holding it". `capture_shortcut_set` registers BEFORE storing, so a
combination the system refuses is never written down as though it worked.

Registration at startup is best-effort and logged: a shortcut that worked when
it was chosen can be taken by something installed later, and the app must
still open.

## Two windows, one database, no shared store

The capture window runs a second copy of the frontend with its own Pinia
stores, so a note saved there is invisible to the board until it is told. It
is told — `capture_done(saved)` emits to `main`, and BoardView reloads. The
emit failing is cosmetic (the note is already in SQLite) so it is logged, not
raised.

The window is opened at `index.html?capture=1` rather than at `/capture`
because the bundled assets are served as FILES: a path with no file behind it
404s in the production build while routing fine under the dev server. The
router turns the query into the route.

It is hidden rather than closed on the way out, and it keeps its text. A
capture interrupted by something more urgent is still there on the next press,
which is what makes Escape safe to press. A failed save also keeps the window
open holding the text — hiding it would throw away the only copy of something
just written in order to report a problem you could retry your way out of.

## Where the setting lives

Rule 25 says a tunable belongs in the UI, and this one has to be. It sits in
the desktop's Sync screen beside the update channel, not in admin Settings:
that screen is the SERVER's and bounces on desktop anyway, while this is a
property of one installation on one machine. Persisted with the same
`store::set_pref` the update channel uses.

No @tauri-apps/api dependency was added — everything routes through `invoke`
and the `withGlobalTauri` global, as the rest of the bridge does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-09-01 09:02:39 -04:00
bvandeusenandClaude Opus 5 c8318c323a android: Share → ThoughtSync, and a "New note" entry in the selection toolbar
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (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
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 22s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m21s
Capture without opening the app first — the input half of #1899. Two ways in:
the share sheet from anywhere, and the text-selection toolbar in any app's
text field.

## The note is created, not pre-filled

The obvious build is "open the editor on a draft holding the shared text".
That silently loses it. `NoteEditorScreen`'s flush is guarded by
`bodyText != note.body`, so a draft handed the text already has nothing to
save — share a link, press back without typing, and it is gone. Which is
exactly the shape of a share: the common case is walking away.

So `captureShared` makes the row first and opens the editor on the real
note. A share has already said "keep this"; creating it is what honours
that, and back then leaves a saved note rather than a decision.

## launchMode="singleTop"

The reminder notification adds FLAG_ACTIVITY_SINGLE_TOP to its own intent,
which is why `onNewIntent` already worked there. A share intent is built by
the OTHER app and nothing here can add a flag to it, so the activity has to
declare it. Without that, every share while the app was running would stack a
second MainActivity — a second view model, a second board, and a back press
landing on a stale copy of the same app.

## Subject and text, both

A browser sends EXTRA_SUBJECT as the page title and EXTRA_TEXT as the URL.
Keeping both makes the note read as its title, because the core names a note
by its first line — the difference between a board you can scan and a column
of identical links. `distinct` because plenty of senders put the same string
in both.

The extras are removed on read, like the reminder's note id and for the same
reason: the activity keeps its launch intent, so without consuming them a
rotation would replay the share and mint the note again.

## Not included: images

`image/*` is deliberately absent from the filter. Nothing in this app can
create an attachment — the core has `delete_attachment` and no counterpart,
and the FFI exposes neither. Declaring the mime type would put ThoughtSync in
front of people in the share sheet for a job it cannot do, and fail after
they had already chosen it. Adding it needs an attachment-creation path
through the core, the FFI and sync, which is its own piece of work.

The desktop half of #1899 — a global hotkey — is not in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-09-01 08:53:38 -04:00
bvandeusenandClaude Opus 5 cc50812a86 ktlint: the Tags imports landed after SyncScreen, and SyncState sorts after that
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 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
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 12s
CI & Build / integration (push) Successful in 23s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m8s
`standard:import-ordering`. The two new imports were inserted by anchoring on
`com.fabledsword.thoughtsync.ui.SyncScreen`, which looked like the right
neighbour and is not — `SyncState` and `SyncViewModel` both sort after it, so
Tags* wedged into the middle of the Sync block.

Moved below `SyncViewModel`. Every import block in the five files this branch
touched is now confirmed sorted, not just the one ktlint happened to reach
first — it reports one violation and stops, so a second would have cost
another full Android lane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-08-31 19:53:08 -04:00
bvandeusenandClaude Opus 5 1e54b80f15 android: a Tags screen, so the phone can do more than attach tags to a note
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
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 12s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 3m28s
Android could list tags and mint new ones. It could not rename, recolour,
delete or merge one — and since the per-note colour picker was removed with
2949, tag colour is the ONLY colour control in the product, which meant an
Android-only session had no way to change any colour anywhere.

A destination reached from the drawer, not a modal. The web's LabelsModal is
a modal because a desktop can float one over the board; on a phone this is a
place you go to tidy up, and a full screen is what that is.

The manage entry is an action ON the drawer's Tags header rather than a row
in it, so it cannot be mistaken for a sixth lens. The header now renders even
when there are no tags: this screen is where you make the first one, and
hiding the way in until one exists is a door that only appears once you are
already inside.

## The two calls this needed

RENAME and MERGE deliberately do not follow the same rule, and the screen
says so rather than hiding it.

  * A rename that lands on an existing name merges, older survives (3324).
    That path is accident-prone — it is a text field, and a typo reaches it —
    so it needs a rule that cannot depend on which way round it was typed.
    The screen catches the collision against the LIST, not from what the core
    returns: the survivor may be the tag being renamed, so an unchanged id
    afterwards proves nothing. Then it asks before merging.

  * An explicit merge keeps its direction. Here the person is choosing, and
    the direction IS the intent — folding #grocery into #groceries is a
    decision, and overriding it with age would refuse the thing they asked
    for. The price is that the direction has to be unmissable, so the body
    names the tag that stops existing and every row offered is the survivor.

Delete quotes the note count, because "it is on 40 notes" is a different
decision from "delete this tag?". The count comes from `list_labels`, the
only call the core populates one on. It also says that a tag written as #tag
in a body comes back on that note's next edit — deleting the row cannot
un-write the word, and that is better said than discovered.

## The board had to learn something

`Destination.WithLabel` holds an id, and deleting or merging a tag the board
is currently LOOKING at would strand it on a lens that queries a row which no
longer exists — permanently empty, escapable only via the drawer. So
`loadLabels` became `refreshLabels`: public, and it drops back to Notes when
the current lens is gone. A failed listing deliberately does NOT trigger that
fallback — "I could not read the tags" is not evidence that this one went.

Reused rather than rewritten: `ErrorBanner` (the board and editor already
share it), `MenuItem` from Panel.kt (it closes the menu before acting so a
dialog cannot open under a hanging menu), `PlainTextField`, and the
`NOTE_TINTS` palette — the screen consumes it and does not fork a copy.

`default` stays in the palette on purpose: a tag with that colour gets a hue
derived from its name, so it means "let it pick", and removing it would leave
no way back to that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-08-31 19:44:54 -04:00
bvandeusenandClaude Opus 5 550a34d8e2 fmt: rustfmt budgets macro arguments at 60 chars, not the 100-char line
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m2s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m45s
Both new assertions fit well inside the 100-column limit and both were still
rejected. The governing setting is `fn_call_width` (60), applied to a macro's
argument list: `survivor.id, older.id, "the older row is the one that
survives"` is 62 characters, so rustfmt breaks it and pairs the two values on
one line with the message beneath.

The neighbouring `assert_eq!(survivor.name, "Grocery", "spelled the way the
caller asked")` was accepted at 59 characters of arguments, which is the
same rule agreeing rather than a different one.

rustfmt's own output, pasted back. Second time this lane has caught the same
class of thing in one session — the other was a method chain, budgeted at 60
by `chain_width`. Recorded so the next person reaches for the 60, not the 100.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-08-31 16:46:15 -04:00
bvandeusenandClaude Opus 5 8c7553d619 copy: the product says "tags" now, and the schema keeps saying Label
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 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 23s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m7s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m17s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m4s
Two words for one concept cost real comprehension: over a single exchange
the operator concluded that auto-tagging did not exist (it does, in
`derive.rs`) and that a tag-management view did not exist (it does,
`LabelsModal.vue`). The `#` is how most of these get made, so the `#` wins
the noun.

User-visible strings only, on all three surfaces plus the server's errors.
`Label`, `NoteLabel`, `via_tag`, `label_id`, the tables, `/api/labels` and
the FFI names are all untouched — renaming those touches migrations and the
wire format to buy nothing a reader can see.

Two of these were more than a find-and-replace:

  * Android's `label_from_tag` said "from #tag", sitting beside a chip that
    already renders as `#name`. Once every one of them IS a tag that hint is
    circular. What it actually tells you is that the note's BODY owns this
    one — which is why it alone has no remove cross — so it now says "from
    the text".

  * The web's empty state said "No labels yet — create one above" while
    Android's already mentioned the `#` route. The web now says it too. That
    is the exact fact the operator did not have.

The paired `aria-label`s went with their `title`s; a screen reader saying
"label" while the tooltip says "tag" is the same confusion with a smaller
audience.

Left alone deliberately: `json_error("invalid label")` and
`"label_ids must be a list"` in `notes/__init__.py` name the `?label=` query
parameter and the `label_ids` request field. Those are wire surface, not the
word a person reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-08-31 15:53:28 -04:00
bvandeusenandClaude Opus 5 d838b27518 ffi: Kotlin could list and create a tag but never rename, recolour, delete or merge one
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
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
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 10s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m0s
`core/src/local/store.rs` implements all seven label operations. The uniffi
object exposed three of them, so Android could attach tags to a note and
mint new ones, and could do nothing else with them ever.

The four additions are pure passthrough, because reading the store showed
both of the things #2963 said to check rather than assume are already
handled there:

  * The note count exists. `Label` carries `count: Option<i64>` and
    `list_labels` computes it per row, excluding trashed notes — which is
    the number a delete confirmation should show. The single-label returns
    all end in `load_label` and leave it `None` on purpose, so a screen must
    read counts from the LIST and never from an operation's result.

  * Sync is free. `rename_label` and `set_label_color` set `dirty = 1`;
    `remove_label` records a pending delete; `merge_labels` records one for
    the source AND marks every note that carried it dirty before the delete
    cascades the membership rows away, because push sends `label_ids` per
    note.

So no store change, no sync change, no count plumbing — the binding only.

One divergence found and documented rather than fixed: renaming a tag onto
an existing name is a 409 on the server (`labels.py:94`) and a silent
duplicate in the local store. The desktop has always had this, calling the
same `store::rename_label`; Android now inherits it. Deciding which side is
right belongs with the screen (#2964), not with the binding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-08-31 15:52:00 -04:00
bvandeusenandClaude Opus 5 a69159e562 fmt: rustfmt breaks the tuple-index chain, and the desktop lane means it
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 3s
CI & Build / Python tests (push) Successful in 10s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / integration (push) Successful in 23s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m11s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m27s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m21s
`cargo fmt --all --check` failed the desktop lane on one hunk in the new
`client_headers_identify_app_and_protocol` test. Clippy and every test
passed; only the formatter objected.

rustfmt splits `client_headers()[0].1.starts_with(..)` across lines because
an index followed by a tuple field followed by a call is a three-element
chain, and it will not keep one on a single line inside a macro argument
regardless of width. This is rustfmt's own output, pasted back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-08-31 14:57:07 -04:00
bvandeusenandClaude Opus 5 c40916699b sync: the client header said "desktop" from every phone, and named the wrong version
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
Desktop (Tauri) / Build, or is the channel already serving this? (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 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m55s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m37s
`client_headers()` built `thoughtsync-desktop/{CARGO_PKG_VERSION}`, and both
halves were wrong.

This crate is compiled into the Android app as well as the desktop one, so
every phone in the field announced itself as a desktop. And CARGO_PKG_VERSION
here is the CORE crate's version — a number no build stamps and no user has
ever seen — where the thing a reader of that header wants is the app's own
build (note 3127 §5: with no version tags, the artifact's self-report is the
only answer to "which build is this?").

The core cannot know either value, so the host says them. `set_client_agent`
is a OnceLock the desktop fills in `run()` and Android fills in
`ThoughtSyncApplication.onCreate`, before anything can sync. A host that never
introduces itself sends `thoughtsync-unidentified/unknown` rather than a
plausible default: nothing reads this header today, which is exactly why a
wrong value could sit in it for months — the first person to look at a server
log is the first who could catch it, and only if what they see is obviously a
host that never said who it was.

Android's version comes from the INSTALLED package, through a new
`Context.installedVersionName()` that the foot of the Sync screen now shares.
One answer to "which build is on this phone", so the line a person quotes in a
bug report and the line in the server's log cannot disagree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-08-31 08:40:01 -04:00
bvandeusenandClaude Opus 5 ef418a8c92 buttons: one definition of the shape, worn by a <button> and by an <a>
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 4s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Successful in 40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m1s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m14s
Desktop (Tauri) / Update manifest (push) Successful in 6s
The download links added in fd1e4ae carried their own copy of BaseButton's
class list, because BaseButton is a <button> and cannot hold an href — and a
download must be an anchor, so the browser's own download manager gets the
3-95 MB transfer instead of a blob this app would have to hold in memory.

A copy is not a solution to that; it is two primary buttons that look alike
until someone changes one. So the shape moves to `.btn` + `.btn-primary` /
`.btn-ghost` in the components layer, where both elements can wear it, and
neither owns it.

The `disabled:` variants stay on BaseButton. An anchor has no :disabled, so
they were never shared and pretending otherwise would put a rule in the
shared definition that only one of its two users can ever match.

Verified there is exactly one shape to unify and no third copy: `px-4 py-2.5`
appears in three other files and all three are something else (a toast, a
dashed quick-add affordance, a retention notice). The smaller brand buttons in
AppShell and NoteEditor are a different size, which is a size-variant question
and not this one. And exactly one call site passes a class to BaseButton —
`shrink-0` — which cannot conflict with anything the shape declares.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-08-31 08:08:01 -04:00
bvandeusenandClaude Opus 5 fd1e4ae487 downloads: five clients, and the page leads with the one that fits you
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
CI & Build / Python lint (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m21s
Desktop (Tauri) / Update manifest (push) Successful in 5s
The Account page offered the APK and nothing else, because the APK was all
the server held. Step 3 baked in four more, so the single card had to become
a section — and five artifacts is exactly where a downloads page turns into
a table of filenames and stops being a product.

So it LEADS with what fits the machine asking, from the user agent, and keeps
the rest quiet but visible. A wrong guess costs nothing: nothing is behind a
disclosure and every other client is one click away.

Linux gets all three at once, because the UA says "Linux" and nothing about
dpkg or pacman — there is no better answer available. They are named for the
distro rather than the package format, since a person knows which system they
run and not necessarily which packaging it uses. The AppImage carries one
clause of its own: it is 95 MB against 3, and it is also the only bundle that
updates itself in place. Both facts belong to the same decision.

macOS and iOS lead with nothing and say so. There is no build for either, and
"There's no macOS build yet" is the difference between deliberate and broken.

The version renders `unknown` rather than blank, and the download stays
offered — not knowing which build it is, is not a reason to withhold it.

Two things this did NOT do, both deliberate:

The task asked for a Tauri case — do not offer the desktop app to someone
already running it. That case cannot be reached: `/account` redirects to the
board in the desktop app (requiresServer, router/index.ts), because device
tokens are a server-side concept. A branch for it would be dead code.

`.btn-link` mirrors BaseButton's declarations rather than replacing them.
BaseButton is a <button> and cannot carry an href, and unifying the two would
have put every button in the app into an operator pass that CI cannot check —
for a cosmetic gain. The comment names the pair.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-08-31 07:58:17 -04:00
Bryan Van Deusen 8a75e5f340 clients: an unquoted 1.0.3504551 is not JSON, and every sidecar was one
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 lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 16s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m27s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m0s
Android / Kotlin + Rust (APK) (push) Successful in 8m18s
`fetch-clients.sh` wrote `"version_code": %s` unquoted, which was right when the
only ordering key in sight was Android's integer. The desktop's is Tauri's
`1.0.<minutes>`, and unquoted that is not valid JSON at all — so `json.loads`
raised on all four generated sidecars and the server advertised nothing. A silent
zero, not an error: `_read` treats a malformed sidecar as "no client here", which
is right for a corrupt drop-in and indistinguishable from this.

Caught by running the real fetch against the live dev channel and feeding the
result to the real resolver, rather than by reading the printf.

Also makes `_resolve` wrap BOTH candidate roots in Path(). Only the first was, and
the asymmetry fails the same quiet way: a str `/` str raises TypeError, `_read`
catches it, and a perfectly good directory reads as empty.

The whole pipeline now resolves end to end against the live channel — five of five
platforms, every sidecar valid JSON, one human-readable version across all of them
with each artifact keeping its own comparator type:

  android         2026.08.30.1711  code=3504552        57.6 MB
  linux-appimage  2026.08.30.1711  code='1.0.3504551'  95.3 MB  signed
  linux-deb       2026.08.30.1711  code='1.0.3504551'   3.3 MB
  linux-pacman    2026.08.30.1711  code='1.0.3504551'   2.7 MB
  windows         2026.08.30.1711  code='1.0.3504551'   2.6 MB
2026-08-30 13:21:03 -04:00
Bryan Van Deusen 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.
2026-08-30 13:19:18 -04:00
Bryan Van Deusen 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.
2026-08-30 13:11:04 -04:00
Bryan Van Deusen 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.
2026-08-30 12:52:40 -04:00
Bryan Van Deusen 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.
2026-08-29 23:07:29 -04:00
Bryan Van Deusen 544cf72735 install: the stable fallback is dead now that stable publishes its own bundles
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 19s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Build & push image (push) Successful in 29s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m25s
Desktop (Tauri) / Update manifest (push) Successful in 4s
It existed for one window: `stable` was a manifest-only pointer at whatever `v*`
tag had last been cut, and `stable` is the DEFAULT channel, so without the
fallback `curl … | sh` was broken for everyone between step 3 landing and the
first merge to `main`. That merge happened (`b6673c6`), and `stable` now holds
its own signed bundles at 1.0.3503145 — AppImage, deb and pacman, all resolving
by the one lookup both channels share.

Kept as a fallback it stops being a safety net and becomes a mask: the branch
only runs when `stable` has no bundles, which from here on means something is
broken, and chasing a `v*` release instead of saying so is the wrong answer.

The header now says the transition is finished and that neither channel should
be special-cased again, because the shape of that code invites re-adding it.
2026-08-29 16:59:44 -04:00
Bryan Van Deusen 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.
2026-08-29 13:45:26 -04:00
bvandeusenandClaude Opus 5 c2fdc05e5c release: a tag builds nothing and carries a changelog instead
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 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m17s
Desktop (Tauri) / Update manifest (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 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m0s
Android / Kotlin + Rust (APK) (push) Successful in 8m4s
Step 7 of M314, the last one. Rule 22 — the old path comes out completely.

## A release stops building

`desktop.yml` no longer triggers on `v*`, and its two `Publish release` steps
are gone. `ci.yml` lost its tag trigger in step 6. So a tag now reaches exactly
one lane: the new `release.yml`, which builds nothing.

That is not a simplification for its own sake. The merge to `main` already
published everything a user can receive — `:latest` + `:<sha>`, both channel
feeds, the updater manifest. A tag rebuilding that source produces identical
artifacts under identical names and re-pushes `:<sha>` with different bytes,
which rule 145 forbids even when they match.

## So what a release is FOR

The changelog (note 3127 §5). Two halves to "what am I running", and the
version answers only the first: which build is this (the footer, /api/config,
the APK's versionName) and what is in it that was not in the one I ran last
month (nothing, until now).

`packaging/release-notes.sh` derives it from git rather than a hand-maintained
CHANGELOG, which drifts into recording what someone MEANT to ship. Capped at 60
entries with the omitted count stated — the first dated release spans 181
commits since `v0.1.0`, and a truncated list that does not say it is truncated
is a lie.

It publishes through `publish-release.sh` rather than making its own API calls,
for the create-or-PATCH-on-409 path: a fixed-tag release that only ever POSTs
keeps whatever body its first run wrote, which is #2182, and reimplementing that
correctly in a second place is how it comes back.

## Retired

`MANIFEST_TAG` and the whole branch behind it. It let the manifest live on a
`stable` pointer release while the bundles sat on a versioned one — a split step
3 removed when `stable` started holding its own bundles. Nothing had passed it
since; a parameter that can only ever receive its own default is a branch nobody
exercises and a comment that goes stale, and its stale text was still telling
readers the installable builds live on the versioned releases.

`desktop/src-tauri/Cargo.toml`'s version and `thoughtsync/__init__.py`'s both
now say out loud that they are not shipped values. The Cargo one carries the
history worth keeping: the old scheme took its base from that line, so `0.2.<run>`
on dev outranked a bare `0.2.0` on main, and the remedy was "remember to bump the
minor before tagging" — documented in a comment, enforced nowhere. #2183 is what
that looked like in the field. **That ritual is now formally dead**, and this is
the deliberate act of killing it rather than a side effect.

## Still there on purpose

`install.sh`'s transitional stable fallback. It cannot go until `main` has
published to `stable` at least once, and that is gated on an operator request.
Removing it now would break the DEFAULT install channel.

#3147

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:43:21 -04:00
bvandeusenandClaude Opus 5 fa43c2f4e9 ci: a docs-only merge to main produced no image, so no :<sha> for that commit
CI & Build / Build now, or wait for Android? (push) Successful in 3s
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 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
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 12s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Successful in 17s
Rule 145 promises every push to `main` publishes a `:<sha>`, so any production
commit is addressable without a release ceremony. `ci.yml`'s `paths:` filter
quietly broke that: a commit touching only docs never triggered the lane, so
that commit had no image and no sha tag.

Pre-existing — the filter has always been there — but it is rule 145's guarantee
and step 6 is where the tag set is being made to match the rule, so it is this
step's to close.

Confirmed live on a0c789b: a docs-only push produced two runs, both client lanes
skipping correctly, and NO image at all.

The server image now always builds. It is the cheap one — ~15 seconds against 6
and 9 minutes for the clients, which is exactly why they skip and it does not —
and always building is what keeps `python:3.12-slim` fresh on something that can
face the internet. That is also why §4's base-image tension does not bite this
project: the artifact it would apply to is the one that never skips.

#3146

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:32:27 -04:00
bvandeusenandClaude Opus 5 a0c789b3ba docs: the image tag list said something step 6 stopped being true
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
`:<git-sha>` is on `main` only now — a sha tag per dev push was a rollback
target nobody had ever pulled — and `:<version>` never existed as an image tag
after rule 145 was narrowed. Both were still documented.

`docs/android-distribution.md` also said `:dev`, `:latest` and `:<version>` all
ship a client, which is now two-thirds true and misses the more useful fact: the
channel IS the image you run, so a stable server serves a stable client. Worth
saying because until step 3 it was hard-wired to the dev release on every branch
and did the opposite.

This push is also the skip-if-exists verification. It touches neither client's
file set, so both `decide` jobs should report the channel already serving the
current version and skip a 6- and a 9-minute build — while the guard still runs
on that path (§6.3).

#3146

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:27:43 -04:00
bvandeusenandClaude Opus 5 22a9a279b1 ci: one definition of what ships decides both the version and whether to build
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (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 3s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 10s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m5s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m24s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m12s
Step 6 of M314. Two changes that only make sense together.

## The image tag set rule 145 mandates

  dev push   -> :dev
  main push  -> :latest + :<sha>
  a v* tag   -> nothing; the trigger is gone

`:<sha>` was going out on EVERY branch — a rollback target nobody has ever
pulled, accumulating forever, for a channel whose entire contract is that it
moves. It is on main only now, where rollback matters and where gated merges
(rule 2) make it dozens per year rather than one per push.

No version-shaped image tag in any lane. Verified the way rule 145 asks — by
looking for a CONSUMER, not for whether one is imaginable: `docker-compose.yml`
is parameterised for a pin and the docs describe the option, but no compose
file, deploy script or CI job reads one.

## Skip-if-exists, adapted, because §4 assumes a registry §5 removed

Note 3127 §4 says to ask the registry whether that exact version exists. There
is no `:<version>` tag to ask about any more. What there IS, for both clients,
is a channel that publishes the version it serves — and that answers the same
question: if the channel already serves what this source derives, the artifact
would be byte-identical.

So the `paths:` filters are gone from the desktop and Android lanes, replaced
by a `decide` job reading the real file set. That duplication is not
theoretical: `packaging/` was added to the sets and not to the filters, so the
commit that fixed a derivation bug never ran on the two lanes it fixed
(85ead4d). One definition, one reader.

The cost is that both workflows now start on every push rather than a matching
one — a ~15s container for a decision, against a lane that cannot silently fail
to run.

## The server always builds, deliberately

Its image is ~15 seconds against 6 and 9 minutes for the clients, so there is
little to save. And always building is strictly BETTER for something that can
face the internet: it picks up `python:3.12-slim` base updates on every push.

That also dissolves §4's base-image tension for this project rather than
deciding it — the artifact most exposed to base staleness is the one that never
skips. Resolving a base digest at derive time was the alternative and it is
forbidden: §7's corollary bars an external lookup, because two lanes would then
derive different values for one source.

## The guard runs on the skip path

It moved into `decide`, ahead of the decision. §6.3 is explicit that skipping
because "this version already exists" is indistinguishable from "we derived a
stale value that happens to match" unless something checks. It also now runs
once per lane instead of once per job.

## Two defects found while wiring this

`ci.yml`'s gate greps a path list that MUST match Android's file set, and
`packaging/` was missing from it. A packaging-only push would have had the
Android lane build and dispatch while the gate ALSO let the image through —
two images for one commit, and on main a second push of the same `:<sha>` with
different bytes. Rule 145's exact prohibition.

`guard-forward.sh` ends every fetch in `|| true`, so a runner image without
curl would have read as "nothing published yet" and passed without checking
anything. Missing curl is now fatal.

#3146

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:19:16 -04:00
bvandeusenandClaude Opus 5 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>
2026-08-28 21:33:00 -04:00
bvandeusenandClaude Opus 5 6e891357ff ci: the deriver is in the file sets but was not in the path filters
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m58s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m23s
Desktop (Tauri) / Update manifest (push) Successful in 6s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 22s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m59s
`85ead4d` changed `packaging/version.sh` — the script that decides what every
artifact claims to be — and the desktop and Android lanes did not run at all.
Only CI & Build fired, and only because it happens to watch `tests/**`.

So the fix in that commit is unverified on exactly the two lanes whose bug it
was fixing.

`version.sh` lists `packaging` in all three file sets; the workflows' `paths:`
filters did not. Two places holding one decision, with one of them updated —
the failure this subsystem keeps producing (#2181-2183, and again in step 3
where `install.sh` still expected stable's bundles on a versioned release).

The script's own header already warned about this: "a change here that is not
mirrored there means a lane that does not fire — check both." Written, then
not followed, in the same commit.

Step 6 removes the duplication for real by replacing these filters with
skip-if-exists. This is the stopgap until then, and it says so at each site.

#3144

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:11:26 -04:00
bvandeusenandClaude Opus 5 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 c504433, two root causes, and the interesting one is that
`git log -- <paths>` resolves pathspecs against the CURRENT DIRECTORY.

Callers run from wherever suits them: the desktop build from
`desktop/src-tauri`, the Android build from `android`, the manifest job from
the root. So one push produced THREE versions:

  desktop build      1.0.3494522     <- six days stale
  pacman packager    1.0.3502131
  manifest job       1.0.3502131

The build's pathspec had matched `desktop/src-tauri/Cargo.toml` — a real file
— so git answered with the newest commit touching THAT. Non-empty, so the
shallow-clone guard could not fire; the manifest then found no bundle matching
its own answer and the lane went red two steps from the cause. The Android job
failed loudly in the same run only because ITS pathspec happened to match
nothing from `android/`. Same bug, luckier symptom.

The script `cd`s to `git rev-parse --show-toplevel` before doing anything now,
and the test asserts every artifact answers identically from four directories.

## And a third instance of the trap that bit yesterday

The unit test caught it: `version.sh display nope` printed "unknown artifact"
to stderr and then answered `2026.08.28.0900` with exit 0. `paths_for` is
reached through `$(paths_for "$1")`, so its `exit 2` ended the subshell,
returned an EMPTY pathspec — and an empty pathspec matches everything.

That is now three occurrences of one mistake in one file: the shallow-clone
guard on `key` (emitted `1.0.-26297280`, exit 0), the same guard on `display`
(which failed only because `date` then choked on the empty string), and this.
Each was found by a different mechanism and none by reading the code. The
artifact is validated in the parent shell now, and the file says so where the
next guard would be written.

Both tests assert on STDOUT as well as the exit code. The exit code alone
passed for `display nope` while stdout carried a lie.

#3144

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:03:04 -04:00
bvandeusenandClaude Opus 5 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>
2026-08-28 20:51:39 -04:00
bvandeusenandClaude Opus 5 c268ae4f23 ci: main publishes, so a tag stops being required — and :latest stops shipping a dev client
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 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 17s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m36s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m24s
Step 3 of M314. Note 3127 §0's diagnostic is "is `main` publishing
sufficient for a user to receive the build" — and here it was not. The
desktop and Android lanes BUILT on main and published nothing: `Publish
release` was gated on `refs/tags/v*`, the channel publishes on
`refs/heads/dev`, the manifest job on dev-or-tag. So the stable channel
moved only when somebody cut a tag, which made a `v*` tag load-bearing
rather than the optional bookmark the model wants.

Both channels are rolling fixed-tag releases now. `dev` from dev, `stable`
from main, same machinery — `publish-release.sh` already took RELEASE_TAG,
`write-manifest.sh` already pruned, and both already PATCHed a stale
description on 409 (#2182). This is wiring, not new mechanism.

## The defect this carried

`ci.yml`'s "Fetch the Android client to bake in" read
`releases/download/dev` UNCONDITIONALLY, on every branch. Every image baked
in the dev APK — `:latest` included — so a stable server served a
dev-channel client to anyone who downloaded it from there. That has nothing
to do with versioning; it is fixed here because this is the step that
finally gives `stable` an APK to point at.

It also means Android needs no channel machinery of its own. The APK is
served FROM the image, so the channel is already a property of which image
you run — note 3127 §7's "nothing to hand off" shape, arrived at here by
accident. One branch-conditional line, not a second channel in
`client_dist.py` as this milestone first assumed.

## The break this nearly shipped

`install.sh --channel stable` read the version out of `stable/latest.json`
and then fetched `releases/tags/v<version>` for the bundles — correct while
stable was a manifest-only pointer, and broken the moment stable holds its
own. Stable is the DEFAULT channel, so `curl … | sh` would have failed for
everyone between this commit and the first merge to main.

Both channels are one lookup now: fetch the fixed-tag release, install what
is on it. A transitional fallback covers the window where `stable` still
has no bundles, marked for deletion in step 7 — without it the default
channel is broken for however long it takes to merge, and that window is
gated on an operator request rather than on this lane.

## The two writers problem

`stable`'s manifest was written by tag builds. It is written by main now,
and the tag path stops writing it — two writers for one channel is a race
with no winner worth having. A `v*` tag still writes its own versioned
manifest; its build consequence goes entirely in step 7.

Also corrected: `update.rs`'s header still described stable as following
`v*` tags. Nothing in that file moved — it only ever read
`<channel>/latest.json` — but the comment was a lie, and it is the file
somebody reads to understand the feed.

#3143

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 18:22:31 -04:00
bvandeusenandClaude Opus 5 b7e0e5dbba ffi: two items: lines left at the indent of the field above them
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m56s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m24s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m33s
`cargo fmt --check`. Deleting `color:` from these two NoteDraft literals left
the line after it one level too deep — the sort of thing a formatter exists to
catch and an eye does not.

#3041

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

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

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

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

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

#3041

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

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

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

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

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

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

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

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

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

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

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

#3041

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:07:03 -04:00
bvandeusenandClaude Opus 5 13a88179b8 tags: one ink, chip and inline, and the chip edge solved for 3:1
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 5s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m31s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m1s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 8m45s
Step 1 left one card surface per theme, so the tag ink is no longer
choosing a value that has to clear twenty backgrounds. Re-measured against
the one it actually lands on, the two tables collapse into one.

`-800` in light, `-300` in dark, for a `#tag` in the prose AND for a chip's
text. Dark needed no decision at all — the two tables already held the same
value for all ten hues, which is most of the argument on its own. Light
collapses onto the INLINE column deliberately: since M311 a tag whose text
is in the body is drawn where it was typed and not repeated as a chip, so
inline is the common case and this leaves what is seen most exactly as it
was. The chip is strictly better for the move:

                    inline, on the card    as a chip, on its own fill
  light `-800`      7.09 - 15.13           6.37 - 12.01  (was 4.52 - 8.23)
  dark  `-300`      9.45 - 14.23           8.23 - 11.88  (unchanged)

The chip edge goes 0.60 -> 0.65, and this is the first time that number
could be solved rather than judged. 0.60 was picked against a chip sitting
on a card of its own colour, a case that no longer exists; against a known
fill the smallest alpha clearing the 3:1 of WCAG 1.4.11 for all ten hues is
arithmetic. 0.60 gives 2.75-3.82 and misses for six of them, 0.65 gives
3.03-4.36 and misses for none. Dark runs 4.52-5.76.

That edge is doing more work than it looks: a chip's fill measures 1.02-1.26
against the card in light and 1.02-1.73 in dark, and dark red at 1.02 is
invisible. The ring is the pill; the fill only tints it.

`LABEL_CHIP_CLASSES` becomes `LABEL_CHIP_SHELL` — fill and edge, no ink —
and `labelChipClasses(label)` composes shell and ink in one place. The board
and the editor each had their own copy of that composition, with a comment
on one of them asking the other to stay in step. Now it is one call.

Fixed on the way past: the web drew `default`'s chip ring at `black/10`
(1.36 against its own fill) where Compose derived it from the ink (3.21) —
the same chip, visibly different pills. Both are the ink at 65% now.

`chipForeground` stays, narrowed to what it always actually was: the
REMINDER pill's ink, transcribed from NoteCard.vue's literal red-700 /
neutral-600. It is not a tag and must not move with one.

Also gone: `NOTE_NODE_FILL`, a per-hue table of solid hexes for graph nodes
with no consumer anywhere in the repo.

Step 2 of M315. #3149

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:13:43 -04:00
bvandeusenandClaude Opus 5 b91091caca cards: one neutral surface, and the generated fill deleted with it
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 11s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m54s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m15s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m59s
A card's fill stops being a function of the note. One neutral per theme on
all three surfaces — white in light, neutral-900 in dark, which is what the
palette's `default` always was and what both editors already used, so this
is a collapse onto a surface everything already had rather than a colour
anybody has to like.

Measured, against "not the same color as their background but close to it":
card vs board 1.04 light / 1.10 dark, edge vs card 1.98 / 1.73, body text
17.93 / 17.17, muted 10.37 / 14.23. The fill is deliberately the weakest
number on the card — the edge and the shadow separate it from the board, so
a fill that separated on its own would make it a panel.

Deleted, since the card was their only consumer: `derivedFill` / `hslHex`
and the level tables in colors.ts, `derivedFillArgb` / `hslToArgb` in
DerivedTint.kt, `NOTE_CARD_CLASSES_STRONG`, `chosenNoteColor`,
`noteCardClasses`, `noteTintVars`, the `.note-tint` rule in style.css,
`chosenBackground` / `tintable` / `noteTintFor` / `noteCardColor` /
`noteIsStrong` / `firstLabelColor` in NoteTint.kt, and
`resolvedNoteColor` / `noteColorIsChosen`.

`tintHash` and `derivedTint` STAY, against the plan: a label with no colour
of its own still derives one from its name, and that path was never the one
that failed. The mirrored pair and its fixture survive intact.

The editor follows the card, and its Done button takes the brand — the
board's compose FAB is the app's existing statement of "affirmative action
here", where Material's default secondaryContainer is a baseline colour this
theme never sets.

The colour picker is left in place, doing nothing, for exactly one step:
removing it here would leave `note.color` written by nothing and read by
nothing, which is a worse intermediate than a control that visibly does
nothing. #3041 takes the field and the picker together.

Step 1 of M315. #3148

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:55:51 -04:00
bvandeusenandClaude Opus 5 f50204a98b editor: detekt counts returns, so the promotion guards collapse into one
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 5s
CI & Build / TypeScript typecheck (push) Successful in 9s
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m0s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m0s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m3s
`promotingTasks` had four returns against ReturnCount's limit of two — three of
them the same `return this`. Collapsed into a null-or-task guard and a
`changed` flag, which says the contract more plainly anyway: the list comes
back untouched unless something was actually promoted.

Mirrored in blocks.ts even though nothing lints it there. The two files are
kept line-by-line alike on purpose, and letting them drift on shape is how the
next person stops trusting that reading one tells you the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:59:15 -04:00
bvandeusenandClaude Opus 5 1a49ae7ea9 editor: a - [ ] typed by hand becomes a real item when you leave the line
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m15s
Android / Kotlin + Rust (APK) (push) Failing after 5m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m31s
Desktop (Tauri) / Update manifest (push) Successful in 4s
`splitBlocks` runs once, when the editor opens. After that the blocks ARE the
state and nothing reads the body again — every edit travels the other way,
through `joinBlocks`. So a marker typed by hand stayed literal text on screen
until the note was closed and reopened, even though it was already a real item
in storage and the card was already drawing a checkbox for it. The editor was
the only place that disagreed with itself. (#3024)

On BLUR, and only the block being left. There is no good moment to convert
while someone is typing: re-splitting on a keystroke moves the caret out of the
word being written, and converting the instant `- [ ]` is complete does it
before the item has any text. Blur is the one moment the person has
demonstrably finished with the block.

`promotingTasks` / `promoteTasks` return the SAME list when there was nothing
to promote, and both call sites compare by identity. Without that, every blur
would re-key every field below it — including the blur that fires on first
composition, before a field has ever held focus.

Both surfaces in one commit, deliberately: blocks.ts is a line-by-line mirror
of EditorBlock.kt, and the reason that mirror is worth keeping is that the two
editors behave identically. Fixing one would spend its whole value.

Non-canonical markers (`- [X]`, an odd bullet) come back canonical — the only
case where this changes the body rather than just how it is drawn, and exactly
what reopening the note already did.

No unit test: `splitBlocks` reaches the core over uniffi for the grammar, so it
needs the native library and cannot run in the JVM lane. No existing Android
test touches the core for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:50:43 -04:00
bvandeusenandClaude Opus 5 e7af7a4b77 board: the FAB and the undo snackbar rode behind the keyboard
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m45s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m48s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 8m57s
Found by the Scaffold audit #2951 asked for. Three Scaffolds exist; the editor
and the sync screen both consume the IME inset, and the board consumed nothing.

`enableEdgeToEdge()` makes the manifest's `adjustResize` a no-op on API 30+, so
nothing resizes for the keyboard unless the app asks — and
`ScaffoldDefaults.contentWindowInsets` is systemBars, which the IME is not part
of. The Scaffold positions the FAB and the snackbar host from that value, so
with the search field focused both sat under the keyboard.

Not theoretical, and newly load-bearing: `3f0eef1` put an UNDO on the trash
snackbar, so the one control you could not reach was the one that takes back a
note you did not mean to throw away — reachable by searching, long-pressing a
hit and trashing it.

`union` rather than `add`: the navigation bar and the IME are the same edge,
not two stacked ones, and adding them would inset twice under a keyboard that
already covers the nav bar. Set once on the Scaffold rather than per-slot, so
the content column shrinks with it and the board's cards stay above the
keyboard instead of scrolling under it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:55:23 -04:00
bvandeusenandClaude Opus 5 396e91e609 board: ktlint on the long-press menu — a named modifier, three dead imports
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m49s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m0s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 7m55s
Two failures on `3f0eef1`, both ktlint, both mine.

`chain-method-continuation`: a multiline element in a Modifier chain wants the
next `.` glued to its closing paren — `).background(…)`. Every other multiline
chain element in this codebase happens to be LAST in its chain, so nothing had
exercised the rule before. `combinedClickable` is now a named `opening`
modifier applied with `.then(…)`, which keeps the chain single-line per element
and reads better than the shape ktlint was asking for.

`no-unused-imports`: lifting the delete-forever dialog into Panel.kt took the
last use of `Text`, `stringResource` and `R` out of NoteEditorScreen.kt with
it. I had checked AlertDialog and TextButton and stopped there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 07:49:57 -04:00
bvandeusenandClaude Opus 5 3f0eef145b board: a long press on a card does what the editor's overflow does
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m9s
Android / Kotlin + Rust (APK) (push) Failing after 4m43s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m39s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Trash existed on Android and was three interactions deep — open the note,
tap the overflow, Move to trash — with nothing at all on the board itself.
The operator's read of that was not "the actions are in the editor"; it was
"there are no long hold context menus in the app I have no way to delete
notes." (#2946)

The card now takes `combinedClickable` and raises a DropdownMenu holding the
same items as the editor's overflow, in the same words, from the same string
resources, dispatching the same `EditorAction`s through the same
`BoardViewModel.onEditorAction`. A note has one vocabulary of things you can
do to it, and reusing the exhaustive dispatcher means the board cannot grow a
parallel one that drifts.

Gated on `note.trashed` rather than on the board's destination — the same
reading the editor uses for read-only, and the only one that survives
Reminders and search, which both mix piles.

Trash gets an UNDO snackbar rather than a confirmation. A long press is a
gesture you can make by accident, so the mistake worth designing for is the
one nobody meant to make, and a dialog only helps someone paying attention in
the moment they were not. Delete forever keeps its dialog; that one does not
undo.

`MenuItem` and the delete-forever dialog move to Panel.kt now that two
surfaces raise them, so there is one place for the close-before-acting order
and one wording of the consequences.

Colour is deliberately not in this menu, though #2946 suggested it:
`note.color` and its picker come out in #3041, so a swatch row here would be
building the one control already known to be leaving.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 20:01:32 -04:00
bvandeusenandClaude Opus 5 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>
2026-08-26 19:54:26 -04:00
bvandeusenandClaude Opus 5 606e345580 Fix the rename that renamed itself
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 16s
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 28s
`sed s/_reconcile_tags/_lift_and_reconcile_tags/` ran over tags.py after the
new function was already written with the new name, so the definition became
`_lift_and_lift_and_reconcile_tags` while all 15 call sites were correct.
Twelve test modules failed to import.

The check that should have caught it is the reason it got through: the
verification grep piped output through `sed 's/:.*_lift/: _lift/'`, which
trims to the LAST `_lift` and therefore prints a doubled name identically to
a correct one. A filter that can only make wrong output look right is worse
than no filter.

Same sed also clobbered the docstring's historical reference — it read "it
used to be `_lift_and_reconcile_tags`", naming the function after itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:48:50 -04:00
bvandeusenandClaude Opus 5 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>
2026-08-26 19:44:43 -04:00
bvandeusenandClaude Opus 5 23fd2da91e The tag goes at the top of the card, where it gets looked at
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 12s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m58s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m21s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m6s
Label chips sat under the body, the checklist, the attachments and the link
previews. On a tall note that puts the one thing saying what a note IS below
the fold of a glance — and a board is scanned, not read. "Which of these is
about the thing I am looking for" should be the first thing the eye lands on.

Above the body rather than beside it: the body's first line is the note's
NAME (M13 steps 3 and 4), and a chip floated next to it would compete with
the thing that identifies the note. A row of its own costs one line, and only
on notes that carry tags.

Both surfaces, same order. Does not depend on tag lifting, which is a much
larger change — see the task.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:17:32 -04:00
bvandeusenandClaude Opus 5 3d490bb6f3 HSL lightness is not luminance — the dark floor was too low
The unit test I added with the generated fills failed on its first run, on
exactly the claim it was written to check, so it earned its keep immediately.

The floor was 0.090 — `neutral-900`'s own HSL lightness — reasoning that a
ramp starting at the card surface and climbing could not end up below it.
That confuses HSL lightness with luminance. At one fixed lightness the eye
sees very different brightnesses by hue, because green carries 71% of the
luminance formula and blue only 7%: at L=0.090 a yellow measures 0.0118 and a
blue 0.0061. Every blue-ish untagged note was 1.41x DARKER than the card it
was supposed to match, which on the board reads as a hole rather than as
variety — the opposite of what the whole change is for.

Solved rather than nudged: 0.113 is the lowest floor at which EVERY hue
clears the card surface. The range now measures 1.11-1.71 against the board
against the old 1.06-1.54, so the floor is back where the shipped ramp had it
and the ceiling is higher. Body text 7.8 against the 4.5 it needs, meta 4.6
against 3.0. 338 distinct dark fills.

Two things about the test are worth keeping.

It asserts on LUMINANCE rather than on the lightness that was put in — a test
of the input would have agreed with the bug and passed.

And it now sweeps 40,000 ids rather than 500. The worst case is a HUE, not an
id, and 500 ids reach only 459 of the 2160 hue/level combinations — it caught
this one by luck. 40,000 covers all 2160.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:17:32 -04:00
bvandeusenandClaude Opus 5 86f1e4a08f detekt: sector indices as a table, not a when
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m44s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m52s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Failing after 5m23s
MagicNumber's ignore list is -1/0/1/2, so the `3 ->` and `4 ->` branch
labels were findings. A lookup table has no literals to flag, and it is the
form colors.ts already uses — the two now read as the same function rather
than as two people's idea of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:08:20 -04:00
bvandeusenandClaude Opus 5 c255b170d4 ktlint: a stray blank line from the append
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m48s
Android / Kotlin + Rust (APK) (push) Failing after 4m18s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m49s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:01:40 -04:00
bvandeusenandClaude Opus 5 1d3cc7bcd4 Nine tints that looked like three — generate the fill instead
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 20s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m54s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Failing after 4m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m13s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Measured, the nine dark subdued fills were separated from each other by at
most a 1.03 contrast ratio. That is not "subtle", it is identical, and it is
why a board of them reads as one card repeated: "I only see 3 colors ... it
looks like a monolithic wall."

Two causes, and the second one is mine.

  NINE IS TOO FEW. The palette exists to say WHICH TAG. An untagged note's
  fill says nothing at all — it only has to keep the board from repeating.
  Those are different jobs and tying them together capped the second at nine
  values for a board that will hold hundreds.

  ONE AXIS IS TOO FEW. The subdued ramp varied hue while pinning every fill
  to the same lightness — deliberately, so each would read as a card against
  the board. But the eye separates by lightness first, so nine hues at one
  lightness are one card nine times. Hue alone was never going to carry it at
  that darkness.

So an untagged note's fill is now generated from its id rather than looked up:
hue anywhere on the circle, one of six lightness levels, saturation fixed.
324 distinct fills in dark and 193 in light, against nine. Separation between
fills goes from a 1.03 ceiling to 1.42.

Varying lightness is only SAFE because the card has its own grey edge now.
While the fill was the card's only boundary it could not afford to drift
toward the board; the edge bought that freedom, one commit before it was
needed.

Saturation is the one dial the hash never touches — variety comes from hue and
lightness, loudness would come from saturation. Dark starts a hair under
`neutral-900` and climbs, so no note is ever darker than a plain card. Light
runs from white down past the board. Body text measures 8.7 at worst against
the 4.5 it needs; the meta row 5.1 against 3.0.

DOUBLE, NOT FLOAT, on the Kotlin side. JavaScript has one number type and it
is binary64; a Kotlin Float is binary32, so the two would round differently
near a channel boundary and a note would be one byte off between the phone and
the browser. Nobody would ever file that — they would see two colours that are
"sort of the same" and never work out why.

The web half cannot be executed here at all (no node on this machine), so the
Kotlin fixture test is the only place the two implementations are ever
compared. It now pins eight generated values as well as the hash, plus the
properties that actually matter: that lightness varies, that nothing sinks
below the card surface, and that body text stays clear of AA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:55:50 -04:00
bvandeusenandClaude Opus 5 ae2053d2ed Give the cards an edge again — one grey, not ten hues
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 9s
CI & Build / Python lint (push) Successful in 10s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 20s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m59s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m39s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m20s
The border was never the problem; a border that carried COLOUR was. It said
exactly what the fill already said, at 1.56-2.09 against that fill where the
fill managed 1.03-1.05 against the board — the loudest element on every card
was redundant with the quietest. A line that varies by colour is content and
competes with the fill. A line that never varies is structure and does not.

So the edge comes back, and it comes back as a constant in NoteCard rather
than a column in the palette. Uniformity is the feature, and putting it where
the palette cannot reach it is how that stays true.

  light  #b8b8b8    1.57-1.98 against all twenty card fills
  dark   #404040    1.58-1.73

Matched, not eyeballed: both land at ~1.6-1.7 against the card they edge, so
the edge reads with the same authority in either theme. Dark is `neutral-700`
— what the `default` card's border always was, one entry's value promoted to
the rule for all of them. Light sits between `neutral-300` and `neutral-400`
because neither lands in range: 300 fades to 1.18 on a gray-tagged card, 400
jumps to 2.52 and reads as a wireframe.

Rejected on measurement: a translucent black/white edge, which is the tidier
way to write it and self-adjusts per card. A border composites over the
card's own fill, so `border-white/20` comes out #56396d on a purple card and
#a3c9c1 on a teal one. Hue-coded edges are the thing being removed.

The shadow steps back to what it was for — depth, not the boundary. Web
returns to `shadow-sm`; Android's 2dp drops to 1dp, matching it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 15:00:22 -04:00
bvandeusenandClaude Opus 5 47f108c9c8 The border was the thing making every note look the same
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 9s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m23s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m4s
A note card carried a 1px tint border. Measured against its own fill, that
line was a 1.56-2.09 contrast in dark mode while the fill managed only
1.03-1.05 against the board — so the loudest thing on every card was an
identical line in an identical place, and a field of them read as a grid of
outlined rectangles however different the colours inside were.

Removed from the note card on both surfaces. `border` survives for panels,
banners, the update card and the pickers: those are single elements, not a
field of them.

What replaces it differs by theme, because elevation does.

  Light leans on a shadow. An untagged card is `bg-red-50` on a `neutral-50`
  board — a 1.04 contrast that can only read as a card by sitting above one.
  The web goes `shadow-sm` -> `shadow`; Android had no shadow at all and gets
  2dp.

  Dark cannot use one, black on near-black. So the subdued fills moved onto
  the card surface instead: `{hue}-950` composited at 0.18 over #171717 and
  baked, rather than the same hue at 0.25 over the near-black board. An
  untagged card now sits where the plain white card always sat (1.11-1.14
  against the board, against `bg-neutral-900`'s 1.10) while carrying LESS hue
  than before — chroma 7-17 where the old ramp had 10-23.

Subtler and more visible at once, which is only a contradiction if subtlety
has to come from lightness. Here it comes from chroma, and lightness is left
to say "this is a card". Which also reframes the two weights: in dark they
now sit within a hair of each other (red: 1.11 vs 1.12) and differ threefold
in colour (chroma 10 vs 41).

The chosen ramp is untouched — the operator signed those colours off, and a
ramp somebody likes is not something to redo while fixing something else.
Light was already built this way: `-50` and `-100` are both white plus a
different amount of hue.

Body text still measures 14.3-16.4 against the 4.5 it needs, meta 6.9-7.1
against 3.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:21:42 -04:00
bvandeusenandClaude Opus 5 fe18aaa956 The contrast pass, and the invisible chip it found
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m53s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m30s
Step 4 of milestone 309. All 40 combinations measured rather than eyeballed —
10 hues x 2 themes x 2 weights, dark ones composited over the board the way
Compose and CSS both do, against the text actually drawn on a card
(neutral-700/300 body, neutral-500/400 meta).

Body text ranges 8.23:1 to 13.01:1 against a 4.5:1 requirement; meta text 4.33
to 7.11 against 3.0. Every combination passes AA with room to spare, so the two
ramps step 3 introduced need no adjustment. That is the boring half.

THE PASS FOUND A REAL REGRESSION. A tagged note takes its first tag's colour and
is drawn at that hue's `-100` — which is exactly what the chip uses as its fill.
Measured contrast between the chip and the card it had itself coloured: 1.00 in
light mode. Perfectly invisible. Dark was 1.04-1.07, invisible in practice. On
every tagged note the tag name had stopped reading as a chip and become loose
text, and nothing about step 3 looked wrong while writing it.

Fixed with an EDGE rather than a different fill. A fill can collide with any card
colour and chasing that would need the chip to know what it is sitting on; a
border in the chip's own foreground reads against any background and needs no
plumbing.

Alpha is 0.60, measured: 2.32:1 at worst, where the 0.30 I first wrote gave 1.49
and was no edge at all. It does not reach WCAG 1.4.11's 3:1, which needs 0.80 and
draws a hard outline instead of a hairline. 1.4.11 governs boundaries carrying
REQUIRED information, and a chip's information is its text — passing AA at 8:1 or
better on every card here. The number and the reasoning are both in the source so
the judgment can be overruled rather than rediscovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 13:37:51 -04:00
bvandeusenandClaude Opus 5 20e9d535de android: two more ktlint rules, both in the code I just added
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m57s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m54s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m57s
`noteIsStrong` had a single-line body expression wrapped onto the next line;
ktlint's function-signature rule wants it on the signature line when it fits.
`firstLabelColor` wrapped a call chain after `note.labels.firstOrNull()`, and
chain-method-continuation wants a newline before EVERY link once one is wrapped.
It reads better as two statements than as a chain, so it is two statements.

Third ktlint round trip on this milestone. I pre-flighted the rules I already
knew and these were not among them — and when I then wrote greps for the two new
rules, they flagged sixteen files that have been passing for months, because my
heuristics do not match what the rules actually check. There is no local ktlint
(rule 10), so CI is the first and only reader; more elaborate greps are not the
fix, and pretending they are would just add false confidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 13:25:28 -04:00
bvandeusenandClaude Opus 5 988e1d3f00 A note's colour is its first tag's colour, at a heavier weight
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 20s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m41s
Android / Kotlin + Rust (APK) (push) Failing after 3m53s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m7s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Four #todo notes on the operator's board in four different colours, because the
tint was derived per-note-id and ignored tags entirely. Now a tagged note wears
its first tag's colour, so notes that share a tag share a look.

TWO WEIGHTS, NOT ONE RAMP. The operator, seeing step 1: "the tints look the same
as the chosen colors". They did — there was only one ramp. `strong` is not a
second decision, it IS whether the colour was chosen: a tag (or, until step 5,
the picker) means somebody said what this note is, while a derived tint only
means the board should not be a wall of white.

The two weights move in OPPOSITE directions per theme, because that is where
each has headroom. The operator asked whether the tint could go lighter instead
of the tagged end going darker; in dark mode that is the better half of the
answer, so the derived end drops to a quarter opacity — closer to the board,
which gives the light body text MORE contrast rather than less. Light mode has
nowhere to go below `-50` without being white again, so there the gap opens by
deepening the chosen end to `-100`.

No hex was transcribed for any of it. `-100` is already in NoteTint.kt as every
hue's `lightChipBackground`, and the dark weights are the existing `-950` fill
re-alphaed, so the only two numbers that have to agree by hand are the alphas.
Copying ten more Tailwind values from memory is exactly how this mirror would
have drifted.

`default` is marked not tintable — it is the ABSENCE of a colour, there is no
emphatic version of it, and re-alphaing its opaque neutral fill would have made
every draft card translucent.

Borders untouched: the fill is the signal, moving both muddies the edge.
Resolution order is explicit pick, then first tag, then the id hash. First tag
because it is the one you control by typing; manual labels count the same as
#tags because nobody can tell which kind they made by looking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 13:15:47 -04:00
bvandeusenandClaude Opus 5 6fbee27f9c A tag with no colour of its own derives one from its name
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 15s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Update manifest (push) Successful in 6s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m10s
Android / Kotlin + Rust (APK) (push) Successful in 7m43s
Every #tag ever typed is `default`. `notes/tags.py` mints one as
`Label(owner_id=…, name=name)` with no colour, so it takes the column default —
which means tag-driven note colour, built on top, would have left the board
exactly as grey as it was. Four #todo notes in the operator's screenshot, four
different colours, because the tint is per-note-id and ignores tags entirely.

DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing the plan in 2965. That plan
wanted a hashed colour written wherever a label is born, and named the risk in
its own body: `find_or_create_label` is "easy to miss, and it is the common one",
because most tags are born from typing `#grocery`, not from a management screen.
Deriving has no mint points to miss, needs no backfill for the tags that already
exist, and reuses the hash and the fixture the notes already have.

The cost is that renaming a tag recolours it. That is defensible — the name IS
the tag — and an explicitly picked colour is still stored and still wins, so tag
colours stay editable exactly as asked.

Lowercased before hashing: tags dedupe case-insensitively, so #Todo and #todo are
one tag and must not be two colours.

All five places a label's colour is drawn now resolve the same way — the card
chip, the editor chip, the drawer's tag list, and the management modal's dot and
swatch ring. The modal's ring follows the resolved colour rather than the stored
one, so opening the picker highlights what you can already see instead of
nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:57:59 -04:00
bvandeusenandClaude Opus 5 cddaf35280 android: ktlint forces a multiline signature at two parameters
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m55s
Android / Kotlin + Rust (APK) (push) Successful in 8m3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m13s
Desktop (Tauri) / Update manifest (push) Successful in 3s
`resolvedNoteColor` and `noteTintFor` are the first non-composable functions
here to take more than one parameter, and ktlint_official's function-signature
rule requires each parameter on its own line once there are two or more. Four
findings on one and four on the other, all the same rule.

Nothing had type-checked: ktlint is step 6 and the unit tests are step 8, so the
fixture pinning the derived-tint mirror never ran.

I checked line width, trailing whitespace and KDoc adjacency before pushing —
the three that have bitten before — and not this one. The list of rules learned
by failing CI is not the list of rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:38:23 -04:00
bvandeusenandClaude Opus 5 6f173b166b Every note carries a tint, derived from its id
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m33s
Android / Kotlin + Rust (APK) (push) Failing after 6m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 7m2s
Desktop (Tauri) / Update manifest (push) Successful in 3s
The board was a wall of white rectangles: `default` is the colour nobody picks,
so it was the colour of every note except the two the operator had coloured by
hand. Reported twice — 2026-08-23 as "a wall of broken up text", and again today
as "all the existing notes are the same dull color".

The ask was "random subdued colors", but random is the one thing it must not be.
A tint rolled at render time would differ between the phone and the browser and
change on every reload. FNV-1a over the note's id is deterministic, identical on
every surface, needs no column and no migration, and a note keeps its colour for
life — which is what "random" meant here.

Two implementations, deliberately mirrored, same discipline as the checklist
grammar. The Kotlin half lives in a Compose-free file so a host-JVM test can pin
the fixture; the TypeScript half carries the same four ids and hashes as a
comment because the frontend has no test runner at all — its whole CI lane is
`vue-tsc --noEmit`. That asymmetry is worth naming rather than papering over.

A draft has no id yet (DRAFT_ID is ""), so it stays white until it is saved.
Hashing the empty string would give every draft one shared tint and then change
it on save anyway — two surprises where one will do.

An explicitly-picked colour still wins. The picker is on its way out (milestone
309 step 5) but it has not gone yet, and a hand-coloured note changing under the
operator would read as data loss.

First of five steps toward colour coming from tags. This one stands alone: no
storage change, nothing removed, and the board stops being white today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:26:01 -04:00
bvandeusen f92a3d0a99 android: detekt's return limit, on two functions I wrote after it caught me once
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m12s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m26s
Desktop (Tauri) / Update manifest (push) Successful in 9s
Android / Kotlin + Rust (APK) (push) Successful in 8m33s
`dev` went red on 1e2b42a and nobody was watching — the CI wait was killed with the
session, so the push was never confirmed. Checked on the way back in.

Both findings are ReturnCount: four exits against a limit of two. The same rule
caught continueChecklist earlier the same day, which is the annoying part — I had
the lesson and wrote two more guard-clause ladders anyway.

checkInBackground becomes a `when`, which it wanted to be regardless: it is four
mutually exclusive situations and one action, and the ladder made that read like a
sequence of unrelated escapes.

onWifi folds its three null checks into one nullable chain. Same behaviour, and the
`caps != null &&` reads as what it is — an uncertain answer being treated as no.
2026-08-26 10:01:24 -04:00
bvandeusen 1e2b42af25 android: say nothing until the update is downloaded, and only fetch on wifi
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m29s
Android / Kotlin + Rust (APK) (push) Failing after 5m39s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m30s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Both corrections to what I built, and the second changes the first.

NAG ONLY WHEN READY. The banner is now gated on the bytes being on disk. I had it
appearing as soon as a build was FOUND, with Install downloading on demand — which
turns one tap into an unplanned download, and is exactly the surprise the wifi gate
was meant to avoid. Off wifi the app now stays quiet and picks it up later.

ONLY ON WIFI, and both halves of that. `isActiveNetworkMetered` alone would download
over an unmetered cellular plan, which is not what "on wifi" means. TRANSPORT_WIFI
alone would download over a tethered hotspot, which is mobile data wearing a
different hat and the precise bill this avoids. It now requires both.

Found while making the first change: gating the nag on `ready` broke the nag. The
background path returns early once a build is fetched, so `nagDismissed` would never
be cleared again and a single "Later" would have silenced the update permanently —
the exact "lost" this whole path exists to prevent. Coming forward with a fetched
build now clears the dismissal instead of returning.

Also: a build found off wifi retries its FETCH on the next foreground rather than
waiting out the six-hour check interval. Found on the train, downloaded at home.

The banner loses its two-state text with the change, and BoardUpdate loses `ready` —
it is implied now. It stays visible while installing, deliberately: that is the one
moment it has something to report, and hiding it would look like the tap did nothing.
2026-08-26 09:53:40 -04:00
bvandeusen a48b034a94 android: my insertion stole downloadTarget's doc comment
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m57s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m58s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m41s
Anchoring the new function on `fun downloadTarget` put it between that function
and its own KDoc — so downloadTarget lost its doc and onUnmeteredNetwork gained a
second one describing something else entirely. ktlint caught both halves.

Anchor on a declaration and you land inside its documentation. Swept the rest of
the tree for the same shape; nothing else.
2026-08-26 08:43:42 -04:00
bvandeusen ee47a61270 android: find updates without being asked, fetch them, then nag
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m14s
Android / Kotlin + Rust (APK) (push) Failing after 4m29s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 5s
`check()` had exactly one caller: a button on the sync screen. So a new build was
found only by someone who went looking for one — and having to remember to go
looking is the same as not being told. The operator has been doing that by hand
every time.

Three parts.

FIND. The app checks when it comes forward, which is the moment the person is
present. Rate-limited to six hours in the view model, so flicking between two apps
is not a re-check, and skipped entirely on an unlinked device — updates come from a
linked server and there is nothing to ask. Same ForegroundTransitions shape as
AutomaticSync, for the same reason.

FETCH. Finding one downloads it, so the nag is a one-tap install rather than the
start of a wait. NOT over mobile data: fifty-odd megabytes is a bill nobody agreed
to, so this is gated on an unmetered connection (new ACCESS_NETWORK_STATE
permission — normal, no prompt). On a metered link the update is still found and
still nags; Install downloads it then, which is a choice rather than a surprise.

NAG. A banner on the board, under the error banners — an update is worth saying and
never worth saying before a note failed to save. "Later" clears it for this sitting
only: the next time the app comes forward the check finds the same build and says so
again. That is the difference between a reminder and a notice you can lose.

downloadAndInstall now skips the download when the background fetch already did it,
so the sync screen's button and the banner's are the same action with the same
name — whether the bytes are already there is this class's problem, not the
person's.
2026-08-26 08:34:28 -04:00
bvandeusen 68f851110f android: checklist rows were still 48dp of touch target
Second pass on the same report. Taking the field's own padding off got rows from
57dp to 48dp and the operator said it was still too big — correctly, because 48dp
was never the field's, it is Material's minimum touch target and every interactive
component gets it.

On a checklist that minimum IS the row height. It is the right floor for a control
somebody has to find on a screen; it is the wrong one for a box that sits in a
predictable column with an identical box directly above and below it, where a near
miss ticks the neighbouring item — visible, and undone by tapping again.

36dp, provided to the row rather than hardcoded into the controls, so the checkbox
and the delete × move together and nothing else in the app is affected.
2026-08-26 08:31:21 -04:00
bvandeusen 96a6f6e691 web: the editor draws the checklist too
CI & Build / integration (push) Successful in 19s
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 6s
CI & Build / Python tests (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 38s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m37s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m34s
Desktop (Tauri) / Update manifest (push) Successful in 3s
2992's other half. The browser was the last surface still showing `- [ ] ` as
markup: cards rendered and ticked checkboxes, the editor did not.

Same shape as Android, deliberately. notes/blocks.ts mirrors EditorBlock.kt —
splitBlocks, joinBlocks, afterEnter, withoutIndex, plusTask — because the two
editors should behave alike and the cheapest way to keep them that way is for the
code to read alike. `body` becomes a computed over the blocks, so every save,
baseline check and draft still reads the one markdown string they always did.

markdown.ts now exports parseTaskLine and renderTaskLine, and parseMarkdown uses
the former. The read view and the editor's block split had been matching the same
grammar through two separate copies of one regex; now they agree by construction.

Two places the web can do better than Compose, and does:

  * Backspace at the start of an empty item removes it. A browser sends a real
    keydown for Backspace; an Android soft keyboard sends an IME delete that never
    surfaces as one, which is why that surface only has Enter-on-empty.
  * Prose fields size to their text — rows="1" plus a scrollHeight fit, which beats
    guessing a row count that is wrong the moment a line wraps.

KNOWN, and the same on both surfaces: typing `- [ ] ` by hand into a prose block
leaves it prose until the note is reopened. Blocks are split when the editor loads,
not re-derived per keystroke — re-splitting mid-type would move the caret. The
toolbar button is the intended path. Converting on blur would fix it and is worth
doing to BOTH editors at once rather than letting them drift.
2026-08-26 07:53:13 -04:00
bvandeusen 44b3bcb2b2 Correct a claim about the operator's data, and the first-row delete
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 12s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / integration (push) Successful in 17s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m39s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m12s
Two things, one of which I got wrong in a place that outlives the session.

**The claim.** Migration 0027's docstring said "The Google Keep import is genuine
content on this instance, not fixtures." That is not true and I had no basis for it.
Note 2916's headline is the opposite — "there is no work that anyone has done that
isn't test data" — and its clause about imports is CONDITIONAL: text arriving from
another app would be real, and any import path has to treat it that way. I read a
rule about how import code must behave as a fact about what is in the database, then
repeated it in a migration that will be read long after anyone remembers this week.

The operator has never run the importer. They did not know it existed.

Nothing about the migration changes. Content-preserving was cheap and is right for
anything that rewrites somebody's text — and it is what the rule will demand the day
an import does happen. Only the reason recorded in the file was wrong, and a wrong
reason in a migration is how a later decision gets made on a false premise.

**The delete.** Removing the FIRST checklist row asked to focus `index - 1`, which is
-1, so nothing took focus and the keyboard stayed up over a list with no cursor in
it. It now focuses whichever row takes the deleted one's place, which also does the
right thing when the deleted row was the only one — `withoutIndex` leaves a fresh
empty block behind, and that block is what gets the caret.

Found by reading the path the operator said they were about to test, rather than by
waiting for them to find it.
2026-08-26 07:44:31 -04:00
bvandeusen a45a44ef11 android: split the block model from the block UI
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m41s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 9m16s
detekt's TooManyFunctions, at exactly the threshold. Worth taking as the signal it
is rather than suppressing: the file held the block MODEL — split a body, join it
back, add an item, drop one — and the COMPOSABLES that draw it, which are two jobs
that happen to share a data class.

EditorBlock.kt keeps the model and is pure: no Compose imports beyond the types it
stores, and testable on its own if it ever earns tests. BlockBody.kt keeps the four
composables.

afterEnter, withoutIndex and nextId become internal, since the UI half calls them
across the file boundary now. That is the one cost of the split and it is small —
same module, same package, and each says why in its doc.
2026-08-26 07:13:27 -04:00
bvandeusen 56264a9220 android: the checklist rows were carrying a form field's padding
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m50s
Android / Kotlin + Rust (APK) (push) Failing after 3m52s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m9s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Reported from the device with a screenshot: six items took up most of a phone
screen. The rows measured ~57dp apart, which is Material's TextField content
padding almost exactly — 16dp above the text, 16dp below, around a 24dp line.

That padding is right for a form field, where it is the difference between a
comfortable target and a fiddly one. On a checklist it IS the row height, so every
item was paying for a hit area the checkbox beside it already provides.

The editor's blocks drop to BasicTextField. Nothing about the "no box" treatment is
lost — PlainTextField exists to strip a container and an indicator, and
BasicTextField never had either, so there is nothing here to drift back into
existence. What it does not supply and BlockField now does: the text colour, which
defaults to Color.Unspecified and draws BLACK (the same default that made the
toolbar invisible in dark mode), the cursor brush for the same reason, and the
placeholder, which becomes a plain Text behind the field.

PlainTextField keeps serving the search box, the label picker and the sync-pairing
form — fields where Material's padding is what you want. Its TextFieldValue
overload went with the change: the editor was its only caller, and every remaining
one passes a String.

Rows are now bound by the 48dp checkbox rather than by the field. If that is still
looser than it should be, the next lever is the touch targets themselves, which
trades against how easy the box is to hit — worth looking at on a device before
spending it.
2026-08-26 07:03:15 -04:00
bvandeusen a88f7c2dd0 core: drop the two helpers the block editor made unnecessary
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m56s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m15s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m30s
`toggle_at` existed to map a tap on `[ ]` inside a plain text field to an item, and
`continuation` to make Return start the next one. The block editor needs neither: a
checkbox is a real Checkbox, so it is tapped rather than located, and Return is the
field's own IME action rather than a shape recognised in a string.

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

The section comment above them described the tap-in-a-text-field problem, which is
no longer the problem this pair solves. Rewritten to say what is actually there:
one function to read a body apart, one to put a line back together, and between them
Kotlin renders checkboxes without owning the grammar.
2026-08-24 10:42:26 -04:00
bvandeusen 32ec29fc4a android: the comment pointed at the file's old name
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m48s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m48s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m44s
2026-08-24 10:32:55 -04:00
bvandeusen ae17b8a8e7 android: name the file after the type in it
Android / Kotlin + Rust (APK) (push) Canceled after 7s
Desktop (Tauri) / Tauri desktop (Linux) (push) Canceled after 7s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Canceled after 7s
Desktop (Tauri) / Update manifest (push) Canceled after 0s
detekt's MatchingDeclarationName: a file whose only top-level type is EditorBlock
has to be EditorBlock.kt. The plural read better as 'the blocks and the machinery
around them', but the rule is about the type, and the convention here already works
that way — NoteCard.kt holds NoteCard plus its helpers.
2026-08-24 10:32:45 -04:00
bvandeusen eeca4d48c2 android: a trailing blank line where the dead helpers used to be
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m49s
Android / Kotlin + Rust (APK) (push) Failing after 4m20s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m49s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Removing MIN_BODY_LINES took its declaration but left the newline in front of it,
so the file ended with a blank line. My pre-push sweep only looked for consecutive
blanks INSIDE a file and could not see one at the end — checked across the whole
Kotlin tree this time, not just the files I touched.
2026-08-24 10:23:59 -04:00
bvandeusen b2435d97b6 android: the editor draws the checklist instead of the markup for one
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m30s
Android / Kotlin + Rust (APK) (push) Failing after 4m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m53s
Desktop (Tauri) / Update manifest (push) Successful in 4s
2992. A checklist item is a real Checkbox with its text beside it, so a box can be
ticked while looking at the note — which is what M304 left undone. It changed where
a checklist is STORED and never changed what the editor draws.

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

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

Two things that look like detail and are not:

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

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

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

WANTS A DEVICE PASS, and the focus behaviours are what to look at: return making a
row and landing in it, return twice at the end of a list getting you a paragraph,
and rotation restoring the right field. CI can only prove this compiles.
2026-08-24 10:14:53 -04:00
bvandeusen 9a3c4ec377 android: ticking a box on a card threw the editor open on top of it
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m45s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m18s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m31s
Reported from the device: tapping a checkbox on the board checks it AND opens the
note. The "and" is the tell — both things happened, so this was never a tap landing
on the wrong target.

`mutate` ends with `editing = updated ?: state.editing`. That is right for an editor
action, where the reloaded note refreshes a screen already on display. But
`editing != null` IS "the editor is up" — it is what MainActivity's `when` selects
on — so calling `mutate` from the BOARD, where editing is null, wrote the mutated
note into it and opened the editor as a side effect of saving.

Fixed at `mutate` rather than at the caller, because the caller was not wrong: any
board-initiated mutation would have done this, and toggleItem is simply the first
one to exist. It now refreshes an open editor and cannot open a closed one.

The comment claimed the narrower behaviour all along — "so an open editor shows its
own change" — which is what the code should have been doing and wasn't.
2026-08-24 09:59:00 -04:00
bvandeusen 315c5f19e6 android: detekt caught a callback that never reached the cards
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m18s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m46s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m39s
Not a style finding. `onToggleItem` was added to BoardScreen's signature and read
inside NoteBoard — which is a separate top-level composable, not a nested one, so
the two were never connected. detekt reported it as an unused parameter; the
compiler would have called it an unresolved reference. Neither had run: Kotlin is
compiled at the "Unit tests" step, which is gated behind detekt, so nothing in this
lane had type-checked the Android changes yet.

Threaded properly now, which is what makes ticking a box from the board actually
work rather than merely appear to.

Two more from reading it again with that in mind:

  * `when { item != null -> … onToggleItem(index, …) }` would not have compiled.
    Kotlin does not infer that a non-null item implies a non-null index, so the
    index stayed `Int?` against an `Int` parameter. Both are in the condition now.
  * continueChecklist had six returns against detekt's limit of two. Collapsed to
    one `when`, with the two intermediate values guarded on `typedNewline` —
    `caret - 1` is only a real index once it is known to be the newline just typed.
2026-08-24 08:35:52 -04:00
bvandeusen 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.
2026-08-24 08:26:50 -04:00
bvandeusen 77b1a87712 android: ktlint on the import order and a leftover blank line
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m56s
Android / Kotlin + Rust (APK) (push) Failing after 4m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m55s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Inserting the checklistLines import after Note split Note from NoteLabel, and
removing the checklistOpen state left two blank lines behind it. Both are the
formatter only — the bindings built and the Rust lanes were already green.
2026-08-24 08:26:01 -04:00
bvandeusen 68b2a5dc8d android: a checklist is lines of the note here too
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m22s
Android / Kotlin + Rust (APK) (push) Failing after 4m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m3s
Desktop (Tauri) / Update manifest (push) Successful in 4s
M304 step 6, and the surface with the least room to hide: Android has no markdown
renderer at all, so the card was about to show every list twice — once as literal
`- [ ] milk` in the body preview, and again as the glyph rows underneath. Same bug
the web had, one commit later.

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

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

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

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

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

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

KNOWN GAP, not an oversight: tapping a checkbox inside the EDITOR does nothing yet.
Material3's TextField does not expose onTextLayout, so mapping a tap to a character
offset means either moving the body to BasicTextField or intercepting pointer events
ahead of the field — both real changes to the surface this operator uses most, and
neither verifiable without a device. `checklist_toggle_at` lands here, tested, so
that task is pure UI. Ticking from the board works today.
2026-08-24 08:16:57 -04:00
bvandeusen 3cab054684 web: task lines render as checkboxes where they sit in the note
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 9s
CI & Build / integration (push) Successful in 25s
CI & Build / Build & push image (push) Successful in 41s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m55s
Desktop (Tauri) / Update manifest (push) Successful in 4s
M304 step 5. The server already returns a derived `items` array, so the web kept
working across the last two commits — but it was rendering every list TWICE: once
as literal `- [ ] milk` bullets inside the body, and again as the separate
NoteChecklist block underneath. This is the commit that makes the body the only
place a checklist appears.

markdown.ts gains a `task` block, matched BEFORE the plain bullet — which would
otherwise swallow the marker and leave the brackets showing, the same ordering
reason `code` is matched before emphasis in INLINE_RE. Each item carries its
ordinal across the WHOLE document, because that is what an item's id means
everywhere else now; counting per block would have made the second list's
checkboxes toggle the first list's items.

The card's preview clamp is why that ordinal is safe there: it only ever drops
lines from the end, so a visible item's index is the same whether or not the body
was truncated.

MarkdownText emits a toggle rather than reaching for the store. Ticking a box
rewrites a line of someone's note, and a renderer used in several places should not
be the thing deciding that is allowed — the card passes `toggleable` and wires it,
a read-only render does not and the boxes are inert. Not wrapped in a <label>
either: on a card the text is the note's own words and clicking it opens the note,
so only the box toggles.

In the editor, the toolbar button stops revealing a section and inserts `- [ ] ` at
the caret. That makes it the one toolbar action needing no persisted note to hang
anything off — ensureDraft is gone from it, and it works on an empty compose box
the moment it opens. Enter on a task line continues the list, and on an EMPTY one
clears the marker; without that second half a list would be impossible to get out
of. Indent and bullet are carried over rather than normalised, because continuing
someone's `*` list with a `-` is an edit they did not ask for.

NoteChecklist.vue is deleted (rule 22). The store's item methods stay: they are the
repository seam the REST routes and Tauri commands both implement, not the old path.

CI cannot check any of this beyond types — there are no frontend tests, only
vue-tsc. It wants a real browser pass.
2026-08-24 08:10:20 -04:00
bvandeusen 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.
2026-08-24 08:06:11 -04:00
bvandeusen 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.
2026-08-24 08:03:37 -04:00
bvandeusen 32dafca148 core: what rustfmt actually wanted
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m59s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m14s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m11s
Three from the checker's own diff. Two are the rule I had guessed at: when a
call overflows and its last argument is a closure, rustfmt keeps the earlier
arguments on the line and expands the closure into a block, rather than putting
every argument on its own line.

The third is a stray double blank line before the test module.
2026-08-24 00:49:05 -04:00
bvandeusen 668f7faf03 core: the body is the checklist, and checklist_items is gone
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m30s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m47s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m45s
M304 steps 2 and the client half of 4, together — they cannot be separated. A
commit where the store writes items into the body while push.rs still reads them
from a table is one that silently pushes the wrong list, and dev publishes to the
dev channel on every green build.

Store:
  * load_items becomes items_of(body) — a parse, not a query. An item's id is its
    ORDINAL, which is all it ever amounted to: push.rs sent text and checked and
    never an id, and both sides replaced the whole list on every sync.
  * add_item / update_item / delete_item route through update_note, so they get
    revision snapshotting, #tag re-derivation and the dirty/updated_at bookkeeping
    without any of it being written a second time.
  * create_note folds its items: input into the body, and syncs tags from the
    FOLDED body — an item can carry a #tag too.
  * display_title no longer takes items, because items ARE body lines now. It
    strips the task marker instead: a list-only note is still named by its first
    item, and calling that note "- [ ] milk" would show someone the storage.

Wire: items leave it. A second copy of data already in the body field of the same
message is how the two come to disagree. CLIENT_PROTOCOL_VERSION and
MIN_SERVER_PROTOCOL_VERSION go to 3, which is what makes this safe to land before
the server: a v3 client refuses a v2 server outright rather than pushing a body
whose list the old _apply_note_items would then delete.

Schema v8 folds every existing row into its note's body before dropping the
table. Written in Rust, not SQL: the fold has to produce exactly what
derive::append_item produces, and group_concat only gained a guaranteed ORDER BY
in SQLite 3.44 — a checklist that quietly reordered itself during a migration
would be a poor way to learn that. updated_at and dirty are deliberately left
alone, because the server's migration folds the same rows the same way and both
sides land on identical bodies; marking every note dirty would push a body the
server already has, from every device at once.

NOT deployable yet. The server still speaks v2 and still has note_items, so a
client built from this will refuse to sync until the server half lands.
2026-08-24 00:41:23 -04:00
bvandeusen d0e3e48943 core: rustfmt splits on fn_call_width, not max_width
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m14s
Android / Kotlin + Rust (APK) (push) Successful in 7m19s
Three assertions I had collapsed to one line because they fit inside
max_width=100. rustfmt's fn_call_width is 60 and applies to the ARGUMENT list,
so a call can sit well under the line limit and still be split vertically.
Clippy and the tests were already green; this is the formatter only.
2026-08-24 00:28:08 -04:00
bvandeusen 1045db318b core: derive checklist items from the body, the way tags already are
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m35s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m48s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m17s
First step of M304. Additive on its own — nothing calls this yet — so it can be
read and tested before anything depends on it.

A checklist is currently a TABLE, and a table can only ever render after the
body, because a row has no idea where in the note it belongs. That is why
"inline with the note" is not a styling problem: prose, three checkboxes, then
more prose is not expressible at all today.

derive.rs already owns "structure derived from body text" for #tags and says so
in its module doc. Task lines join it rather than opening a second home for the
same idea. The difference between the two is worth stating and now is: tags
MATERIALISE into label rows because the board queries by label; items
materialise into nothing, because nothing queries them. Their only readers are
the card, the editor, and display_title.

The grammar is fixed here because three languages will implement it —
derive.rs, notes/checklist.py, notes/markdown.ts — and any difference between
two of them is a checklist that changes shape when it syncs. `*` is accepted
since markdown.ts already takes it for a plain bullet, and a rule that allowed
`* item` but not `* [ ] item` would be one nobody could guess. `- [ ]` with
nothing after it parses as an empty item: that is what pressing Enter on a list
leaves behind, and refusing it would make a half-typed list stop being a list.
`- [X]` parses and normalises to lowercase on the first rewrite, so round trips
are stable.

append_item spaces its output exactly as import_export.py:_note_markdown does.
That is not cosmetic — the server migration will fold existing rows into bodies
with the same layout, so an export taken before it and one taken after have to
agree byte for byte.

A stale index is inert rather than fatal: the index comes from a UI that may be
a moment behind the store, and a late tap should do nothing rather than panic.
2026-08-24 00:20:32 -04:00
bvandeusen 65af37d159 android: a checkmark to leave, and asking for a checklist stops writing a blank one
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m23s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m10s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m16s
Two reports from the same device pass.

**The exit is a checkmark.** It shipped as the word "Done" one commit ago, on the
argument that a tick in a NOTES app reads as a checklist item to anyone who has
used one. Overruled by the operator, and the filled treatment is what settles the
objection anyway: a tonal button in the note's own colour is plainly a control,
where a bare glyph beside a checklist would not be. It carries "Done" as its
content description, so the argument survives where it actually mattered — read
aloud.

**Starting a checklist wrote an empty item**, purely so the section would have
something to render. That left a blank row with the always-present add-row beneath
it — two empty fields, and the caret in the lower one. Whether a checklist is
SHOWING is view state, not a row in the store: the toolbar reveals the section and
focuses the add row, and nothing reaches SQLite until an item has words in it.

EditorAction.AddChecklist is gone rather than repurposed (rule 22), which makes the
first real item the action that can create a body-less note — a note named from its
first item, which the core already does.
2026-08-23 23:50:30 -04:00
bvandeusen 8257e1035c android: put the way out of a note back within reach
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m5s
Android / Kotlin + Rust (APK) (push) Canceled after 4m27s
Desktop (Tauri) / Tauri desktop (Linux) (push) Canceled after 4m27s
Desktop (Tauri) / Update manifest (push) Canceled after 0s
Moving the toolbar to the top took the back arrow with it, and left the only
exit from a full-screen editor in the top-left corner — the furthest point on
the display from a right-handed thumb, reached over the whole note to get to.
Reported on the first device pass, and correctly.

So the footer carries a Done as well as the timestamp. With the keyboard up it
sits directly above the thumb, which is where a hand already is for every other
part of writing a note.

The top-left arrow stays. Two affordances for one action is usually clutter,
but this is the case that earns it: the arrow is what habit, the system back
gesture and TalkBack all expect of a full-screen surface, and removing it would
strand the reflex to strike a duplicate costing one icon slot.

A word rather than a checkmark, on the same argument the overflow menu makes: a
tick in a notes app is a checklist item to anyone who has used one, and "Done"
cannot be misread, including aloud.

EditorSavedLine is now EditorFooter, since it is no longer only a line.
2026-08-23 23:46:03 -04:00
bvandeusen bca9e16bd0 android: ktlint wants that body expression on one line
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m26s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m17s
2026-08-23 22:30:56 -04:00
bvandeusen 9ea2a2f9b6 android: the toolbar moves to the top, and the note says when it saved
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m45s
Android / Kotlin + Rust (APK) (push) Failing after 3m50s
Desktop (Tauri) / Tauri desktop (Linux) (push) Canceled after 4m41s
Desktop (Tauri) / Update manifest (push) Canceled after 0s
The capture sheet's drag handle cost a strip of screen and did nothing a back
gesture does not already do. The toolbar takes that strip instead, which is
where it belonged once one surface served both writing and editing: the
keyboard owns the bottom of the display for most of a note's life, so a bar
down there spends its time riding on the IME.

The bottom is now the answer to "did that land". There is no save button —
writes are continuous, so a button offering to do what already happened would
be a lie with a tap attached — but that left nothing on screen saying the work
was safe. Not saved yet → Saving… → Edited just now is the whole lifecycle in
the corner, and someone who watches it once never has to be told that closing
a note keeps it. DateUtils formats the relative part, so plurals and
"yesterday" are not this app's problem to solve twice.

Shape: the screen keeps the sheet's rounded top and its gap below the status
bar, so opening a note still reads as something rising over the board. Full
height rather than a real ModalBottomSheet — a sheet spends a writing session
negotiating with the IME for the bottom half of the display, and the
swipe-down it buys is a gesture back already does.

Both content colours on the card are spelled out. Surface and Scaffold each
default theirs to contentColorFor(their container), which returns Unspecified
for anything that is not a colour-scheme role; a note tint never is. That is
the same default that made the last toolbar invisible in dark mode, latent in
two more places.

Also: the running LinearProgressIndicator is gone, since the corner line now
says the same thing without moving the text; and the SaveText comment in
BoardViewModel still claimed saves happened on close.
2026-08-23 22:26:12 -04:00
bvandeusenandClaude Opus 5 ce6a1093a3 android: writing a note and editing one are the same surface
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m40s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m44s
The + button raised a capture sheet with a single text field. The editor is
a screen with a toolbar. So a note being WRITTEN could not be given a
colour, a reminder or a checklist — those live on the toolbar, and the sheet
had none. To make a checklist you wrote a note, saved it, reopened it, and
found a control you had never seen.

ComposeSheet is deleted. + opens the editor on an unsaved draft.

A draft is a real Note carrying DRAFT_ID (the empty string) rather than a
null. Note has eighteen fields and the editor reads eight of them; threading
nullability through all of that to express "not saved yet" would spread the
concept across a screen that should not have to know about it. A real id is
a uuid, so the sentinel cannot collide.

It becomes a row on its first save, and the first save is now an autosave:
the editor writes a second after typing stops. That is affordable because
2707054 made a body write stop costing a revision — before it, saving this
often would have meant a revision per second.

Autosave is also what makes materialisation work at all. Creating the note
on a toolbar tap instead races: the typed text lives in the field's own
state and only reaches the view model on flush, so the tap would create an
EMPTY note and lose what was written. With a one-second debounce the note
already exists by the time any button is reachable.

Three consequences worth naming:

- editingSession, bumped only when the editor opens on a DIFFERENT note.
  The text field keys on it instead of note.id, because a draft's id changes
  the moment it is first saved and re-keying on that would reset the field
  to whatever the store just returned — discarding everything typed during
  the write.
- The field is rememberSaveable now. A new note has nothing to fall back on,
  and the old sheet used rememberSaveable for exactly this reason; the
  editor inherits the requirement along with the job.
- draftDismissed, so a create still in flight cannot reopen an editor the
  user has already closed.

Starting a checklist may create an empty note — a note named from its first
item is one this app already has. Colour and reminder are attributes OF a
note and need words first.

editor_body_hint becomes "Take a note…". It read "Note", which is a label on
a blank screen where the sheet's was an invitation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 22:14:02 -04:00
bvandeusenandClaude Opus 5 2707054563 A write should not cost a revision
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m21s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 7m42s
Every body change snapshotted into history — core/src/local/store.rs and
notes/__init__.py both — so a write was expensive, and the clients
compensated by writing as rarely as they could. BoardViewModel says it
outright: "Saved on close rather than per keystroke, so a session of typing
costs one write and one revision snapshot."

That is durability paying for version history. An app kill mid-session lost
everything typed, so that the revision list would stay tidy. The safety
property is worth more than the feature it was subsidising, and no
comparable product makes this trade: Keep and Apple Notes write
continuously with no history, Docs and Notion write continuously and
coalesce history behind the scenes, Obsidian debounces and snapshots on an
interval. Save-on-close is the outlier, and this coupling is why we had it.

A body change now earns a snapshot only if it is the first of an editing
session — the body actually differs, and the note carries no revision from
the last ten minutes.

Session granularity falls out of the window rather than being declared. A
snapshot stores the body as it was BEFORE the edit, so the first write of a
sitting captures the note as you found it and every write after it inside
the window adds nothing. One revision per sitting, with no commit flag for
a client to send and no wire surface to carry it.

That is why it is a time rule and not a protocol one. sync.py applies pushed
bodies through the same check, so a client autosaving every second cannot
make the server snapshot every second either — which a client-declared
commit point could not have guaranteed without a protocol bump.

Restoring a revision still snapshots unconditionally: a considered act, not
a keystroke, and it stays undoable.

Unblocks idle-debounced autosave, an honest updated_at, and the "Edited just
now" line the editor is getting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 21:47:01 -04:00
bvandeusenandClaude Opus 5 24685556b7 android: open an existing note ready to keep writing
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m14s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m51s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m19s
Opening a note put no cursor anywhere, so carrying on cost a tap into the
body and usually a second one to drag the caret past the existing text.
The compose sheet has always focused its field on open; the editor never
did, and continuing a note is the more common act of the two.

Focus the body on open, caret at the end. Not for a trashed note — that
renders read-only and a keyboard over a record you cannot edit is noise.
Keyed on note.id so the reused editor re-requests when pointed at a
different note.

The caret position is why the body state moves from String to
TextFieldValue: a String field always starts its selection at offset zero,
so focusing one lands the cursor before the first character — the wrong
end of a note you meant to continue. PlainTextField gains a TextFieldValue
overload for it, and the two overloads share one colours definition rather
than growing a second copy of the "no box" treatment this file exists to
keep in one place.

I recorded this backwards in Scribe 2947 — as the keyboard opening
unwanted, when the report was the opposite. The source having no
FocusRequester was the tell, and I read it as a mystery instead of as
evidence I had the direction wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 21:00:57 -04:00
bvandeusenandClaude Opus 5 50e2d308ea android: the editor toolbar was black icons on a near-black bar
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m11s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m38s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 7m54s
Reported as "I'm unable to see a toolbar in the editor on android, is
there one?" — and it was rendering the whole time.

EditorBottomBar passed containerColor but no contentColor, so Material3
defaulted it to contentColorFor(containerColor). That maps a colour-SCHEME
ROLE to its `on-` pair and returns Color.Unspecified for anything else. A
note tint is never a role: the default note is 0xFF171717 while the dark
scheme's surface is 0xFF0A0A0A. So contentColor resolved to Unspecified,
Surface published it as LocalContentColor, Icon took it as its tint, and an
unspecified tint applies no colour filter — leaving the icons-core vectors
their intrinsic black, on a near-black bar.

Every note colour, both themes, only visible in dark. The top bar escaped
it because topAppBarColors(containerColor = …) overrides the container and
leaves the icon colours at their scheme defaults.

Also inset the bar for the keyboard. enableEdgeToEdge makes the manifest's
adjustResize a no-op and Scaffold does not inset its bottomBar slot, so the
bar would sit under the IME the moment anyone typed — a second way to not
see it. imePadding moves to the bar; the content Column drops its own, since
Scaffold now measures the bar at its lifted height and the inset reaches the
content through innerPadding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 17:40:28 -04:00
bvandeusenandClaude Opus 5 77c5422951 ci: a failing lane must not publish an image
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 9s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Successful in 16s
build gated on lint + typecheck only, so run 4293 failed its test lane and
pushed :dev and :09b5f87 regardless — the deployed server was running a
build whose tests were red.

The comment justified this by saying DB-backed testing happened manually
against the dev image rather than on every push. That was true when it was
written and stopped being true at 6f21db8, which added the integration
lane. The reason went away; the exception didn't.

Gate on test and integration too. A :<sha> image is the rollback unit for
its commit (family rule 46) — one publishable from a failing run is not
something you can roll back to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 16:52:59 -04:00
125 changed files with 11105 additions and 2461 deletions
+75 -25
View File
@@ -19,15 +19,9 @@ name: Android
on:
push:
# NO `paths:` FILTER — the `decide` job below reads the real file set instead.
# See desktop.yml for why, and 85ead4d for what the duplication cost.
branches: [dev, main]
paths:
- "android/**"
# The Rust the .so is built from. A core change reaches the phone exactly
# as it reaches the desktop, so this lane has to rebuild on it.
- "core/**"
- "Cargo.toml"
- "Cargo.lock"
- ".forgejo/workflows/android.yml"
workflow_dispatch:
concurrency:
@@ -42,8 +36,46 @@ env:
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
jobs:
# Does the APK need rebuilding, or is the channel already serving this source?
# See the equivalent job in desktop.yml — same reasoning, same replacement of a
# hand-kept `paths:` filter with the one file set in `packaging/version.sh`.
#
# The guard runs here so it covers the skip path too (§6.3).
#
# NOTE THE COUPLING WITH ci.yml: when this lane builds, its last step dispatches
# ci.yml so the image bakes in the APK just published. When it SKIPS, no dispatch
# happens — and that is correct, because ci.yml's `gate` stands down only when the
# push touched Android's files, which is the same condition that makes this build.
# The two decisions agree because they read the same fact; they are still two
# readers of it, which is why the gate's grep carries a comment pointing here.
decide:
name: Build, or is the channel already serving this?
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
outputs:
build: ${{ steps.d.outputs.build }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Decide
id: d
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
case "$GITHUB_REF_NAME" in
main) channel=stable ;;
*) channel=dev ;;
esac
sh packaging/guard-forward.sh android "$channel"
echo "build=$(sh packaging/should-build.sh android "$channel")" >> $GITHUB_OUTPUT
build:
name: Kotlin + Rust (APK)
needs: [decide]
if: needs.decide.outputs.build == 'true'
# runs-on is only a scheduling label (Label Model B). flutter-ci is the
# proven-working label that can pull our container images.
runs-on: flutter-ci
@@ -63,6 +95,10 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
# Derives a version, so it needs the whole history — see the note in
# desktop.yml. Depth-1 is silently wrong here, not loudly broken (§6.1).
fetch-depth: 0
- name: Cache Gradle and Cargo
uses: actions/cache@v4
@@ -85,13 +121,17 @@ jobs:
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
version="$(sh ../desktop/packaging/build-version.sh)"
# TWO CLOCKS, ON PURPOSE (note 3127 §2). The NAME answers "is this the
# same code?", so it comes from the COMMIT and a dev build and the main
# build of one commit read identically. The CODE answers "may this be
# installed over that?" and must be monotonic BY CONSTRUCTION, because
# Android hard-fails a downgrade with INSTALL_FAILED_VERSION_DOWNGRADE and
# leaves a channel you cannot get out of — so it comes from BUILD time,
# which cannot go backwards. Commit time can.
version="$(sh ../packaging/version.sh display android)"
code="$(sh ../packaging/version.sh key android)"
echo "name=$version" >> $GITHUB_OUTPUT
# versionCode must RISE for Android to accept an update, and the run
# number is the same monotonic counter the desktop's version scheme
# already uses — no state carried between runs, and immune to the
# shallow checkout that makes a commit count useless here.
echo "code=$GITHUB_RUN_NUMBER" >> $GITHUB_OUTPUT
echo "code=$code" >> $GITHUB_OUTPUT
if [ -n "${ANDROID_KEYSTORE_BASE64:-}" ]; then
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/thoughtsync-release.jks
@@ -105,7 +145,7 @@ jobs:
echo "profile=debug" >> $GITHUB_OUTPUT
echo "keystore=/tmp/thoughtsync-release.jks" >> $GITHUB_OUTPUT
echo "apk=android/app/build/outputs/apk/release/app-release.apk" >> $GITHUB_OUTPUT
echo "Signed release build — $version (versionCode $GITHUB_RUN_NUMBER)"
echo "Signed release build — $version (versionCode $code)"
else
echo "::warning::No ANDROID_KEYSTORE_BASE64 secret. Building an UNSIGNED DEBUG APK: it cannot be installed over a signed build and cannot self-update."
echo "variant=Debug" >> $GITHUB_OUTPUT
@@ -199,19 +239,29 @@ jobs:
JSON
cat dist/thoughtsync-android.json
# The rolling dev channel, same fixed-tag release the desktop bundles use.
# CI artifacts are per-run and auth-gated, so they are no use as a fetch
# target; a release asset has a permanent URL. Only ever a SIGNED build —
# publishing an unsigned APK would offer people something they cannot
# install over what they already have.
- name: Publish to the dev channel
if: github.ref == 'refs/heads/dev' && steps.build.outputs.keystore != ''
# The rolling channel for this branch, the same fixed-tag releases the desktop
# bundles use. CI artifacts are per-run and auth-gated, so they are no use as a
# fetch target; a release asset has a permanent URL. Only ever a SIGNED build —
# publishing an unsigned APK would offer people something they cannot install
# over what they already have.
#
# `stable` from main is new in M314 step 3, and it is what lets the server image
# bake in a client that matches its own channel: a :latest image fetches the APK
# from `stable`, a :dev image from `dev`. Before this, main published no APK at
# all and every image — stable included — baked in the dev one.
- name: Publish to the channel for this branch
if: (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && steps.build.outputs.keystore != ''
working-directory: .
env:
GITHUB_TOKEN: ${{ github.token }}
RELEASE_TAG: dev
RELEASE_PRERELEASE: "true"
run: bash desktop/packaging/publish-release.sh
run: |
case "$GITHUB_REF_NAME" in
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
esac
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing the APK to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh
- name: Upload the APK
# Mirrored action, never actions/upload-artifact. @v4+ throws
+98 -81
View File
@@ -1,12 +1,21 @@
# CI runs first; build only proceeds if lint + typecheck pass.
#
# Push to dev: typecheck + lint + test + build :dev + :<sha>
# Push to main: typecheck + lint + test + build :latest + :<sha>
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
# Push to dev: typecheck + lint + test + build :dev
# Push to main: typecheck + lint + test + build :latest + :<sha>
#
# main is the production line, so a merge to main rebuilds and moves :latest to its
# tip (family rule 46) — no version release required. The :<sha> image is the
# immutable rollback unit for every build.
# THAT IS THE COMPLETE TAG SET (rule 145). No version-shaped image tag in any lane:
# nothing pins one — verified by looking for a consumer, not for whether one is
# imaginable — and the git release tag is a different object in a different system
# (step 7). The image is addressed by CHANNEL or by COMMIT; the release by date.
#
# A `v*` tag builds nothing at all. The merge to main already published everything,
# so a tag rebuilding that same source would re-push :<sha> with different bytes,
# which rule 145 forbids even when they match.
#
# main is the production line, so a merge moves :latest to its tip (family rule 46)
# — no version release required. :<sha> is the immutable rollback unit, and it is
# on main ONLY: a sha tag per dev push is a rollback target nobody has ever pulled,
# accumulating forever, for a channel whose entire contract is that it moves.
#
# Required secret (repo -> Settings -> Secrets -> Actions):
# REGISTRY_TOKEN -- Forgejo PAT with write:packages scope
@@ -16,27 +25,29 @@ name: CI & Build
on:
push:
# NO `paths:` FILTER, and unlike the client lanes this one does not skip either —
# the image ALWAYS builds. Two reasons:
#
# * Rule 145 promises that every push to `main` publishes a `:<sha>`, so any
# production commit is addressable. A path filter quietly broke that promise
# for a docs-only merge: no trigger, no image, no sha tag for that commit.
# * It is the artifact most exposed to base-image staleness (`python:3.12-slim`
# is a floating tag and this can face the internet), and building every push
# picks those updates up. That is why note 3127 §4's base tension does not
# bite here — the one artifact it would apply to never skips.
#
# Affordable because it is the cheap one: ~15 seconds, against 6 and 9 minutes
# for the clients, which is why THEY skip and this does not.
branches: [dev, main]
tags: ["v*"]
paths:
- "src/**"
- "frontend/**"
- "tests/**"
- "pyproject.toml"
- "alembic/**"
- "alembic.ini"
- "Dockerfile"
- ".forgejo/workflows/ci.yml"
# Dispatched by the Android lane once it has published a client, so the image
# that bakes it in is built AFTER the APK exists rather than racing it. See the
# `gate` job below for the other half.
workflow_dispatch:
# Cancel older runs on the same branch when a newer push lands. Tag runs get their
# own group implicitly and are never cancelled.
# Cancel older runs on the same branch when a newer push lands.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
cancel-in-progress: true
permissions:
contents: read
@@ -64,7 +75,7 @@ jobs:
# than a config so at least it is inspectable in the log.
gate:
name: Build now, or wait for Android?
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -89,17 +100,6 @@ jobs:
exit 0
fi
# A tag. The Android lane does not run on tags, so nothing would ever
# call back — standing down here would mean a release tag that never
# produces an image at all.
case "${{ github.ref }}" in
refs/tags/*)
echo "Tag build — the Android lane does not run on tags. Building."
echo "build=true" >> $GITHUB_OUTPUT
exit 0
;;
esac
# No parent (first commit, or a force-push that orphaned it) — nothing to
# compare, so build rather than stall.
if ! git rev-parse --verify -q HEAD^ >/dev/null; then
@@ -125,7 +125,12 @@ jobs:
echo "Changed in this push:"
echo "$changed" | sed 's/^/ /'
if echo "$changed" | grep -qE '^(android/|core/|Cargo\.toml$|Cargo\.lock$|\.forgejo/workflows/android\.yml$)'; then
# MUST match android's file set in packaging/version.sh. `packaging/` was
# missing here after step 4 added it there — so a packaging-only push had
# the Android lane rebuild and dispatch while this gate ALSO let the image
# build, producing two images for one commit and, on main, a second push of
# the same :<sha> with different bytes. Rule 145's exact prohibition.
if echo "$changed" | grep -qE '^(android/|core/|packaging/|Cargo\.toml$|Cargo\.lock$|\.forgejo/workflows/android\.yml$)'; then
echo ""
echo "This push also changes the Android client. Standing down: the"
echo "Android lane will publish a new APK and dispatch this workflow,"
@@ -140,7 +145,7 @@ jobs:
typecheck:
name: TypeScript typecheck
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -157,7 +162,7 @@ jobs:
lint:
name: Python lint
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -170,7 +175,7 @@ jobs:
test:
name: Python tests
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -193,15 +198,14 @@ jobs:
# them ever executed by CI — and the schema the migrations build had never been
# checked against the models that read it.
#
# Runs for visibility and does NOT gate the build, matching the `test` lane and
# FabledScribe's equivalent job.
# Gates the build, along with every other lane — see the `build` job's `needs`.
#
# Job key stays separator-free ("integration") with no `name:` — rule 80. act_runner
# derives the service-container name from the truncated job display name, and the
# discovery step below filters `docker ps` by it. Service hostnames are not routable
# on this runner (rule 79), so the step resolves the container's bridge IP.
integration:
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -261,10 +265,16 @@ jobs:
build:
name: Build & push image
# Build gates on lint + typecheck. The `test` job runs in parallel for
# visibility but does not block dev image builds (DB-backed integration
# testing happens against the dev image manually, not on every push).
needs: [gate, typecheck, lint]
# Every lane gates the build. This once stopped at lint + typecheck, on the
# reasoning that DB-backed testing happened manually against the dev image
# rather than on every push — true until 6f21db8 added the integration lane,
# and false since.
#
# What that gap cost: run 4293 failed `test` and published :dev and :<sha>
# anyway, so the deployed server ran a build whose test lane was red. An image
# tag is the rollback substrate (family rule 46); one that can be published
# from a failing run is not a substrate you can roll back TO.
needs: [gate, typecheck, lint, test, integration]
if: needs.gate.outputs.build == 'true'
runs-on: python-ci
container:
@@ -274,27 +284,35 @@ jobs:
packages: write
steps:
- uses: actions/checkout@v6
with:
# Derives a version — see the note in desktop.yml. Depth-1 sees one commit
# and produces a too-low value silently, with the lane green (§6.1).
fetch-depth: 0
- name: Generate image tags and version
id: tags
# run: steps execute under busybox sh (family rule 81), so use POSIX `case`,
# NOT bash `[[ ]]`.
run: |
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
BUILD_VERSION="dev"
# The image's version is DERIVED from its own shipped files — including the
# Android client it bakes in, which is why an APK-only change re-versions
# it. One value and no ordering key: nothing compares a server image, so
# §2 says do not invent one just because the other artifacts have one.
#
# This was a short sha on main and the literal "dev" elsewhere, which could
# not answer "how old is this instance?" — the question that actually gets
# asked of a self-hosted app running in several places.
BUILD_VERSION="$(sh packaging/version.sh display server)"
case "${{ github.ref }}" in
refs/heads/dev)
TAGS="$TAGS,${{ env.IMAGE }}:dev"
TAGS="${{ env.IMAGE }}:dev"
;;
refs/heads/main)
# Production line: :latest tracks main's tip (rule 46). No :main tag;
# the :<sha> above is the rollback unit. Version label = short sha.
TAGS="$TAGS,${{ env.IMAGE }}:latest"
BUILD_VERSION="$(echo ${{ github.sha }} | cut -c1-7)"
TAGS="${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.sha }}"
;;
refs/tags/*)
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
BUILD_VERSION="${{ github.ref_name }}"
*)
echo "::error::This lane builds images for dev and main only."
exit 1
;;
esac
echo "value=$TAGS" >> $GITHUB_OUTPUT
@@ -305,42 +323,41 @@ jobs:
docker system prune -af || true
docker builder prune --keep-storage 5g -f || true
# Bake the Android client in, on EVERY image build, so :dev, :latest and
# :<version> all carry one and a `docker compose pull` delivers a new client
# along with the new server.
# Bake EVERY client in, on every image build, so a self-hoster gets a working
# app for their machine from the server holding their notes — without an
# account on this forge, which is private (issue 2091) and is why serving them
# from a release page was never an option for anybody but the operator.
#
# Always the rolling `dev` release — the newest build there is. A versioned
# image therefore carries the newest client rather than one pinned to that
# version; the two negotiate a sync protocol version before linking, so
# "newest" is safe in a way "matching" would not buy anything over.
# ~104 MB on top of the ~85 MB image, almost all of it the AppImage. That is
# the price of the product being complete (rule 23), and the AppImage is not
# optional within it: it is the ONLY bundle that can replace itself in place,
# so a server without one cannot serve in-app updates to anyone.
#
# Fetched by the JOB, not by the Dockerfile: the release is private, and a
# Fetched by the JOB, not by the Dockerfile: the releases are private, and a
# token used inside a build lands in the context or a layer.
#
# NEVER fails the build. An image with no Android client advertises none and
# hides the download — a supported state, and the only one available before
# the first Android build has ever published.
- name: Fetch the Android client to bake in
# NEVER fails the build — see the script. A platform with nothing published
# means the server advertises nothing for it and the UI hides that download,
# which is a supported state and the only one available before that platform's
# first build has ever published.
- name: Fetch the clients to bake in
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
mkdir -p client
base="${{ github.server_url }}/${{ github.repository }}/releases/download/dev"
ok=1
for f in thoughtsync.apk thoughtsync-android.json; do
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "client/$f" "$base/$f" || ok=0
done
if [ "$ok" = 1 ]; then
echo "Baking in:"
cat client/thoughtsync-android.json
ls -l client/thoughtsync.apk
else
# Both or neither. Half a pair is worse than none: the server would
# read a sidecar describing an APK that isn't there, or an APK it
# cannot state a version for.
echo "::warning::No Android client on the dev release — this image ships without one."
rm -f client/thoughtsync.apk client/thoughtsync-android.json
fi
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves dev clients;
# :latest serves stable ones. This read `download/dev` unconditionally
# until M314 step 3, on every branch — so every stable server shipped a
# dev-channel APK to anyone who downloaded the client from it. Not a
# versioning gap; a plain defect, and the reason the channel is chosen here
# rather than inside the script: the caller is what knows which image it is
# building.
case "${{ github.ref_name }}" in
main) channel=stable ;;
*) channel=dev ;;
esac
sh packaging/fetch-clients.sh "$channel" client
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
+173 -85
View File
@@ -16,33 +16,20 @@ name: Desktop (Tauri)
on:
push:
# NO `paths:` FILTER. It was a second, independent statement of this artifact's
# file set, hand-kept beside the one in `packaging/version.sh`, and it drifted
# from it within a day (85ead4d). The `decide` job below reads the real set and
# skips in seconds when nothing moved — one definition, one reader (§3).
#
# The cost is that this workflow starts on every push rather than on a matching
# one. That is a ~15s container for a decision, against a lane that cannot
# silently fail to run.
branches: [dev, main]
tags: ["v*"]
paths:
- "desktop/**"
# The shared client core (store + sync engine) the desktop wraps. Its own
# crate since the Android client binds the same code, so a change there is a
# change to this app even though nothing under desktop/ moved.
- "core/**"
# The Android uniffi shim. It builds no desktop artifact, but it is a
# workspace member, so this lane's `cargo clippy --all-targets` is what
# compiles and lints it — and until the Android lane exists (M12 step 5),
# it is the ONLY thing that does.
- "android/**"
# The workspace manifest and lockfile, which now live at the repo root.
- "Cargo.toml"
- "Cargo.lock"
# The whole frontend, not just the adapter/bridge seam: it is compiled INTO
# the desktop binary, so any part of it changing means the shipped app is out
# of date. Config and lockfile included — a dependency bump changes the bundle
# as surely as a component does.
- "frontend/**"
- ".forgejo/workflows/desktop.yml"
workflow_dispatch:
concurrency:
group: desktop-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
cancel-in-progress: true
permissions:
# write (not read) so the tag build can publish a Release with the bundles
@@ -51,9 +38,47 @@ permissions:
contents: write
jobs:
# Does anything need building at all?
#
# ONE reader of ONE definition — the file sets in `packaging/version.sh` — replacing
# the `paths:` filters that used to state the same fact a second time. They drifted
# from it within a day: `packaging/` was added to the sets and not to the filters,
# so the commit fixing a derivation bug never ran on the two lanes it fixed
# (85ead4d). Note 3127 §3 warns about exactly that duplication.
#
# THE GUARD RUNS HERE, so it runs on every path INCLUDING the skip one (§6.3).
# Skipping because "the channel already serves this version" is indistinguishable
# from "we derived a stale value that happens to match" unless something checks.
decide:
name: Build, or is the channel already serving this?
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
outputs:
build: ${{ steps.d.outputs.build }}
steps:
- uses: actions/checkout@v6
with:
# Derives a version — depth-1 is silently wrong (§6.1).
fetch-depth: 0
- name: Decide
id: d
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
case "$GITHUB_REF_NAME" in
main) channel=stable ;;
*) channel=dev ;;
esac
sh packaging/guard-forward.sh desktop "$channel"
echo "build=$(sh packaging/should-build.sh desktop "$channel")" >> $GITHUB_OUTPUT
build:
name: Tauri desktop (Linux)
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
needs: [decide]
if: needs.decide.outputs.build == 'true'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
@@ -64,6 +89,14 @@ jobs:
APPIMAGE_EXTRACT_AND_RUN: "1"
steps:
- uses: actions/checkout@v6
with:
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
# the lane green — note 3127 §6.1, and the direction you cannot recover
# from. `packaging/version.sh` fails loudly on an empty result rather than
# emitting something plausible, which is what turns this into a red lane
# if it is ever dropped.
fetch-depth: 0
# tauri's generate_context! embeds the built frontend at compile time, so the
# frontend must exist before any cargo compile (clippy/test/build), not just
@@ -123,8 +156,20 @@ jobs:
else
echo "No TAURI_SIGNING_PRIVATE_KEY — building unsigned, no updater artifacts."
fi
version="$(sh ../packaging/build-version.sh)"
echo "Building version $version"
# The ORDERING KEY, not the display version: this string is what Tauri's
# updater parses as semver, and what it stamps into bundle FILENAMES that
# `write-manifest.sh` then selects on. The human-readable version is a
# separate value and arrives with the UI that shows it (#3181).
version="$(sh ../../packaging/version.sh key desktop)"
echo "Building desktop ordering key $version"
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
# A different value for a different audience: this is the one a person
# quotes in a bug report, the key above is the one only a comparator
# sees. Exported rather than passed as a flag because the macro that
# reads it is in Rust source, not in Tauri's config.
THOUGHTSYNC_DISPLAY_VERSION="$(sh ../../packaging/version.sh display desktop)"
export THOUGHTSYNC_DISPLAY_VERSION
echo "Baking display version $THOUGHTSYNC_DISPLAY_VERSION"
cargo tauri build \
--config '{"build":{"beforeBuildCommand":""}}' \
--config "{\"version\":\"$version\"}" \
@@ -205,37 +250,40 @@ jobs:
# failure, not as a green run with an empty artifact.
if-no-files-found: error
# Tag builds only: publish a real, versioned Fabled-Git Release with the
# AppImage + .deb attached — the stable fetch target the install script and
# the in-app updater consume (Actions artifacts above are ephemeral/test).
# Cutting the tag is the operator's action (rule 2); this only publishes a
# Release for a tag that already exists. Dormant on dev/main pushes.
- name: Publish release
if: startsWith(github.ref, 'refs/tags/v')
env:
GITHUB_TOKEN: ${{ github.token }}
run: bash desktop/packaging/publish-release.sh
# The rolling DEVELOPMENT channel (M10.9): a release whose tag never moves, so
# the updater has a permanent URL to read — Forgejo has no
# /releases/latest/download/<asset> route, so "newest" can't be named in a URL.
# The rolling channel for this branch: `dev` from dev, `stable` from main. Both
# are releases whose tag never moves, so the updater has a permanent URL to
# read — Forgejo has no /releases/latest/download/<asset> route, so "newest"
# cannot be named in a URL.
#
# MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until
# M314 step 3 this job built on main and published nothing, so the stable
# channel moved only when somebody cut a tag — that section's diagnostic
# failing outright: main publishing was not sufficient for a user to receive
# the build.
#
# Gated on the signing key INSIDE the script rather than with an `if:`, because
# the secrets context isn't reliably available to step conditions. Publishing
# bundles the app would then refuse to verify is worse than publishing nothing:
# it looks like a working feed.
- name: Publish to the dev channel
if: github.ref == 'refs/heads/dev'
- name: Publish to the channel for this branch
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
env:
GITHUB_TOKEN: ${{ github.token }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
RELEASE_TAG: dev
RELEASE_PRERELEASE: "true"
run: |
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the dev channel publish."
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
exit 0
fi
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
# `prerelease` is true for dev so it does not read as a supported build,
# and false for stable, which is the real thing.
case "$GITHUB_REF_NAME" in
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
esac
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh
# Windows installer, CROSS-COMPILED from Linux — there is no Windows build host.
@@ -253,12 +301,21 @@ jobs:
# built, not that it runs. A real-machine check stays mandatory before trusting it.
windows:
name: Windows installer (cross-compiled)
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
needs: [decide]
if: needs.decide.outputs.build == 'true'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-tauri-win:1.97
steps:
- uses: actions/checkout@v6
with:
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
# the lane green — note 3127 §6.1, and the direction you cannot recover
# from. `packaging/version.sh` fails loudly on an empty result rather than
# emitting something plausible, which is what turns this into a red lane
# if it is ever dropped.
fetch-depth: 0
# Same reason as the Linux job: generate_context! embeds the built frontend
# at compile time, so it must exist before cargo runs.
@@ -292,8 +349,20 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
version="$(sh ../packaging/build-version.sh)"
echo "Building version $version"
# The ORDERING KEY, not the display version: this string is what Tauri's
# updater parses as semver, and what it stamps into bundle FILENAMES that
# `write-manifest.sh` then selects on. The human-readable version is a
# separate value and arrives with the UI that shows it (#3181).
version="$(sh ../../packaging/version.sh key desktop)"
echo "Building desktop ordering key $version"
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
# A different value for a different audience: this is the one a person
# quotes in a bug report, the key above is the one only a comparator
# sees. Exported rather than passed as a flag because the macro that
# reads it is in Rust source, not in Tauri's config.
THOUGHTSYNC_DISPLAY_VERSION="$(sh ../../packaging/version.sh display desktop)"
export THOUGHTSYNC_DISPLAY_VERSION
echo "Baking display version $THOUGHTSYNC_DISPLAY_VERSION"
updater='{}'
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
updater='{"bundle":{"createUpdaterArtifacts":true}}'
@@ -317,35 +386,40 @@ jobs:
path: target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
if-no-files-found: error
# Publishes to the SAME release as the Linux job. Safe to run twice: the
# script reuses an existing release (409) and nullglob means each job uploads
# only the bundles present in its own workspace.
- name: Publish release
if: startsWith(github.ref, 'refs/tags/v')
env:
GITHUB_TOKEN: ${{ github.token }}
run: bash desktop/packaging/publish-release.sh
# The rolling DEVELOPMENT channel (M10.9): a release whose tag never moves, so
# the updater has a permanent URL to read — Forgejo has no
# /releases/latest/download/<asset> route, so "newest" can't be named in a URL.
# The rolling channel for this branch: `dev` from dev, `stable` from main. Both
# are releases whose tag never moves, so the updater has a permanent URL to
# read — Forgejo has no /releases/latest/download/<asset> route, so "newest"
# cannot be named in a URL.
#
# MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until
# M314 step 3 this job built on main and published nothing, so the stable
# channel moved only when somebody cut a tag — that section's diagnostic
# failing outright: main publishing was not sufficient for a user to receive
# the build.
#
# Gated on the signing key INSIDE the script rather than with an `if:`, because
# the secrets context isn't reliably available to step conditions. Publishing
# bundles the app would then refuse to verify is worse than publishing nothing:
# it looks like a working feed.
- name: Publish to the dev channel
if: github.ref == 'refs/heads/dev'
- name: Publish to the channel for this branch
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
env:
GITHUB_TOKEN: ${{ github.token }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
RELEASE_TAG: dev
RELEASE_PRERELEASE: "true"
run: |
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the dev channel publish."
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
exit 0
fi
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
# `prerelease` is true for dev so it does not read as a supported build,
# and false for stable, which is the real thing.
case "$GITHUB_REF_NAME" in
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
esac
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh
# The updater manifest, written AFTER both bundle jobs — they run in separate
@@ -359,12 +433,20 @@ jobs:
manifest:
name: Update manifest
needs: [build, windows]
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
steps:
- uses: actions/checkout@v6
with:
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
# the lane green — note 3127 §6.1, and the direction you cannot recover
# from. `packaging/version.sh` fails loudly on an empty result rather than
# emitting something plausible, which is what turns this into a red lane
# if it is ever dropped.
fetch-depth: 0
- name: Write and publish latest.json
env:
@@ -376,23 +458,29 @@ jobs:
echo "manifest to write. Add the secret to enable in-app updates."
exit 0
fi
# The SAME helper the bundles were built with — a second derivation here
# could drift, and a manifest whose version doesn't match the binary it
# points at is an updater that never settles.
version="$(sh desktop/packaging/build-version.sh)"
if [ "${GITHUB_REF_NAME}" = "dev" ]; then
export RELEASE_TAG=dev
export RELEASE_NOTES="Development build from ${GITHUB_SHA}"
# Rolling channel: drop the previous build's bundles once the manifest
# points at this one. Nothing can reach them, and they're ~100 MB a push.
export PRUNE_OLD_ASSETS=true
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
else
export RELEASE_TAG="${GITHUB_REF_NAME}"
export RELEASE_NOTES="ThoughtSync ${GITHUB_REF_NAME}"
# Twice: once onto the versioned release itself, and once onto the
# permanent `stable` pointer the app actually reads. Same manifest both
# times — its URLs point at the versioned assets either way.
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
APP_VERSION="$version" MANIFEST_TAG=stable bash desktop/packaging/write-manifest.sh
fi
# The SAME helper AND the same request the bundles were built with — a
# second derivation here could drift, and a manifest whose version doesn't
# match the binary it points at is an updater that never settles. It must
# be `key`: this value is matched against bundle filenames.
version="$(sh packaging/version.sh key desktop)"
# The version a PERSON reads, published beside the manifest as
# `thoughtsync-desktop.json`. The image build reads it to describe the
# bundles it bakes in (packaging/fetch-clients.sh) without re-deriving
# anything from its own checkout — which would be a different commit
# whenever the desktop did not rebuild.
display="$(sh packaging/version.sh display desktop)"
# Both channels are rolling: the manifest lands on the same release that
# holds the bundles, and the previous build's bundles are dropped once it
# points at this one. Nothing can reach them, and they are ~100 MB a push.
#
# No tag arm any more. A `v*` tag does not reach this workflow at all — it
# triggers release.yml, which writes a changelog and builds nothing.
case "${GITHUB_REF_NAME}" in
main) export RELEASE_TAG=stable
export RELEASE_NOTES="Stable build from ${GITHUB_SHA}" ;;
*) export RELEASE_TAG=dev
export RELEASE_NOTES="Development build from ${GITHUB_SHA}" ;;
esac
export PRUNE_OLD_ASSETS=true
APP_VERSION="$version" DISPLAY_VERSION="$display" \
bash desktop/packaging/write-manifest.sh
+68
View File
@@ -0,0 +1,68 @@
name: Release
# A RELEASE BUILDS NOTHING. That is the whole point of this lane (M314 step 7).
#
# The merge to `main` already published everything a user can receive: the server
# image as `:latest` + `:<sha>`, the desktop bundles and the APK to the `stable`
# channel, and the updater manifest that advertises them. A tag rebuilding that same
# source would produce identical artifacts under identical names, and would re-push
# `:<sha>` with different bytes — which rule 145 forbids even when they match.
#
# So the tag is a BOOKMARK, and this lane gives it the only job it has left: saying
# what was in it. Note 3127 §5 — there are two halves to "what am I running", and
# the version answers only the first:
#
# which build is this? the footer, /api/config, the APK's versionName
# what changed since the one ← this
# I was running last month?
#
# Cutting the tag is the operator's act (rule 2). This only responds to one.
#
# THE TAG IS NOT AN IMAGE TAG and never becomes one. `ci.yml` does not trigger on
# tags at all. The image is addressed by channel or by commit; the release by date.
# Same string as the artifact version (rule 148, `vYYYY.MM.DD.HHMM`), different
# system.
on:
push:
tags: ["v*"]
permissions:
contents: write
jobs:
notes:
name: Write the changelog
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
- uses: actions/checkout@v6
with:
# The whole history AND every tag: the notes are the commit range between
# this tag and the previous `v*` one, and neither end exists in a shallow
# clone. A depth-limited checkout here does not fail — it produces a
# shorter changelog, which is the kind of wrong nobody notices.
fetch-depth: 0
- name: Publish the release notes
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
notes="$(sh packaging/release-notes.sh "$GITHUB_REF_NAME")"
echo "$notes"
echo "---"
# JSON-escaped HERE rather than in publish-release.sh, which cannot assume
# python3 is on PATH in the three images that call it. `json.dumps` then
# strip the surrounding quotes — the script supplies those.
RELEASE_BODY_JSON="$(printf '%s' "$notes" \
| python3 -c 'import json,sys; print(json.dumps(sys.stdin.read())[1:-1])')"
export RELEASE_BODY_JSON
# Through publish-release.sh for its create-or-PATCH-on-409 path: a
# release that is only ever POSTed keeps whatever body its first run
# wrote (#2182), so re-tagging or re-running must rewrite it. No bundles
# exist in this workspace, so its asset globs match nothing and it
# uploads none — which is the intended behaviour, not a side effect.
RELEASE_TAG="$GITHUB_REF_NAME" bash desktop/packaging/publish-release.sh
Generated
+67
View File
@@ -1286,6 +1286,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix",
"windows-link 0.2.1",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -1405,6 +1415,24 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "global-hotkey"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e"
dependencies = [
"crossbeam-channel",
"keyboard-types",
"objc2",
"objc2-app-kit",
"once_cell",
"serde",
"thiserror 2.0.20",
"windows-sys 0.59.0",
"x11rb",
"xkeysym",
]
[[package]]
name = "gobject-sys"
version = "0.18.0"
@@ -3947,6 +3975,21 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-global-shortcut"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b"
dependencies = [
"global-hotkey",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.20",
]
[[package]]
name = "tauri-plugin-log"
version = "2.9.0"
@@ -4196,6 +4239,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-global-shortcut",
"tauri-plugin-log",
"tauri-plugin-updater",
"thoughtsync-core",
@@ -5577,6 +5621,23 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xattr"
version = "1.6.1"
@@ -5587,6 +5648,12 @@ dependencies = [
"rustix",
]
[[package]]
name = "xkeysym"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "yoke"
version = "0.8.3"
+13 -7
View File
@@ -24,17 +24,23 @@ COPY --from=build-frontend /build/dist/ src/thoughtsync/static/
COPY alembic.ini .
COPY alembic/ alembic/
# The Android client this server hands out. CI fetches the newest published build
# into ./client immediately before this runs (ci.yml), so every image tag — :dev,
# :latest and :<version> alike — ships a client, and a `docker compose pull`
# delivers a new one with no file copying by hand.
# The clients this server hands out — the APK and all four desktop bundles. CI
# fetches the newest published build of each into ./client immediately before this
# runs (packaging/fetch-clients.sh), so both image tags ship a full set and a
# `docker compose pull` delivers new ones with no file copying by hand.
#
# Fetched by the JOB rather than here on purpose: the release is private, and a
# ~104 MB of this image is that set, almost all of it the AppImage.
#
# Fetched by the JOB rather than here on purpose: the releases are private, and a
# token used inside a build ends up in the build context or a layer.
#
# LAST of the COPYs, deliberately: this directory changes on every build, so
# putting it above the `pip install` layer would invalidate that layer every time.
#
# The directory is tracked (client/.keep) so this COPY cannot fail on a tree where
# that step never ran. An image with no APK is a supported state — the server
# advertises nothing and the web UI hides the download (client_dist.py).
# that step never ran. An image with no clients — or with some and not others — is
# a supported state: the server advertises what it has and the web UI hides the
# rest (client_dist.py).
COPY client/ src/thoughtsync/client/
ENV PYTHONPATH=/app/src
+4 -2
View File
@@ -99,8 +99,10 @@ Then open `http://<host>:5000` and register — **the first account becomes the
unset, a signing key is generated and persisted in the database (sessions survive restarts).
- Uploaded images live under the `thoughtsync-data` volume at `/var/thoughtsync`.
- The app waits for the database and runs migrations (`alembic upgrade head`) automatically on start.
- **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev` build) ·
`:<git-sha>` (immutable, for pinning / rollback).
- **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev`
build) · `:<git-sha>` on `main` only (immutable, the rollback unit). There are
no version-shaped tags: nothing pins one, and the build reports its own version
at `/api/config` and `/health`.
- **Putting it on the public internet:** there are four things to do first — close
registration, terminate TLS and forward `X-Forwarded-Proto`, stop publishing the app
port, and back up the attachment volume as well as the database. See
@@ -0,0 +1,128 @@
"""fold note_items into the note body and drop the table
Revision ID: 0027
Revises: 0026
Create Date: 2026-08-24
M304. A checklist item becomes a `- [ ] milk` line of `notes.body`, and `note_items`
goes. The reason is positional, not cosmetic: a row had a position in a table and no
position in the text, so a separate list could only ever render AFTER the prose. With
the items in the body, a list can sit between two paragraphs — which is the thing that
could not be built before and no amount of restyling would have delivered.
## This migration rewrites note bodies
Every note that has items gets its body appended to. The rules below are strict
because rewriting somebody's text deserves it — not, as an earlier draft of this
docstring claimed, because this instance holds imported Google Keep notes. It does
not; note 2916's headline is that nothing here is anyone's work but the operator's
test data. What 2916 actually says about imports is conditional — text arriving from
another app WOULD be real, and any import path has to treat it that way — and the
importer this migration shares a format with is one nobody here has run.
Careful was still the right call. It cost little, and the same care is what the rule
demands the day someone does import something:
* Rows are read BEFORE the table is dropped, in this one transaction.
* The existing body is never rewritten, only appended to.
* The layout — a blank line between prose and the list, nothing between consecutive
items — is byte-for-byte what `_note_markdown` has always exported and what
`derive::append_item` produces on every client. All three landing on the same text
is what lets the clients migrate their own SQLite stores independently and still
agree with the server, with no sync required to reconcile them.
## The fold is inlined on purpose
`notes/checklist.py` has this same function and this migration deliberately does not
import it. A migration has to keep producing what it produced the day it ran; if the
app's spacing rule ever changes, this file must not change with it.
## `updated_at` is left alone, and that is load-bearing
Raw SQL, so SQLAlchemy's `onupdate` never fires. Two reasons, and the second matters
more than the first. Every client folds the same rows the same way, so the new body is
news to nobody. And a client holding an UNPUSHED body edit still has the newer
`updated_at`, so when it pulls the migrated note last-write-wins keeps its edit instead
of the migration silently winning.
The `notes` row's own `sync_revision` trigger (migration 0015) does fire, so every
migrated note becomes pullable once. That is wanted: it is what makes a client whose
local fold somehow differed converge on the server's text.
## The downgrade is not a true inverse, and says so
It recreates an empty `note_items` and leaves the bodies alone. Nothing is lost —
every item is still there as text, which is where this migration put it — but the old
code would show those notes as prose with no checklist. A faithful inverse is not
possible: once the items are lines, nothing distinguishes a line this migration wrote
from one somebody typed, and a downgrade that guessed would eat hand-written task
lists. The real rollback is a database restore.
Recreating the table is not decoration, though. Migration 0015's downgrade runs
`DROP TRIGGER IF EXISTS trg_note_items_bump_note ON note_items`, and `IF EXISTS`
covers the trigger, not the table — against a missing table that statement errors. So
this is what keeps the migration chain runnable all the way back down.
"""
import re
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision = "0027"
down_revision = "0026"
branch_labels = None
depends_on = None
_TASK_RE = re.compile(r"^\s*[-*] +\[[ xX]\](?: +.*)?$")
def _append_item(body: str, text: str, checked: bool) -> str:
mark = "x" if checked else " "
text = (text or "").strip()
line = f"- [{mark}] {text}" if text else f"- [{mark}]"
trimmed = (body or "").rstrip("\n")
if not trimmed.strip():
return line
follows_a_list = bool(_TASK_RE.match(trimmed.split("\n")[-1]))
return f"{trimmed}\n{line}" if follows_a_list else f"{trimmed}\n\n{line}"
def upgrade():
bind = op.get_bind()
rows = bind.execute(
sa.text("SELECT note_id, text, checked FROM note_items ORDER BY note_id, position, created_at")
).fetchall()
grouped: dict = {}
for note_id, text, checked in rows:
grouped.setdefault(note_id, []).append((text, bool(checked)))
for note_id, items in grouped.items():
body = bind.execute(sa.text("SELECT body FROM notes WHERE id = :id"), {"id": note_id}).scalar()
# An item whose note is already gone has nothing to fold into. The foreign key
# should make this impossible; skipping costs nothing and failing here would
# leave the database half-migrated.
if body is None:
continue
for text, checked in items:
body = _append_item(body, text, checked)
bind.execute(sa.text("UPDATE notes SET body = :body WHERE id = :id"), {"body": body, "id": note_id})
op.drop_table("note_items")
def downgrade():
# Column-for-column as migration 0006 created it, index name included: 0015's
# downgrade names both the table and its trigger, so a near-enough copy is not
# good enough.
op.create_table(
"note_items",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("note_id", UUID(as_uuid=True), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
sa.Column("text", sa.Text(), nullable=False),
sa.Column("checked", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_note_items_note", "note_items", ["note_id"])
@@ -0,0 +1,168 @@
"""lift standalone #tags out of note bodies
Revision ID: 0028
Revises: 0027
Create Date: 2026-08-26
M311. A `#tag` was being shown twice — once as the text you typed and once as a chip —
and with the chip moved to the top of the card the text is redundant. This removes it,
but only from notes where the tag was standing on its own.
## This migration rewrites note bodies
The rule is deliberately narrow, and the same one `notes/tags.py:split_body_tags`
applies from here on:
* A line containing nothing but tags and whitespace is REMOVED.
* Every other line is left exactly as written.
So `#todo` on its own line goes, and `remember to call #mom tomorrow` does not. The
looser reading — also stripping a trailing tag off a prose line — was 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".
Rewriting somebody's words to save a duplicate chip is a bad trade, and a migration is
the worst possible place to make it.
Two guards, both of which cost a note nothing:
* A line inside a ``` fence is never touched. A `#tag` there is a shell comment in a
snippet somebody pasted, and deleting it would eat a line of their example.
* A note that is NOTHING but tags keeps its text. Lifting would leave a blank card,
which is worse than the duplication this fixes.
## The label rows have to graduate in the same transaction
A `via_tag` row means "this label is backed by text still in the body". Once the text
is gone that is false, and leaving it true is not cosmetic: `_lift_and_reconcile_tags`
detaches any `via_tag` row it cannot find a `#tag` for, so the note would lose the tag
on its very next save. The flip to `via_tag = false` is what makes the label the record
instead — and what makes the chip's × appear in both editors, which is now the only way
to remove a tag whose text no longer exists.
## The transform is inlined, like 0027's
`split_body_tags` is deliberately NOT imported. 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 is only recomputed for a note whose
body actually moved — a note named after a `#todo` line needs a new name, and reading it
from the app would couple this migration to a rule that has already changed once (M13).
## `updated_at` is left alone, and that is load-bearing
Raw SQL, so SQLAlchemy's `onupdate` never fires. A client holding an UNPUSHED body edit
keeps the newer `updated_at`, so when it pulls the migrated note last-write-wins keeps
its edit instead of the migration silently winning.
The `sync_revision` trigger (migration 0015) does fire, so every rewritten note becomes
pullable once and clients converge on the server's text. That 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 they do.
## The downgrade is not a true inverse, and says so
It cannot be. Nothing distinguishes a `#todo` line this migration deleted from one that
was never there, and putting one back would be guessing at where in the note it went.
Nothing is lost, though, which is why that is acceptable: the tag still exists as a
label on the note, and the chip still shows it. What a downgrade cannot restore is the
DUPLICATE — which is the thing this migration set out to remove. Rolling the rows back
to `via_tag = true` would be actively harmful: the text that flag claims to be backed by
is gone, so the next save would detach the label and lose the tag for real. So the
downgrade leaves both alone. The real rollback is a database restore.
"""
import re
import sqlalchemy as sa
from alembic import op
revision = "0028"
down_revision = "0027"
branch_labels = None
depends_on = None
# Frozen copies. See "The transform is inlined" above — these must not follow the app.
_TAG_RE = re.compile(r"(?:^|(?<=\s))#(\w[\w-]*)")
_FENCE_RE = re.compile(r"^\s*(?:```|~~~)")
_TASK_RE = re.compile(r"^(?P<indent>\s*)(?P<bullet>[-*]) +\[(?P<mark>[ xX])\](?: +(?P<text>.*))?$")
_DISPLAY_TITLE_CAP = 200
def _is_tag(name: str) -> bool:
"""A tag must contain a letter, so #2024 and #_ are not tags — and a line holding
only those is therefore not a tag-only line and is left alone."""
return any(c.isalpha() for c in name)
def _split(body: str) -> tuple[list[str], str]:
"""(standalone tag names, body with their lines removed)."""
standalone: list[str] = []
kept: list[str] = []
in_fence = False
for line in body.split("\n"):
if _FENCE_RE.match(line):
in_fence = not in_fence
kept.append(line)
continue
matches = [m for m in _TAG_RE.finditer(line) if _is_tag(m.group(1))]
remainder = line
for m in reversed(matches):
remainder = remainder[: m.start()] + remainder[m.end() :]
if in_fence or not matches or remainder.strip():
kept.append(line)
else:
standalone.extend(m.group(1) for m in matches)
lifted = re.sub(r"\n{3,}", "\n\n", "\n".join(kept)).strip("\n")
if body.strip() and not lifted.strip():
return [], body # nothing but tags: keep the note readable
# A tag still written in prose somewhere keeps its text, so it stays derived.
still_in_prose = {m.group(1).lower() for m in _TAG_RE.finditer(lifted) if _is_tag(m.group(1))}
return [n for n in standalone if n.lower() not in still_in_prose], lifted
def _display_title(body: str) -> str:
for line in body.splitlines():
stripped = line.strip()
match = _TASK_RE.match(stripped)
text = (match.group("text") or "") if match else stripped
text = text.strip()
if text:
return text[:_DISPLAY_TITLE_CAP]
return ""
def upgrade():
bind = op.get_bind()
rows = bind.execute(sa.text("SELECT id, body FROM notes WHERE body LIKE '%#%'")).fetchall()
flip = sa.text(
"UPDATE note_labels nl SET via_tag = false "
"FROM labels l "
"WHERE nl.label_id = l.id AND nl.note_id = :nid AND nl.via_tag = true "
"AND lower(l.name) IN :names"
).bindparams(sa.bindparam("names", expanding=True))
for note_id, body in rows:
if not body:
continue
standalone, lifted = _split(body)
if lifted != body:
bind.execute(
sa.text("UPDATE notes SET body = :body, display_title = :title WHERE id = :id"),
{"body": lifted, "title": _display_title(lifted), "id": note_id},
)
# Even when the body did not move, a tag can be standalone only in the sense
# that its line was already removed by an earlier pass — so the flip is driven
# by the tag list, not by whether the text changed.
if standalone:
bind.execute(flip, {"nid": note_id, "names": [n.lower() for n in standalone]})
def downgrade():
"""Deliberately empty — see the module docstring.
Restoring the deleted lines would be guessing, and flipping the rows back to
`via_tag = true` would be worse than doing nothing: the text that flag claims backs
them is gone, so the next save would detach the label and lose the tag for real.
"""
+93
View File
@@ -0,0 +1,93 @@
"""drop notes.color — a card is one neutral surface, colour lives on the tag
Revision ID: 0029
Revises: 0028
Create Date: 2026-08-28
M315 step 3. A note's colour was set by a picker and read by three card renderers.
Steps 1 and 2 stopped every one of those reads: the card is one neutral per theme and
the only coloured thing on a board is a tag. This drops the column that nothing has
been reading since, and the picker goes with it.
`labels.color` is untouched. That is the colour that survived, and the one the whole
milestone was about keeping.
## What is lost, and why that is the change rather than a cost of it
Any colour a note was explicitly given. There is nowhere to preserve it TO — the field
it would be preserved in is the one being dropped — and nothing renders it, so a
preserved value would be a column kept warm for a feature that was deliberately
removed. A note that had a colour now takes its identity from its tags, which is what
the operator asked for: "strip color from the cards ... and keep the color for tags
just on the tag."
The palette itself is not lost. `NOTE_COLORS` moved from `models/note.py` to
`colors.py` in the same change — labels still name a colour, and leaving the vocabulary
defined on the model that lost one would be an invitation to put the column back.
## The saved-filter sweep is not optional
`saved_filters.params` is opaque JSON mirroring the `GET /api/notes` facet query, and
a stored view could carry `"color": "teal"`. With the facet gone that key would sit
there forever, and `clean_params` only guards what is written FROM here on. A view that
silently filters on a field the app no longer has is worse than one that visibly lost a
criterion, so the stored rows are swept too.
Done in Python rather than as `params::jsonb - 'color'`, deliberately. Postgres has no
try-cast: one malformed blob would abort the whole migration, and these rows are
somebody's saved views. `json.loads` in a try/except lets a corrupt row keep whatever it
holds and lets every other row be fixed.
## Search is not affected
`notes.search_vector` is a stored generated column over `display_title` and `body`
(rebuilt in 0026). It never named `color`, so unlike the title drop there is nothing
here to tear down and recreate.
## Downgrade
Restores the column, empty, at its old default. The values are not recoverable — see
above. It is the schema that comes back, not the data.
"""
import json
from alembic import op
import sqlalchemy as sa
revision = "0029"
down_revision = "0028"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column("notes", "color")
bind = op.get_bind()
rows = bind.execute(
sa.text("SELECT id, params FROM saved_filters WHERE params LIKE '%color%'")
).fetchall()
for sf_id, params in rows:
try:
parsed = json.loads(params)
except (ValueError, TypeError):
# A blob that does not parse cannot be edited safely. Leaving it is
# correct: it was already unreadable by the app, and this migration is not
# the place to decide what it should have said.
continue
if not isinstance(parsed, dict) or "color" not in parsed:
continue
parsed.pop("color")
bind.execute(
sa.text("UPDATE saved_filters SET params = :p WHERE id = :id"),
{"p": json.dumps(parsed), "id": sf_id},
)
def downgrade() -> None:
# Comes back at the default every note would have had anyway. Which notes once
# carried a chosen colour is not recorded anywhere after the upgrade.
op.add_column(
"notes",
sa.Column("color", sa.Text(), nullable=False, server_default="default"),
)
+41
View File
@@ -7,6 +7,9 @@
this permission never exercised.
-->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Only to answer "is this connection metered?" before the app downloads its own
update in the background. Normal permission, no prompt, no location. -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!--
Four more permissions are NOT declared here and still reach the merged
@@ -96,15 +99,53 @@
android:supportsRtl="true"
android:theme="@style/Theme.ThoughtSync"
android:usesCleartextTraffic="true">
<!--
launchMode="singleTop" exists for the SHARE filters below.
The reminder notification adds FLAG_ACTIVITY_SINGLE_TOP to its own
intent, so onNewIntent already worked for that one. A share intent is
built by the OTHER app — Chrome, a reader, the text-selection toolbar —
and nothing here can add a flag to it. Without singleTop declared on the
activity itself, every share while the app is running would stack a
second MainActivity on top of the first: a second view model, a second
board, and a back press that lands on a stale copy of the same app.
-->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.ThoughtSync">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!--
Capture without opening the app first: Share → ThoughtSync from
anywhere, and the selection toolbar in any text field.
text/plain ONLY, and image/* deliberately absent. Nothing in this
app can create an attachment — the core has `delete_attachment` and
no counterpart, and the FFI exposes neither. Claiming images in the
share sheet would put this app in front of people for a job it
cannot do and fail after they had chosen it.
-->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<!--
The label is what appears in the text-selection menu beside Copy and
Share, where "ThoughtSync" would say who rather than what.
-->
<intent-filter android:label="@string/capture_process_text">
<action android:name="android.intent.action.PROCESS_TEXT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<!--
@@ -4,6 +4,8 @@ import android.content.Context
import android.content.Intent
import android.content.IntentSender
import android.content.pm.PackageInstaller
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Build
import android.provider.Settings
@@ -62,6 +64,30 @@ object AppUpdate {
.setData(Uri.fromParts("package", context.packageName, null))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
/**
* Whether this is wifi somebody is not paying by the megabyte for.
*
* The app fetches its own update in the background, and fifty-odd megabytes over
* mobile data is a bill nobody agreed to. Anywhere else it simply waits — the
* update is found, nothing is downloaded, and nothing is said until it can be.
*
* BOTH conditions, deliberately. Wifi alone would still download over a tethered
* hotspot, which is mobile data wearing a different hat and the exact bill this
* avoids. Unmetered alone would download over an unmetered cellular plan, which
* is not what "on wifi" means to the person who asked for it.
*
* Every uncertain answer is `false`: the cautious one costs nothing.
*/
fun onWifi(context: Context): Boolean {
val caps =
context
.getSystemService(ConnectivityManager::class.java)
?.let { manager -> manager.activeNetwork?.let(manager::getNetworkCapabilities) }
return caps != null &&
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
}
/** Where a download goes: app-private, so no storage permission is involved. */
fun downloadTarget(context: Context): File = File(context.cacheDir, "update.apk")
@@ -0,0 +1,21 @@
package com.fabledsword.thoughtsync
import android.content.Context
/**
* The `versionName` of the INSTALLED package, or null when it cannot be read.
*
* From the package manager rather than from `BuildConfig`: this reports what is
* actually on the phone, which is the question both callers are asking — a bug
* report reading the foot of Sync, and a server log reading the client header. It
* also needs no `buildFeatures.buildConfig`, which this module does not enable.
*
* Returns null rather than a fallback string, because the two callers want
* different ones: the UI wants a localized "unknown" from string resources, the
* client header wants the literal the core recognizes. Note 3127 §5 governs both —
* with no version tags, the artifact's self-report is the only answer to "which
* build is this?", so a missing name must read as missing and never as a plausible
* default that nothing can contradict.
*/
fun Context.installedVersionName(): String? =
runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull()
@@ -25,14 +25,16 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.fabledsword.thoughtsync.core.ThoughtSync
import com.fabledsword.thoughtsync.ui.BoardScreen
import com.fabledsword.thoughtsync.ui.BoardSync
import com.fabledsword.thoughtsync.ui.BoardUpdate
import com.fabledsword.thoughtsync.ui.BoardViewModel
import com.fabledsword.thoughtsync.ui.ComposeSheet
import com.fabledsword.thoughtsync.ui.ForegroundTransitions
import com.fabledsword.thoughtsync.ui.NoteEditorScreen
import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen
import com.fabledsword.thoughtsync.ui.SyncScreen
import com.fabledsword.thoughtsync.ui.SyncState
import com.fabledsword.thoughtsync.ui.SyncViewModel
import com.fabledsword.thoughtsync.ui.TagsScreen
import com.fabledsword.thoughtsync.ui.TagsViewModel
import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme
import com.fabledsword.thoughtsync.ui.UpdateViewModel
import com.fabledsword.thoughtsync.ui.olderThan
@@ -51,12 +53,24 @@ class MainActivity : ComponentActivity() {
*/
private val requestedNote = mutableStateOf<String?>(null)
/**
* Text shared into the app from elsewhere, waiting to become a note.
*
* Same shape and same reason as [requestedNote]: a share that arrives while
* the app is already running lands in [onNewIntent], long after the
* composition was built, so a piece of state it is already reading is the only
* way in. The activity is `singleTop` in the manifest precisely so that this
* path exists for an intent another app built.
*/
private val sharedText = mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val app = application as ThoughtSyncApplication
requestedNote.value = takeRequestedNote(intent)
sharedText.value = takeSharedText(intent)
setContent {
ThoughtSyncTheme {
@@ -67,7 +81,7 @@ class MainActivity : ComponentActivity() {
// than render an empty board that looks like data loss.
StoreUnavailableScreen(reason = app.openFailure)
} else {
App(core, requestedNote)
App(core, requestedNote, sharedText)
}
}
}
@@ -77,6 +91,7 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent)
setIntent(intent)
requestedNote.value = takeRequestedNote(intent)
sharedText.value = takeSharedText(intent)
}
/**
@@ -92,10 +107,58 @@ class MainActivity : ComponentActivity() {
intent.removeExtra(Reminders.EXTRA_NOTE_ID)
return id
}
/**
* Read the text a share or a text selection brought in, and CONSUME it.
*
* Consumed for the same reason [takeRequestedNote] is: the activity keeps the
* intent it was launched with, so without removing the extras a rotation would
* replay the share and mint the same note again, with nothing on screen to
* explain where the duplicates were coming from.
*/
private fun takeSharedText(intent: Intent?): String? {
val shared =
when (intent?.action) {
Intent.ACTION_SEND -> intent.takeSendText()
Intent.ACTION_PROCESS_TEXT -> intent.takeProcessText()
else -> null
}
return shared?.takeIf { it.isNotBlank() }
}
}
/**
* The shared text, with a subject line above it when the sender gave one.
*
* Sharing a page from a browser sends EXTRA_SUBJECT as the page title and
* EXTRA_TEXT as the URL. Keeping both makes the note read as its title, because
* the core names a note by its first line — so this is not decoration, it is what
* turns a board of identical-looking links into a board you can scan.
*
* `distinct` because plenty of apps put the same string in both, and a note that
* says the URL twice is worse than one that says it once.
*/
private fun Intent.takeSendText(): String? {
val body = getStringExtra(Intent.EXTRA_TEXT)
val subject = getStringExtra(Intent.EXTRA_SUBJECT)
removeExtra(Intent.EXTRA_TEXT)
removeExtra(Intent.EXTRA_SUBJECT)
return listOfNotNull(subject, body)
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
.joinToString("\n")
}
/** The selection from another app's text field, via the selection toolbar. */
private fun Intent.takeProcessText(): String? {
val text = getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)?.toString()
removeExtra(Intent.EXTRA_PROCESS_TEXT)
return text
}
/** Which screen is up. Exactly one at a time. */
private enum class Screen { BOARD, EDITOR, SYNC }
private enum class Screen { BOARD, EDITOR, SYNC, TAGS }
/**
* The whole app, once the store is open.
@@ -104,7 +167,7 @@ private enum class Screen { BOARD, EDITOR, SYNC }
* both cover the display completely, so keeping the board's two-column grid
* measuring and recomposing underneath one would be pure waste.
*
* Still no navigation library. Three destinations, each entered from exactly one
* Still no navigation library. Four destinations, each entered from exactly one
* place and left by back — a nav graph would be ceremony around an enum, and the
* state that actually matters (which note is open, whether this device is linked)
* already lives in view models.
@@ -113,6 +176,7 @@ private enum class Screen { BOARD, EDITOR, SYNC }
private fun App(
core: ThoughtSync,
requestedNote: MutableState<String?>,
sharedText: MutableState<String?>,
) {
val context = LocalContext.current
val board: BoardViewModel =
@@ -135,32 +199,51 @@ private fun App(
}
}
// Cleared the same way and for the same reason: without it every later
// recomposition would capture the shared text again as a new note.
LaunchedEffect(sharedText.value) {
sharedText.value?.let {
board.captureShared(it)
sharedText.value = null
}
}
ReminderAlarms(core)
// A pull can rewrite every note the board is holding, so a sync that changed
// anything tells it to reload. Wired here, at the one place that owns both.
val sync: SyncViewModel =
viewModel(factory = SyncViewModel.factory(core, onStoreChanged = board::refresh))
// Sheet and screen visibility are view STATE, not view-model state: they are
// about what is on the display, and nothing in the store cares.
// Screen visibility is view STATE, not view-model state: it is about what is on
// the display, and nothing in the store cares. Saveable so a rotation does not
// close it.
//
// Saveable, though: `remember` alone meant rotating the phone closed whatever
// was open and took the half-written note in the capture sheet with it. The
// editor never had that problem because the note it is on lives in a view
// model; these two are the only screen state that did not.
var composing by rememberSaveable { mutableStateOf(false) }
// The capture sheet used to keep its own flag here too. It is gone: the + button
// opens the editor on an unsaved draft, so writing a note and editing one are the
// same surface with the same toolbar.
var showingSync by rememberSaveable { mutableStateOf(false) }
var showingTags by rememberSaveable { mutableStateOf(false) }
// Tag writes reach the board two ways at once: the drawer lists tags, and the
// board may be LOOKING at one that a delete or a merge just removed. Both are
// `refreshLabels`, which also leaves a lens whose tag stopped existing.
val tags: TagsViewModel =
viewModel(factory = TagsViewModel.factory(core, onStoreChanged = board::refreshLabels))
val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context))
val settings = remember(context) { SyncSettings(context) }
var automatic by remember { mutableStateOf(settings.automatic) }
AutomaticSync(state = sync.state, enabled = automatic, onSync = sync::syncQuietly)
AutomaticUpdate(linked = sync.state.linked, onCheck = update::checkInBackground)
val editing = board.state.editing
val screen =
when {
showingSync -> Screen.SYNC
// Above the editor: tags are reached only from the board's drawer, so
// there is never an open note underneath one to go back to.
showingTags -> Screen.TAGS
editing != null -> Screen.EDITOR
else -> Screen.BOARD
}
@@ -188,10 +271,23 @@ private fun App(
onInstallOutcome = update::consumeInstallOutcome,
)
Screen.TAGS ->
TagsScreen(
state = tags.state,
onClose = { showingTags = false },
onCreate = tags::create,
onRename = tags::rename,
onColour = tags::setColour,
onDelete = tags::remove,
onMerge = tags::merge,
onDismissError = tags::dismissError,
)
Screen.EDITOR ->
NoteEditorScreen(
// Non-null by construction: `screen` is EDITOR only when it is.
note = requireNotNull(editing) { "the editor screen needs a note" },
sessionKey = board.state.editingSession,
labels = board.state.labels,
saving = board.state.saving,
error = board.state.error,
@@ -217,27 +313,37 @@ private fun App(
onDismissError = sync::dismissSyncError,
),
onOpenSync = { showingSync = true },
onManageTags = { showingTags = true },
onSearch = board::search,
onCompose = { composing = true },
onCompose = board::compose,
onToggleItem = board::toggleItem,
// The SAME seam the editor uses. `onEditorAction` is already the
// exhaustive dispatcher for every action a note has, and it takes
// the note to act on rather than reading the open one — so the board
// can hand it a card without a second dispatcher existing to drift.
onNoteAction = board::onEditorAction,
// Null unless there is genuinely something to say — the board is
// handed a decision, not a state to interpret.
update =
update.state.available
?.takeIf { update.state.nagging }
?.let {
BoardUpdate(
version = it.version,
busy = update.state.busy,
onInstall = update::downloadAndInstall,
onDismiss = update::dismissNag,
)
},
onDismissError = board::dismissError,
)
if (composing) {
ComposeSheet(
saving = board.state.saving,
onDismiss = { composing = false },
onSave = { content ->
board.create(content)
composing = false
},
)
}
}
}
// The sync screen has no back handler of its own, so one lives here. The
// editor keeps its own, because it has to save the open note before leaving.
BackHandler(enabled = showingSync) { showingSync = false }
BackHandler(enabled = showingTags) { showingTags = false }
}
/**
@@ -282,6 +388,36 @@ private fun ReminderAlarms(core: ThoughtSync) {
}
}
/**
* Looking for an app update without being asked.
*
* Until this existed, `check()` had exactly one caller: a button on the sync screen.
* So a new build was found only by someone who went looking for one, and the operator
* had to remember to go looking — which is the same as not being told.
*
* On coming forward rather than on a timer: it is the moment the person is present,
* and the view model rate-limits so flicking between two apps is not a re-check.
* Unlinked devices are skipped entirely — updates come from a linked server, and
* there is nothing to ask.
*/
@Composable
private fun AutomaticUpdate(
linked: Boolean,
onCheck: () -> Unit,
) {
var wanted by remember { mutableStateOf(false) }
ForegroundTransitions(onForeground = { wanted = true }, onBackground = {})
LaunchedEffect(wanted, linked) {
if (!wanted || !linked) return@LaunchedEffect
// Consumed here, so this fires once per trip to the foreground however many
// times the effect restarts. There is no suspension point before the call, so
// the block completes before the recomposition that would cancel it.
wanted = false
onCheck()
}
}
/**
* Syncing without being asked.
*
@@ -3,6 +3,7 @@ package com.fabledsword.thoughtsync
import android.app.Application
import android.util.Log
import com.fabledsword.thoughtsync.core.ThoughtSync
import com.fabledsword.thoughtsync.core.setClientAgent
/**
* Opens the shared Rust core once, for the process lifetime.
@@ -30,6 +31,14 @@ class ThoughtSyncApplication : Application() {
override fun onCreate() {
super.onCreate()
// Introduce this app to any server it links to, before anything can sync.
// The core cannot name us — the same crate is compiled into the desktop app,
// and it used to announce every phone as `thoughtsync-desktop` carrying the
// core crate's own version. "unknown" rather than a guess when the package
// manager will not say (note 3127 §5).
setClientAgent("thoughtsync-android", installedVersionName() ?: "unknown")
try {
val handle = ThoughtSync(filesDir.absolutePath)
core = handle
@@ -0,0 +1,264 @@
package com.fabledsword.thoughtsync.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
/**
* The note's body, as fields and checkboxes rather than as markup.
*
* The point of the whole shape: a box you can tick while looking at the note, rather
* than `- [ ] ` to read and edit around. What the note IS never changed.
*/
@Composable
fun BlockBody(
blocks: List<EditorBlock>,
readOnly: Boolean,
focus: Long?,
onChange: (List<EditorBlock>) -> Unit,
onFocus: (Long?) -> Unit,
modifier: Modifier = Modifier,
) {
// Focus is addressed by block ID, never by position — the id is the only thing
// about a block that survives one being inserted above it. Hoisted to the caller
// rather than kept here, because the TOOLBAR also asks for a focus when its button
// appends an item, and two owners of one cursor is one too many.
val requesters = remember { mutableMapOf<Long, FocusRequester>() }
LaunchedEffect(focus) {
val id = focus ?: return@LaunchedEffect
// Honoured after the composition that created the field: a FocusRequester not
// yet attached to anything throws when asked.
requesters[id]?.requestFocus()
onFocus(null)
}
fun replace(
index: Int,
block: EditorBlock,
) = onChange(blocks.toMutableList().also { it[index] = block })
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) {
blocks.forEachIndexed { index, block ->
val requester = requesters.getOrPut(block.id) { FocusRequester() }
if (block.isTask) {
TaskBlock(
block = block,
readOnly = readOnly,
requester = requester,
onChange = { replace(index, it) },
onEnter = {
val next = blocks.nextId()
onChange(afterEnter(blocks, index, next))
// The new item if there was one; otherwise the block that just
// became prose, which keeps the caret where the person left it.
onFocus(if (blocks[index].value.text.isBlank()) block.id else next)
},
onDelete = {
val remaining = blocks.withoutIndex(index)
onChange(remaining)
// The row above — or, for the FIRST row, whichever one takes
// its place. `index - 1` alone is -1 there, which left the
// keyboard up with nothing focused.
onFocus(remaining.getOrNull((index - 1).coerceAtLeast(0))?.id)
},
)
} else {
ProseBlock(
block = block,
readOnly = readOnly,
requester = requester,
onChange = { replace(index, it) },
onBlur = {
// Compared by IDENTITY, not equality: `promotingTasks` hands
// back the same list when there was nothing to promote, and a
// blur that changed nothing must not touch the state at all.
val promoted = blocks.promotingTasks(index)
if (promoted !== blocks) onChange(promoted)
},
)
}
}
}
}
/**
* A run of prose: one ordinary multi-line field, exactly as the editor always had.
*
* Leaving it is when a `- [ ] ` typed by hand becomes a real checklist item — see
* [promotingTasks] for why blur is the only safe moment to do that.
*
* `onFocusChanged` also fires with `isFocused = false` on the first composition, before
* the field has ever held focus. Deliberately not guarded: [splitBlocks] ran when the
* editor opened, so a prose block nobody has typed in cannot contain a task line, and
* the promotion is a no-op that the caller's identity check drops on the floor.
*/
@Composable
private fun ProseBlock(
block: EditorBlock,
readOnly: Boolean,
requester: FocusRequester,
onChange: (EditorBlock) -> Unit,
onBlur: () -> Unit,
) {
BlockField(
value = block.value,
onValueChange = { onChange(block.copy(value = it)) },
modifier =
Modifier
.focusRequester(requester)
.onFocusChanged { if (!it.isFocused) onBlur() },
enabled = !readOnly,
hint = R.string.editor_body_hint,
)
}
/**
* One checklist item: a real box, and the item's text beside it.
*
* Single-line with [ImeAction.Next], which is what turns the keyboard's return key
* into "next item" — the reason a list can be typed straight through rather than a
* marker at a time.
*/
@Composable
private fun TaskBlock(
block: EditorBlock,
readOnly: Boolean,
requester: FocusRequester,
onChange: (EditorBlock) -> Unit,
onEnter: () -> Unit,
onDelete: () -> Unit,
) {
// Material sizes every interactive component to a 48dp touch target, and on a
// checklist that IS the row height — which is why six items filled a phone screen
// even after the field's own padding came off.
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides ROW_TOUCH) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = block.checked == true,
onCheckedChange = { onChange(block.copy(checked = it)) },
enabled = !readOnly,
)
BlockField(
value = block.value,
onValueChange = { onChange(block.copy(value = it)) },
modifier = Modifier.weight(1f).focusRequester(requester),
enabled = !readOnly,
singleLine = true,
textStyle =
MaterialTheme.typography.bodyLarge.copy(
// Struck through when done, matching the card and the web.
textDecoration =
if (block.checked == true) TextDecoration.LineThrough else null,
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { onEnter() }),
)
if (!readOnly) {
IconButton(onClick = onDelete) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.editor_remove_item),
)
}
}
}
}
}
/**
* The touch target for a checklist row's controls.
*
* Material's floor is 48dp and this is deliberately under it. That floor is sized for
* a control somebody has to find; a checklist box sits in a predictable column with an
* identical box directly above and below, and the cost of a near miss is ticking the
* neighbouring item — visible, and undone by tapping again. Trading twelve of those
* dp for a list that fits on a screen is what was asked for, twice.
*/
private val ROW_TOUCH = 36.dp
/**
* The field a block is typed into.
*
* `BasicTextField`, not the Material one [PlainTextField] wraps, and the reason is
* density. Material's TextField puts 16dp above and below its text — padding that
* makes a FORM field comfortable to hit, and that on a checklist IS the row height. It
* made six items twice as tall as the six items, which is what the operator saw.
*
* Nothing is lost by dropping down a layer. `PlainTextField` exists to strip a
* container and an indicator; `BasicTextField` never had either, so there is no box
* here to drift back into existence. What it does not supply and this must:
*
* - the text COLOUR. It defaults to `Color.Unspecified`, which draws BLACK — the same
* default that made the editor's toolbar invisible in dark mode. Set, not inherited.
* - the cursor brush, which would otherwise be black for the same reason.
* - the placeholder, which is a plain Text behind the field rather than a slot.
*
* `enabled = false` deliberately does not grey the text out: a trashed note renders
* read-only through this and its words are meant to be READ.
*/
@Composable
private fun BlockField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
singleLine: Boolean = false,
@StringRes hint: Int? = null,
textStyle: TextStyle = MaterialTheme.typography.bodyLarge,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
val style = textStyle.copy(color = MaterialTheme.colorScheme.onSurface)
Box(modifier = modifier) {
if (hint != null && value.text.isEmpty()) {
Text(
text = stringResource(hint),
style = style,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
enabled = enabled,
singleLine = singleLine,
textStyle = style,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
)
}
}
@@ -6,11 +6,14 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
@@ -23,6 +26,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CircularProgressIndicator
@@ -37,6 +41,11 @@ import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
@@ -44,7 +53,11 @@ import androidx.compose.material3.pulltorefresh.pullToRefresh
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -62,12 +75,55 @@ fun BoardScreen(
onOpenNote: (Note) -> Unit,
sync: BoardSync,
onOpenSync: () -> Unit,
onManageTags: () -> Unit,
onSearch: (String) -> Unit,
onCompose: () -> Unit,
onToggleItem: (Note, Int, Boolean) -> Unit,
onNoteAction: (Note, EditorAction) -> Unit,
update: BoardUpdate?,
onDismissError: () -> Unit,
) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val snackbars = remember { SnackbarHostState() }
// Held HERE rather than on the card. A card lives in a lazy grid and is disposed
// the moment it scrolls out of view, which would take its dialog down with it —
// and the board can scroll under an open dialog.
var confirmingDelete by remember { mutableStateOf<Note?>(null) }
// Resolved in composition, not inside the coroutine: `stringResource` is a
// composable read and cannot be called from a suspend block.
val trashedMessage = stringResource(R.string.board_trashed)
val undoLabel = stringResource(R.string.board_undo)
// Trash gets an UNDO rather than a confirmation, and the two are not
// interchangeable. A long press is a gesture you can make by accident — resting a
// thumb while reading is enough — so the mistake worth designing for is the one
// nobody meant to make, and a dialog only helps someone who is paying attention
// in the moment they were not. Trash is already recoverable; the snackbar just
// says so where it happened, instead of leaving you to find the Trash view and
// work out which note went missing.
//
// Delete forever keeps its dialog. That one does not undo.
val onCardAction: (Note, EditorAction) -> Unit = { note, action ->
onNoteAction(note, action)
if (action == EditorAction.Trash) {
scope.launch {
val outcome =
snackbars.showSnackbar(
message = trashedMessage,
actionLabel = undoLabel,
duration = SnackbarDuration.Short,
)
// `note` is the pre-trash copy and deliberately so: Restore only needs
// its id, and the id is the one thing trashing does not change.
if (outcome == SnackbarResult.ActionPerformed) {
onNoteAction(note, EditorAction.Restore)
}
}
}
}
ModalNavigationDrawer(
drawerState = drawerState,
@@ -84,10 +140,30 @@ fun BoardScreen(
onOpenSync()
scope.launch { drawerState.close() }
},
onManageTags = {
onManageTags()
scope.launch { drawerState.close() }
},
)
},
) {
Scaffold(
// The IME, added to what the Scaffold already insets for. `enableEdgeToEdge`
// makes the manifest's `adjustResize` a no-op on API 30+, so nothing resizes
// for the keyboard unless the app asks — and `ScaffoldDefaults.contentWindowInsets`
// is systemBars, which the IME is not part of. The Scaffold positions the FAB
// AND the snackbar host from this value, so without it both sit behind the
// keyboard whenever the search field has focus. That is not theoretical: the
// undo on a trashed search hit is exactly the control you cannot reach.
//
// `union` rather than `add` — the two are the same edge, not two stacked ones.
// Adding them would inset by the navigation bar a second time underneath a
// keyboard that already covers it.
//
// One owner for the edge, as with the search bar's missing statusBarsPadding:
// set here, the content Column gets it through `padding` and must not repeat it.
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
snackbarHost = { SnackbarHost(snackbars) },
floatingActionButton = {
// The + is the ONLY way in, by design: one obvious target rather
// than a capture bar and a button competing for the same job.
@@ -117,6 +193,17 @@ fun BoardScreen(
ErrorBanner(message = message, onDismiss = sync.onDismissError)
}
// Below the failures and above the notes: an update is worth saying,
// and never worth saying before a note failed to save.
update?.let {
UpdateBanner(
version = it.version,
busy = it.busy,
onInstall = it.onInstall,
onDismiss = it.onDismiss,
)
}
// Only where someone is already thinking about reminders. On the
// main board it would nag people who have never set one.
if (state.destination == Destination.Reminders) ReminderNotice()
@@ -140,7 +227,14 @@ fun BoardScreen(
when {
state.loading -> LoadingBoard()
state.notes.isEmpty() -> EmptyBoard(state)
else -> NoteBoard(notes = state.notes, onOpenNote = onOpenNote)
else ->
NoteBoard(
notes = state.notes,
onOpenNote = onOpenNote,
onToggleItem = onToggleItem,
onNoteAction = onCardAction,
onConfirmDelete = { confirmingDelete = it },
)
}
// `PullToRefreshBox` would be less code, but it takes no
// `enabled`, so the modifier and the indicator are wired by
@@ -153,6 +247,16 @@ fun BoardScreen(
}
}
}
confirmingDelete?.let { note ->
ConfirmDeleteDialog(
onConfirm = {
confirmingDelete = null
onNoteAction(note, EditorAction.DeleteForever)
},
onDismiss = { confirmingDelete = null },
)
}
}
}
@@ -181,6 +285,25 @@ data class BoardSync(
val onDismissError: () -> Unit,
)
/**
* The waiting app update, or null when there is nothing to say.
*
* A holder rather than five loose parameters, for the same reason [BoardSync] is one:
* `version` and a pair of booleans as positional arguments could be swapped with
* nothing to catch it.
*
* Null covers every reason there is nothing to show — unlinked, up to date, found but
* not yet downloaded, dismissed for this sitting — so the board never has to know
* which.
*/
data class BoardUpdate(
val version: String,
/** An install is in flight — the banner stays and reports it. */
val busy: Boolean,
val onInstall: () -> Unit,
val onDismiss: () -> Unit,
)
/**
* A search field IS the top bar, following the phone convention rather than the
* desktop's title-plus-sidebar.
@@ -250,6 +373,7 @@ private fun NavigationDrawer(
syncSummary: String?,
onOpen: (Destination) -> Unit,
onOpenSync: () -> Unit,
onManageTags: () -> Unit,
) {
ModalDrawerSheet {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
@@ -263,18 +387,36 @@ private fun NavigationDrawer(
DrawerRow(destination, current, onOpen)
}
if (labels.isNotEmpty()) {
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
// The header renders even with no tags, unlike the rows below it: the
// manage screen is where you go to MAKE the first one, and hiding the
// way in until one exists would be a door that appears only once you
// are already inside. It is an action ON the section rather than a row
// in it, so it cannot be mistaken for one more lens.
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 28.dp, end = 16.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.nav_labels),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, bottom = 4.dp),
modifier = Modifier.weight(1f),
)
labels.forEach { label ->
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
IconButton(onClick = onManageTags) {
Icon(
Icons.Filled.Edit,
contentDescription = stringResource(R.string.tags_manage),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
labels.forEach { label ->
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
listOf(Destination.Archive, Destination.Trash).forEach { destination ->
@@ -323,6 +465,9 @@ private fun DrawerRow(
private fun NoteBoard(
notes: List<Note>,
onOpenNote: (Note) -> Unit,
onToggleItem: (Note, Int, Boolean) -> Unit,
onNoteAction: (Note, EditorAction) -> Unit,
onConfirmDelete: (Note) -> Unit,
) {
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(BOARD_COLUMNS),
@@ -336,7 +481,13 @@ private fun NoteBoard(
// rebuilding them — and so a newly captured note slides in instead of
// making every card below it flicker.
items(items = notes, key = { it.id }) { note ->
NoteCard(note = note, onOpen = { onOpenNote(note) })
NoteCard(
note = note,
onOpen = { onOpenNote(note) },
onToggleItem = { index, checked -> onToggleItem(note, index, checked) },
onAction = { onNoteAction(note, it) },
onConfirmDelete = { onConfirmDelete(note) },
)
}
}
}
@@ -68,6 +68,16 @@ data class BoardState(
* has to re-query to see its own change.
*/
val editing: Note? = null,
/**
* Bumped each time the editor is opened on a DIFFERENT note, and deliberately
* not when the note it is already on changes.
*
* The editor keys its text field on this rather than on `editing.id`, because a
* draft's id changes the instant it is first saved — and re-keying on that would
* reset the field to whatever the store just returned, discarding anything typed
* during the write. That is a data-loss bug rather than a flicker.
*/
val editingSession: Long = 0,
) {
/** Search overrides the destination while there is a query to run. */
val searching: Boolean get() = query.isNotBlank()
@@ -113,7 +123,7 @@ class BoardViewModel(
init {
refresh()
loadLabels()
refreshLabels()
}
fun open(destination: Destination) {
@@ -152,13 +162,34 @@ class BoardViewModel(
is Destination.WithLabel -> core.listNotes(query(VIEW_NOTES, labelId = destination.id))
}
private fun loadLabels() {
/**
* Reload the drawer's tags, and leave a lens whose tag no longer exists.
*
* Public because the Tags screen owns operations this board cannot see: a
* delete or a merge removes a tag, and the board may be LOOKING at that tag —
* `Destination.WithLabel` holds an id, and a query for a deleted one returns
* nothing forever. Without the fallback, tidying up tags could strand the board
* on a permanently empty lens whose only escape is the drawer.
*
* A rename needs no fallback: the id survives, and re-listing gives the drawer
* the new name. A rename that MERGED is a delete of one of the two, which this
* catches by id like any other.
*/
fun refreshLabels() {
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
.onSuccess { state = state.copy(labels = it) }
.onSuccess { labels ->
state = state.copy(labels = labels)
val lens = state.destination
if (lens is Destination.WithLabel && labels.none { it.id == lens.id }) {
open(Destination.Notes)
}
}
// A drawer that cannot list labels is a degraded drawer, not a
// broken board — the notes are still there. Failing quietly here
// beats an error banner over working content.
// beats an error banner over working content. The lens is left
// alone in this case on purpose: "I could not read the tags" is not
// evidence that this one is gone.
.onFailure { state = state.copy(labels = emptyList()) }
}
}
@@ -188,44 +219,6 @@ class BoardViewModel(
}
}
/**
* Save a new note or list.
*
* Blank input is ignored rather than rejected: an empty save is a slip, not a
* mistake worth interrupting someone over.
*/
fun create(content: String) {
val cleanContent = content.trim()
if (cleanContent.isEmpty()) return
viewModelScope.launch {
state = state.copy(saving = true)
state =
try {
val created = withContext(Dispatchers.IO) { core.createNote(draft(cleanContent)) }
// Prepend rather than reload: the new note belongs at the top
// of the board, and a full re-query would cost a round trip to
// tell us what we already know. Skipped when the board is not
// showing plain notes — a note created while looking at Trash
// does not belong in that list.
val notes =
if (state.destination == Destination.Notes && !state.searching) {
listOf(created) + state.notes
} else {
state.notes
}
// A capture sheet can carry a reminder in its text one day;
// more to the point, this is a store write and the rule here is
// that every store write re-derives the alarm rather than each
// call site deciding whether its particular write could matter.
withContext(Dispatchers.IO) { onRemindersChanged() }
state.copy(notes = notes, saving = false, error = null)
} catch (e: Exception) {
state.copy(saving = false, error = e.message ?: FALLBACK_ERROR)
}
}
}
// ─────────────────────────────── the editor ──────────────────────────────
/**
@@ -238,12 +231,137 @@ class BoardViewModel(
fun openNoteById(id: String) {
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { core.getNote(id) } }
.onSuccess { state = state.copy(editing = it) }
.onSuccess { state = state.copy(editing = it, editingSession = state.editingSession + 1) }
}
}
fun openNote(note: Note) {
state = state.copy(editing = note)
state = state.copy(editing = note, editingSession = state.editingSession + 1)
}
/**
* Open the editor on a note that does not exist yet.
*
* The + button used to raise a separate capture sheet, which meant a note being
* WRITTEN could not be given a colour, a reminder or a checklist — those live on
* the editor's toolbar, and the sheet had none. Writing and editing are now the
* same surface.
*
* The draft is a real [Note] carrying [DRAFT_ID] rather than a null, so the
* editor renders it without knowing that "not saved yet" is a state it can be
* in. It becomes a row on its first save; see [onDraftAction].
*/
fun compose() {
draftDismissed = false
state = state.copy(editing = blankDraft(), editingSession = state.editingSession + 1)
}
/**
* Capture text shared into the app from somewhere else, and open it.
*
* The note is CREATED here rather than opened as a pre-filled draft, and that
* is the whole design of this path. The editor only flushes when its text
* differs from the note it was handed (`NoteEditorScreen`'s `flush`), so a
* draft arriving already full of the shared text is a draft with nothing to
* save — share a link, press back without typing, and it would be gone. A
* share has already said "keep this"; making the row first is what honours it.
*
* Opening the editor afterwards is then free of that risk: the note exists,
* back leaves it alone, and adding a line of context is optional rather than
* load-bearing.
*/
fun captureShared(text: String) {
val content = text.trim()
if (content.isEmpty()) return
// A share is a new sitting even if the editor was already open on
// something, so the field must be re-keyed onto what arrives. `createFrom
// Draft` deliberately does not bump this — it is written for the autosave
// case, where re-keying mid-typing would be the bug.
draftDismissed = false
state = state.copy(editingSession = state.editingSession + 1)
createFromDraft(content)
}
/**
* Set when a draft's editor closes, so a create still in flight does not reopen
* it. The editor flushes its text and then closes, and the flush is a coroutine —
* without this the note would be created, the screen would close, and the create
* would finish and put the screen back.
*/
private var draftDismissed = false
/**
* The editor's actions, for a note that has no row yet.
*
* Everything a toolbar button does needs an id to act on, so the first action
* that needs one creates the note and replays itself against the real thing.
*/
private fun onDraftAction(
draft: Note,
action: EditorAction,
) {
when (action) {
// Nothing exists, so leaving leaves nothing behind — which is what makes
// tapping + and changing your mind free. Text typed before this point has
// already gone to createFromDraft via the editor's autosave or its flush.
EditorAction.Close, EditorAction.Trash -> {
draftDismissed = true
state = state.copy(editing = null)
}
EditorAction.DismissError -> dismissError()
is EditorAction.SaveText -> createFromDraft(action.body)
// Colour, reminder, pin, labels: attributes OF a note, so there has to be
// a note. With autosave at a second, "typed something" is true by the time
// anyone reaches the toolbar; before that there is nothing to attribute.
else -> createFromDraft(draft.body) { created -> onEditorAction(created, action) }
}
}
/**
* Turn a draft into a row, and keep the editor on it.
*
* Adopting the created note is what lets a session of autosaves stay one note:
* the second save sees a real id and updates rather than creating again.
*/
private fun createFromDraft(
content: String,
allowEmpty: Boolean = false,
then: (Note) -> Unit = {},
) {
val cleanContent = content.trim()
// A blank draft is not a note. Ignored rather than rejected: tapping + and
// walking away is a slip, not a mistake worth interrupting someone over.
if (cleanContent.isEmpty() && !allowEmpty) return
viewModelScope.launch {
state = state.copy(saving = true)
state =
try {
val created = withContext(Dispatchers.IO) { core.createNote(draft(cleanContent)) }
// Prepend rather than reload: the new note belongs at the top of
// the board, and a full re-query would cost a round trip to tell
// us what we already know. Skipped when the board is not showing
// plain notes — a note created while looking at Trash does not
// belong in that list.
val notes =
if (state.destination == Destination.Notes && !state.searching) {
listOf(created) + state.notes
} else {
state.notes
}
withContext(Dispatchers.IO) { onRemindersChanged() }
// editingSession is NOT bumped: this is the same sitting, and the
// editor's field must not be re-keyed underneath the typing.
state.copy(
notes = notes,
editing = if (draftDismissed) state.editing else created,
saving = false,
error = null,
)
} catch (e: Exception) {
state.copy(saving = false, error = e.message ?: FALLBACK_ERROR)
}
if (!draftDismissed) state.editing?.let(then)
}
}
/**
@@ -268,18 +386,21 @@ class BoardViewModel(
note: Note,
action: EditorAction,
) {
if (note.id == DRAFT_ID) {
onDraftAction(note, action)
return
}
val id = note.id
when (action) {
EditorAction.Close -> state = state.copy(editing = null)
EditorAction.DismissError -> dismissError()
// Saved on close rather than per keystroke, so a session of typing
// costs one write and one revision snapshot.
// Sent on an idle debounce while typing, and again on close. Writing
// this often is affordable because a body write no longer snapshots a
// revision — the core keeps one per editing session, not one per save.
is EditorAction.SaveText ->
mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) }
is EditorAction.SetColor -> edit(id, NoteEdit.Color(action.color))
// Pinning re-sorts the board rather than emptying it, and on a phone
// you often pin while still reading — so unlike the three below, it
// deliberately leaves the editor open.
@@ -302,20 +423,6 @@ class BoardViewModel(
null
}
// An empty first item: the checklist editor appears the moment the note
// has one, and an empty row is what someone can type straight into.
EditorAction.AddChecklist -> mutate { it.addItem(id, "") }
is EditorAction.AddItem ->
action.text.trim().takeIf { it.isNotEmpty() }?.let { text ->
mutate { it.addItem(id, text) }
}
is EditorAction.SetItemChecked ->
mutate { it.setItemChecked(id, action.itemId, action.checked) }
is EditorAction.SetItemText ->
mutate { it.setItemText(id, action.itemId, action.text) }
is EditorAction.DeleteItem -> mutate { it.deleteItem(id, action.itemId) }
is EditorAction.SetLabels -> mutate { it.setNoteLabels(id, action.labelIds) }
is EditorAction.CreateLabel ->
@@ -327,7 +434,7 @@ class BoardViewModel(
}
// The drawer lists labels with their note counts, and both
// just changed.
loadLabels()
refreshLabels()
}
is EditorAction.SetReminder -> edit(id, NoteEdit.RemindAt(action.at))
@@ -351,9 +458,10 @@ class BoardViewModel(
/**
* The one path every store mutation takes.
*
* Each core mutation returns the reloaded note, which goes straight into
* [BoardState.editing] so an open editor shows its own change without a
* re-query. The BOARD list is then reloaded rather than patched in place:
* Each core mutation returns the reloaded note, which refreshes
* [BoardState.editing] so an OPEN editor shows its own change without a
* re-query — and does nothing at all when the editor is closed, because that
* field doubles as "which screen is up". The BOARD list is then reloaded rather than patched in place:
* pinning re-sorts it, archiving removes the note from it, and adding a label
* can move it in or out of a label view — a splice would have to reimplement
* the core's ordering and membership rules in Kotlin to get any of that right.
@@ -364,9 +472,13 @@ class BoardViewModel(
* correct-until-a-moment-ago content, and flashing it empty would be a worse
* lie than showing it one frame stale.
*
* Search results are left alone — they are the answer to a query, not a live
* view, and re-running the board query underneath them would replace the hits
* with the whole board.
* While a search is running the QUERY is re-run rather than the board's
* destination — running `load` here would replace the hits with the whole
* board, which is why this branch exists at all. It used to keep the existing
* list instead, and that was right for a note whose place in the pile changed
* and wrong for one that left it: trashing a hit left the card sitting there,
* with a snackbar saying it was gone, until the query happened to re-run
* (#3111). Re-asking is still the answer to the query, just a current one.
*/
private fun mutate(
closeEditor: Boolean = false,
@@ -378,10 +490,8 @@ class BoardViewModel(
try {
val updated = withContext(Dispatchers.IO) { block(core) }
val notes =
if (state.searching) {
state.notes
} else {
withContext(Dispatchers.IO) { load(state.destination) }
withContext(Dispatchers.IO) {
if (state.searching) core.searchNotes(state.query) else load(state.destination)
}
// On IO, not here: re-deriving the alarm reads every note
// that carries a reminder, and this line runs on the main
@@ -389,7 +499,12 @@ class BoardViewModel(
withContext(Dispatchers.IO) { onRemindersChanged() }
state.copy(
notes = notes,
editing = if (closeEditor) null else updated ?: state.editing,
// Only REFRESHES an open editor; it must never open one.
// `editing != null` IS "the editor is on screen", so writing
// the reloaded note in unconditionally meant any mutation
// started from the BOARD threw the editor open on top of it —
// which is exactly what ticking a checkbox on a card did.
editing = if (closeEditor) null else state.editing?.let { updated ?: it },
saving = false,
error = null,
)
@@ -402,6 +517,21 @@ class BoardViewModel(
}
}
/**
* Tick or untick one item from the BOARD, without opening the note.
*
* The common gesture on a checklist, and the reason it goes through the store
* rather than the pure text helpers the editor uses: nothing here is holding a
* half-typed body, so the reloaded note is simply the truth.
*
* `index` is the item's ordinal, which is what its id is now (M304).
*/
fun toggleItem(
note: Note,
index: Int,
checked: Boolean,
) = mutate { it.setItemChecked(note.id, index.toString(), checked) }
fun dismissError() {
state = state.copy(error = null)
}
@@ -428,9 +558,6 @@ class BoardViewModel(
}
}
/** The palette key a note starts on, matching the web and the desktop. */
private const val DEFAULT_COLOR = "default"
// ── pure builders ───────────────────────────────────────────────────────────
//
// Neither of these reads or writes view-model state; they only shape a core input
@@ -446,4 +573,33 @@ private fun draft(content: String): NoteDraft =
// The core names the note from the body's first line, so a captured thought is
// findable without anyone being asked to name it. A checklist is added afterwards,
// in the editor — it is something a note HAS, not a different thing to capture.
NoteDraft(body = content, color = DEFAULT_COLOR, items = null)
NoteDraft(body = content, items = null)
/**
* The id a note has before it has been saved.
*
* A real id is a uuid, so the empty string cannot collide with one. Using a sentinel
* rather than making the editor's note nullable keeps "not saved yet" out of a screen
* that reads eight fields off the note and should not have to null-check any of them.
*/
internal const val DRAFT_ID = ""
private fun blankDraft(): Note =
Note(
id = DRAFT_ID,
displayTitle = "",
body = "",
position = 0,
pinned = false,
archived = false,
trashed = false,
deletedAt = null,
remindAt = null,
recurrence = null,
labels = emptyList(),
items = emptyList(),
attachments = emptyList(),
previews = emptyList(),
createdAt = null,
updatedAt = null,
)
@@ -1,130 +0,0 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
/**
* The new-note surface, opened by the + button.
*
* A bottom sheet rather than a full screen: capture should feel like a quick aside
* from the board, not a place you navigate to and have to come back from. The
* board stays visible behind it, so the note lands somewhere you can already see.
*
* It asks note-or-list up front rather than making that a mode you discover later,
* because on a phone the two are genuinely different typing tasks and switching
* halfway is worse than choosing at the start.
*
* ## Leaving keeps what you wrote
*
* Every way out of this sheet except Discard SAVES: the save button, tapping the
* board behind it, swiping down, back, and the app being backgrounded. A sheet
* that throws away a typed thought because you touched outside it is a sheet that
* teaches people not to trust the app with a thought — and capture is the one
* place this product cannot afford that.
*
* The same shape the editor settled on, for the same reason, with one difference:
* capture also has to be abandonable, because tapping + and changing your mind is
* a normal thing to do. That is what Discard is, and it is the only path that
* loses anything. An empty draft needs neither — it is simply dropped, since a
* blank note nobody asked for is worse than no note at all.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ComposeSheet(
saving: Boolean,
onDismiss: () -> Unit,
onSave: (String) -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
// Saveable, not just remembered: a rotation mid-sentence is the same lost
// thought as a discarded one, and it was losing it before this.
var content by rememberSaveable { mutableStateOf("") }
val contentFocus = remember { FocusRequester() }
val written = content.isNotBlank()
val leave = { if (written) onSave(content) else onDismiss() }
// Straight into the one field there is. A capture is a thought, and every field
// someone has to tab past is the difference between "under a second" and not —
// which is why the title field is gone rather than merely skipped (M13 step 3).
LaunchedEffect(Unit) { contentFocus.requestFocus() }
// Backgrounding PERSISTS but does not close an empty sheet. Someone who tapped
// + and then got distracted should find the composer where they left it; the
// only reason to act here is that there is something to lose.
FlushOnStop { if (written) onSave(content) }
ModalBottomSheet(onDismissRequest = leave, sheetState = sheetState) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.imePadding()
.navigationBarsPadding(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// No note/list switch any more: there is one thing to capture. A
// checklist is added to a note in the editor, once there is a note.
PlainTextField(
value = content,
onValueChange = { content = it },
modifier = Modifier.focusRequester(contentFocus),
hint = R.string.compose_body_hint,
minLines = MIN_CONTENT_LINES,
)
SheetActions(
canSave = !saving && written,
onDiscard = onDismiss,
onSave = { onSave(content) },
)
}
}
}
@Composable
private fun SheetActions(
canSave: Boolean,
onDiscard: () -> Unit,
onSave: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
horizontalArrangement = Arrangement.End,
) {
// "Discard", not "Cancel". Cancel means "undo what I am doing", which is
// precisely what leaving no longer does — the word would now describe the
// one button it is NOT attached to.
TextButton(onClick = onDiscard) { Text(stringResource(R.string.compose_discard)) }
Button(onClick = onSave, enabled = canSave) {
Text(stringResource(R.string.compose_save))
}
}
}
private const val MIN_CONTENT_LINES = 4
@@ -0,0 +1,102 @@
package com.fabledsword.thoughtsync.ui
// The colour a LABEL wears when nobody picked one for it.
//
// Every `#tag` is born colourless, so without this a board of tags is a board of
// identical grey chips. Hashing the tag's NAME is deterministic, identical on every
// surface, costs no column and no migration, and a tag keeps its colour for life.
//
// THIS WAS THE CARD'S COLOUR TOO, ONCE. It is not any more (M315): a note's fill is
// one neutral and only its tags carry hue. The hash survived that removal because the
// job it still does — give a name a stable colour — was never the job that failed.
// What failed was asking a colour that means "which tag" to also mean nothing at all
// on an untagged note, at which point the board had two vocabularies and neither read.
//
// THIS IS HALF A MIRRORED PAIR. `frontend/src/notes/colors.ts` computes the same hash
// over the same key order, and the two must agree exactly or a tag is one colour on
// the phone and another in the browser. Same discipline as the checklist grammar's
// three implementations, and the same reason: a value that disagrees across surfaces
// is a bug you cannot unsee and cannot explain.
//
// NO COMPOSE IN THIS FILE, deliberately. It is the half of the pair that CAN be
// pinned by a host-JVM test, and staying free of `androidx.compose` is what keeps
// `DerivedTintTest` runnable in the Unit tests step rather than on an emulator. The
// web side has no test runner at all, so this test is the only mechanical guard the
// mirror gets — see the fixture comment in colors.ts.
/**
* The colours a derived hue can land on: `NOTE_TINTS`' keys minus `default`, which is
* the ABSENCE of a colour — a tag that derived it would be indistinguishable from one
* nobody has tagged. `gray` stays: as a chip it reads as a deliberate choice.
*
* Order is load-bearing and matches `DERIVED_TINT_KEYS` in colors.ts. Reordering this
* list silently recolours every tag on one surface only.
*/
val DERIVED_TINT_KEYS: List<String> =
listOf("red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray")
private const val FNV_OFFSET_BASIS = -0x7ee3623b // 0x811c9dc5 as a signed Int
private const val FNV_PRIME = 0x01000193
private const val BYTE_MASK = 0xFF
private const val UNSIGNED_MASK = 0xFFFFFFFFL
/**
* FNV-1a over the id's bytes, 32-bit.
*
* Chosen because both languages compute it identically in ten lines with no library.
* Explicitly NOT `String.hashCode()`: Kotlin's is specified but JS has no equivalent,
* and reimplementing Java's from memory in TypeScript is exactly how a mirror drifts.
*
* `and BYTE_MASK` is a no-op for the ASCII of a UUID, and is kept because it states
* the intent — this hashes BYTES, so the TypeScript side reading `charCodeAt(i) &
* 0xff` is the same function rather than a coincidence.
*
* Overflow is the point: Kotlin's `Int` wraps on multiply, which is what the web's
* `Math.imul` exists to reproduce.
*/
fun tintHash(id: String): Int {
var hash = FNV_OFFSET_BASIS
for (ch in id) {
hash = hash xor (ch.code and BYTE_MASK)
hash *= FNV_PRIME
}
return hash
}
/** The colour a name maps to, stable for as long as the name is. Called with a
* label's lowercased name; `id` is the parameter's history, not its meaning. */
fun derivedTint(id: String): String {
// Through Long to read the hash as unsigned. A signed remainder would be negative
// for half of all ids and index out of the list.
val index = (tintHash(id).toLong() and UNSIGNED_MASK) % DERIVED_TINT_KEYS.size
return DERIVED_TINT_KEYS[index.toInt()]
}
/**
* The colour key for a LABEL — its chip, and its `#tag` where it sits in the prose.
*
* Derived from the tag's NAME when nobody has picked one. Every `#tag` ever typed is
* `default`: the server mints one as `Label(owner_id=…, name=name)` with no colour,
* so without deriving, a board of tags would be a board of identical grey chips.
*
* DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing #2965's plan. That plan wanted
* a hashed colour written at each of the four places a label can be born — and named
* the risk itself: `find_or_create_label` is "easy to miss, and it is the common one",
* since most tags are born from typing `#grocery`, not from a management screen.
* Deriving has no mint points to miss and no backfill for the tags already out there.
* The cost is that renaming a tag recolours it, which is fair: the name IS the tag.
*
* Lowercased because tags dedupe case-insensitively — `#Todo` renamed to `#todo` is
* the same tag and should not change colour. Kotlin's `lowercase()` and the web's
* `toLowerCase()` are both locale-independent, so the mirror holds.
*/
fun resolvedLabelColor(
name: String,
color: String,
known: Set<String>,
): String =
when {
color.isNotEmpty() && color != "default" && color in known -> color
name.isEmpty() -> "default"
else -> derivedTint(name.lowercase())
}
@@ -25,19 +25,6 @@ sealed interface EditorAction {
val body: String,
) : EditorAction
data class SetColor(
val color: String,
) : EditorAction
/**
* Give this note a checklist.
*
* Not a conversion — a note HAS a checklist rather than BEING one (M13 step 2),
* so nothing moves and nothing is swapped: the body stays exactly where it is and
* the note gains a first, empty item for someone to type into.
*/
data object AddChecklist : EditorAction
data class SetPinned(
val pinned: Boolean,
) : EditorAction
@@ -52,23 +39,12 @@ sealed interface EditorAction {
data object DeleteForever : EditorAction
data class AddItem(
val text: String,
) : EditorAction
data class SetItemChecked(
val itemId: String,
val checked: Boolean,
) : EditorAction
data class SetItemText(
val itemId: String,
val text: String,
) : EditorAction
data class DeleteItem(
val itemId: String,
) : EditorAction
// No checklist actions at all any more (M304). An item is a `- [ ] ` line of the
// body, so adding, renaming, ticking or deleting one is editing text — which the
// editor already does, through SaveText, with the same autosave and the same
// revision window as any other edit. Routing them through the store would have
// meant the store handing back a note whose body disagreed with the field the
// person was typing in.
/**
* The note's MANUAL labels, replacing whatever was there.
@@ -0,0 +1,189 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.runtime.saveable.Saver
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.fabledsword.thoughtsync.core.checklistItems
import com.fabledsword.thoughtsync.core.checklistRender
/**
* One piece of a note body, as the editor DRAWS it.
*
* The note is still one markdown string underneath (M304) — this is a rendering and
* input shape, and nothing below the editor can tell it exists. [joinBlocks] puts the
* string back together on every edit.
*
* A run of prose lines is ONE block rather than one per line. Typing a paragraph has
* to feel like typing a paragraph, and a separate field under every sentence would
* break the caret in the middle of writing. Only a checklist item earns a block of its
* own, because only a checklist item needs a widget.
*
* The block owns its [TextFieldValue], not just its text, so a caret survives an edit
* to some other block. And [id] is stable across edits: Compose keys fields by
* position unless told otherwise, so inserting an item above one would otherwise move
* everyone's caret up a row. Content cannot serve as that key — two empty items are
* identical and neither is the other.
*/
data class EditorBlock(
val id: Long,
val value: TextFieldValue,
/** null for prose; ticked-or-not for a checklist item. */
val checked: Boolean?,
) {
val isTask: Boolean get() = checked != null
}
/**
* Split a body into blocks, numbering them from [firstId].
*
* Which lines are items comes from the core, not from a pattern here — the grammar is
* written three times already and Kotlin is not going to be the fourth.
*/
fun splitBlocks(
body: String,
firstId: Long = 0,
): List<EditorBlock> {
val itemAt = checklistItems(body).associateBy { it.line.toInt() }
val out = mutableListOf<EditorBlock>()
val prose = mutableListOf<String>()
var id = firstId
fun flushProse() {
if (prose.isNotEmpty()) {
out += EditorBlock(id++, TextFieldValue(prose.joinToString("\n")), null)
prose.clear()
}
}
body.split("\n").forEachIndexed { n, line ->
val item = itemAt[n]
if (item == null) {
prose += line
} else {
flushProse()
out += EditorBlock(id++, TextFieldValue(item.text), item.checked)
}
}
flushProse()
// Never empty: an empty note still needs one field to type into.
return out.ifEmpty { listOf(EditorBlock(id, TextFieldValue(""), null)) }
}
/**
* The body those blocks stand for — byte-identical to what [splitBlocks] was given,
* for a body already in canonical form. A non-canonical one (`- [X]`, an odd bullet)
* comes back canonical, which is the same rule every other rewriter in `derive`
* follows.
*/
fun joinBlocks(blocks: List<EditorBlock>): String =
blocks.joinToString("\n") { block ->
val checked = block.checked
if (checked == null) block.value.text else checklistRender(block.value.text, checked)
}
/**
* Rotation carries the TEXT and re-derives the shape.
*
* Blocks are not parcelable and their ids are meaningless across a process death, so
* the body string is the honest thing to save — it is the real state, and everything
* else about a block is derived from it.
*/
val blocksSaver: Saver<List<EditorBlock>, String> =
Saver(save = { joinBlocks(it) }, restore = { splitBlocks(it) })
/**
* What the return key does on a checklist item.
*
* `internal` rather than private because BlockBody.kt calls it. These three helpers
* are the block MODEL and the composables are the block UI — one file was doing both,
* which detekt noticed by counting functions before anybody noticed by reading.
*
* On one with words in it, a new empty item below. On an EMPTY one, the item becomes
* prose — which is how a list ENDS, and the same rule the plain text field used
* before this: without it a list is impossible to get out of.
*
* Deliberately appends rather than splitting at the caret. Splitting an item in two is
* a rarity, and the caret is at the end for every ordinary use of this key.
*/
internal fun afterEnter(
blocks: List<EditorBlock>,
index: Int,
newId: Long,
): List<EditorBlock> {
val block = blocks[index]
val out = blocks.toMutableList()
if (block.value.text.isBlank()) {
out[index] = block.copy(value = TextFieldValue(""), checked = null)
} else {
out.add(index + 1, EditorBlock(newId, TextFieldValue(""), false))
}
return out
}
/** Drop a block, leaving at least one field to type into. */
internal fun List<EditorBlock>.withoutIndex(index: Int): List<EditorBlock> {
val out = toMutableList().also { it.removeAt(index) }
return out.ifEmpty { listOf(EditorBlock(nextId(), TextFieldValue(""), null)) }
}
/** An id nothing else is using. Monotonic within a session, which is all it has to be. */
internal fun List<EditorBlock>.nextId(): Long = (maxOfOrNull { it.id } ?: -1L) + 1L
/**
* One more empty checklist item at the end, and the id to put the caret in.
*
* What the toolbar's checklist button does. It appends rather than inserting at the
* caret because a block editor has no single caret to insert at — the field that had
* focus may not even be the one being looked at by the time this runs.
*/
fun List<EditorBlock>.plusTask(): Pair<List<EditorBlock>, Long> {
val id = nextId()
return (this + EditorBlock(id, TextFieldValue(""), false)) to id
}
/**
* Re-read ONE prose block for `- [ ] ` lines somebody typed by hand.
*
* [splitBlocks] runs once, when the editor opens. After that the blocks are the state
* and nothing reads the body again — every edit travels the other way, through
* [joinBlocks]. So a marker typed by hand stayed literal text on screen until the note
* was closed and reopened, even though it was already a real item in storage and the
* card was already drawing a checkbox for it. The editor was the only place that
* disagreed.
*
* **On blur, and only the block being left.** There is no good moment to convert while
* someone is typing: re-splitting on a keystroke moves the caret out of the word being
* written, and converting the instant `- [ ]` is complete does it before the item has
* any text. Blur is the one moment the person has demonstrably finished with the block,
* so a re-split costs no caret and cannot catch a half-typed line.
*
* Returns THIS LIST, not an equal copy, when there was nothing to promote — the caller
* leans on that to leave the state alone, and a blur that changed nothing must not
* re-key every field below it.
*
* Non-canonical markers (`- [X]`, an odd bullet) come back canonical, exactly as they
* would have on reopen. That is the only case where this changes the body rather than
* only the way it is drawn.
*/
internal fun List<EditorBlock>.promotingTasks(index: Int): List<EditorBlock> {
val block = getOrNull(index)
if (block == null || block.isTask) return this
val split = splitBlocks(block.value.text, nextId())
// A single prose block back means there was nothing to promote. `splitBlocks` never
// returns an empty list, so `first()` is safe.
val changed = split.size > 1 || split.first().isTask
return if (changed) take(index) + split + drop(index + 1) else this
}
/**
* Put the caret at the end of the last block, for an editor that has just opened.
*
* Opening an existing note means continuing it, and a caret at offset zero would put
* the cursor before the first character of the wrong field.
*/
fun List<EditorBlock>.focusedAtEnd(): List<EditorBlock> {
if (isEmpty()) return this
val last = last()
return dropLast(1) + last.copy(value = last.value.copy(selection = TextRange(last.value.text.length)))
}
@@ -1,144 +0,0 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.ChecklistItem
import com.fabledsword.thoughtsync.core.Note
/**
* The checklist, with real checkboxes this time.
*
* The card renders glyphs because it is a preview; here every row is live. This is
* the other half of the answer to how a list gets typed on a phone: the capture
* sheet takes a whole list at once, one item per line, because at capture time the
* list is already in your head and a tap per row would be the slow part. The
* editor is where a list is REVISED, and revising is item-at-a-time — so this is
* where the per-row control lives.
*
* No empty state: a checklist with no items already shows the add row with its
* hint, which says the same thing an empty state would and can be typed into.
*/
@Composable
fun ChecklistEditor(
note: Note,
readOnly: Boolean,
onAction: (EditorAction) -> Unit,
) {
Column {
note.items.forEach { item ->
ChecklistRow(item = item, readOnly = readOnly, onAction = onAction)
}
if (!readOnly) {
AddItemRow(onAdd = { onAction(EditorAction.AddItem(it)) })
}
}
}
/**
* One row: a live checkbox, editable text, and a remove button.
*
* The text commits on FOCUS LOSS rather than per keystroke. Every commit is a
* store write that reloads the note, so per-keystroke saving would both hammer
* SQLite and race the reload against the next character.
*/
@Composable
private fun ChecklistRow(
item: ChecklistItem,
readOnly: Boolean,
onAction: (EditorAction) -> Unit,
) {
// Keyed by item id, so a reload after some OTHER row's edit doesn't reset the
// text being typed here.
var text by remember(item.id) { mutableStateOf(item.text) }
val commit = { if (text != item.text) onAction(EditorAction.SetItemText(item.id, text)) }
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = item.checked,
onCheckedChange = { onAction(EditorAction.SetItemChecked(item.id, it)) },
enabled = !readOnly,
)
PlainTextField(
value = text,
onValueChange = { text = it },
modifier =
Modifier
.weight(1f)
.onFocusChanged { if (!it.isFocused) commit() },
enabled = !readOnly,
singleLine = true,
textStyle =
MaterialTheme.typography.bodyLarge.copy(
// Struck through when done, matching the card and the web.
textDecoration = if (item.checked) TextDecoration.LineThrough else null,
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { commit() }),
)
if (!readOnly) {
IconButton(onClick = { onAction(EditorAction.DeleteItem(item.id)) }) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.editor_remove_item),
)
}
}
}
}
/**
* The always-present row at the bottom for adding an item.
*
* It clears but keeps focus after a submit, so a list can be typed straight
* through — "milk ⏎ eggs ⏎ bread" — rather than costing a tap between each. That
* is the same speed the capture sheet's one-item-per-line field buys, carried into
* the editor so refining a list never feels slower than making one.
*/
@Composable
private fun AddItemRow(onAdd: (String) -> Unit) {
var text by remember { mutableStateOf("") }
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.Add,
contentDescription = null,
modifier = Modifier.padding(horizontal = 12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
PlainTextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.weight(1f),
hint = R.string.editor_add_item,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(onDone = {
onAdd(text)
text = ""
}),
)
}
}
@@ -1,6 +1,6 @@
package com.fabledsword.thoughtsync.ui
import androidx.annotation.StringRes
import android.text.format.DateUtils
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
@@ -8,23 +8,29 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -39,7 +45,19 @@ import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Note
/**
* The editor's action bar, at the bottom where a thumb already is.
* The editor's action bar, along the top of the surface.
*
* It sits exactly where the capture sheet's drag handle used to. The handle cost
* this strip of screen and did nothing that a back gesture does not already do, so
* the strip carries the actions instead.
*
* Top rather than bottom, now that this one surface is used for WRITING as well as
* editing: the keyboard owns the bottom of the display for most of a note's life,
* so a bar down there spends its time riding on the IME. That is the right place
* for a send button and the wrong one for a colour picker, which is reached for
* between thoughts rather than at the end of them. The cost is honest — the top of
* a phone is further from a thumb than the bottom — and it buys a bar that does not
* move while you type.
*
* The three affordances with a permanent slot are the ones reached for while still
* writing — colour, reminder, note-or-list. Everything structural (pin, labels,
@@ -54,57 +72,184 @@ import com.fabledsword.thoughtsync.core.Note
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditorBottomBar(
fun EditorTopBar(
note: Note,
readOnly: Boolean,
tint: NoteTint,
onClose: () -> Unit,
onStartChecklist: () -> Unit,
onPicker: (Picker) -> Unit,
onConfirmDelete: () -> Unit,
onAction: (EditorAction) -> Unit,
) {
val dark = isSystemInDarkTheme()
BottomAppBar(containerColor = tint.background(dark)) {
if (!readOnly) {
// A dot in the note's CURRENT colour rather than a palette icon: it
// shows what the colour is as well as what the button does.
IconButton(onClick = { onPicker(Picker.COLOR) }) {
Box(
modifier =
Modifier
.size(SWATCH_DOT)
.clip(CircleShape)
.background(tint.chipBackground(dark))
.border(1.dp, tint.border(dark), CircleShape),
)
}
IconButton(onClick = { onPicker(Picker.REMINDER) }) {
TopAppBar(
title = {},
navigationIcon = {
// The only way out, and the only thing that needed a "save" button
// before writes became continuous. Leaving IS saving now, which is what
// the line in the bottom corner is there to say out loud.
IconButton(onClick = onClose) {
Icon(
Icons.Filled.Notifications,
contentDescription = stringResource(R.string.editor_reminder),
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.editor_back),
)
}
// Adds the first checklist item, which is what makes the checklist
// editor appear. Hidden once the note already has one — there is nothing
// left to add that the checklist's own "+" row doesn't do better.
if (note.items.isEmpty()) {
IconButton(onClick = { onAction(EditorAction.AddChecklist) }) {
},
actions = {
if (!readOnly) {
IconButton(onClick = { onPicker(Picker.REMINDER) }) {
Icon(
Icons.Filled.Notifications,
contentDescription = stringResource(R.string.editor_reminder),
)
}
// Inserts `- [ ] ` at the caret. Always available, and never hidden:
// a checklist is text now (M304), so there is no section to be
// already-showing and no reason a second list cannot start further
// down the same note.
IconButton(onClick = onStartChecklist) {
Icon(
Icons.AutoMirrored.Filled.List,
contentDescription = stringResource(R.string.editor_add_checklist),
)
}
}
}
OverflowMenu(
note = note,
readOnly = readOnly,
onPicker = onPicker,
onConfirmDelete = onConfirmDelete,
onAction = onAction,
)
},
// EXPLICIT, and not optional — the same lesson the old bottom bar learned.
// Material derives a bar's content colour from its container via
// contentColorFor(), which maps a colour-SCHEME ROLE to its `on-` pair and
// returns Unspecified for anything else. The card surface is a plain constant
// and not a role, so the icons drew with no colour filter: black vectors on a
// near-black bar, a toolbar that rendered the whole time and was invisible in
// dark mode. STILL TRUE with one neutral surface — it is the same kind of
// value, so this stays exactly as it is.
//
// onSurface for the actions too, not the default onSurfaceVariant: the bar has
// to read against the card rather than the board, and the muted variant does
// not have the contrast to spare.
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = noteCardSurface(dark),
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
),
)
}
Box(modifier = Modifier.weight(1f))
OverflowMenu(
note = note,
readOnly = readOnly,
onPicker = onPicker,
onConfirmDelete = onConfirmDelete,
onAction = onAction,
/**
* The footer: when the note was last written, and the way out.
*
* **Where the note stands.** There is no save button, and there should not be — a
* note is saved continuously, so a button offering to do what already happened is a
* lie with a tap attached. But that left nothing on screen saying the work is safe,
* and "closing this keeps it" is not a thing anyone should have to be told twice. So
* the state says it, as a fact rather than an instruction: Not saved yet → Saving… →
* Edited just now is the whole lifecycle, and someone who watches it once never has
* to wonder again.
*
* **The way out.** Down here because of where hands are. Moving the toolbar to the
* top took the back arrow with it, which left the only exit from a full-screen
* editor in the top-left corner — the furthest point on the display from a
* right-handed thumb, and reached over the whole note to get to. The operator hit
* that on the first device pass and was right to. So the exit lives in the bottom
* corner, which with the keyboard up sits directly above it.
*
* The top-left arrow stays as well. Two affordances for one action is usually
* clutter, but this is the case that earns it: the arrow is what habit, the system
* back gesture and TalkBack all expect of a full-screen surface, and removing it
* would strand the reflex to strike a duplicate that costs one icon slot.
*
* A checkmark, at the operator's ask. I had shipped the word "Done" here on the
* argument that a tick in a NOTES app reads as a checklist item; overruled, and the
* filled treatment is what settles it — a tonal button in the note's own colour is
* plainly a control, where a bare glyph beside a checklist would not be. It carries
* "Done" as its content description, so the reasoning survives where it actually
* mattered: read aloud.
*
* [DateUtils] rather than a hand-rolled formatter: it is localised, it already
* knows the difference between minutes, hours and yesterday, and getting plurals
* right in every language is not this app's problem to solve twice.
*/
@Composable
fun EditorFooter(
updatedAt: String?,
saving: Boolean,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier =
modifier
.fillMaxWidth()
// Rides above the keyboard, like the bar that used to be here. The
// content Column deliberately does not also inset for the IME:
// Scaffold measures this row at its lifted height and passes the
// inset down.
.imePadding()
.navigationBarsPadding()
.padding(horizontal = 12.dp, vertical = 4.dp),
// The gap is what keeps the timestamp from reading as the button's label.
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = savedLabel(updatedAt, saving),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
FilledTonalIconButton(
onClick = onClose,
// The BRAND, matching the board's compose FAB — the app's one existing
// statement of "this is the affirmative action here", now reused rather
// than a second one invented.
//
// This wore the note's own tint until M315, on the argument that it would
// otherwise be the one element on a tinted card ignoring the tint. There is
// no tint to ignore any more, and the alternative — Material's default
// secondaryContainer — is a baseline M3 colour this theme never sets, so
// taking the default would put an off-brand lilac in the corner of the
// editor.
colors =
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
),
) {
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.editor_done))
}
}
}
/** The three things the footer can be saying, in the order it says them. */
@Composable
private fun savedLabel(
updatedAt: String?,
saving: Boolean,
): String {
// No timestamp means no row yet — a draft opened by + and not typed into.
val at = updatedAt?.let { epochMillis(it) }
val now = System.currentTimeMillis()
return when {
saving -> stringResource(R.string.editor_saving)
at == null -> stringResource(R.string.editor_unsaved)
// DateUtils rounds anything under its minimum resolution to "0 minutes
// ago" — which is both odd-looking and precisely the moment this line is
// on screen for, since it is the moment right after a save lands.
now - at < DateUtils.MINUTE_IN_MILLIS ->
stringResource(R.string.editor_edited, stringResource(R.string.editor_just_now))
else ->
stringResource(
R.string.editor_edited,
DateUtils.getRelativeTimeSpanString(at, now, DateUtils.MINUTE_IN_MILLIS).toString(),
)
}
}
@@ -142,24 +287,6 @@ private fun OverflowMenu(
}
}
@Composable
private fun MenuItem(
@StringRes labelRes: Int,
onClose: () -> Unit,
onClick: () -> Unit,
) {
DropdownMenuItem(
text = { Text(stringResource(labelRes)) },
onClick = {
// Close BEFORE acting. An overflow menu left hanging over the sheet
// that just opened underneath it is the classic version of this bug,
// and doing it here means no call site can forget.
onClose()
onClick()
},
)
}
/**
* The note's labels, each removable.
*
@@ -177,19 +304,23 @@ fun EditorLabelRow(
val dark = isSystemInDarkTheme()
Column(modifier = Modifier.padding(top = 12.dp)) {
note.labels.forEach { label ->
val tint = noteTint(label.color)
val tint = labelTintFor(label.name, label.color)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 2.dp),
) {
Text(
text = label.name,
// `#` on every chip, matching the card. This row still shows the
// tags the BODY owns as well — it is the control surface, and the
// "from tag" hint beside one is what says why it has no cross.
text = "#${label.name}",
style = MaterialTheme.typography.labelLarge,
color = tint.chipForeground(dark),
color = tint.tagInk(dark),
modifier =
Modifier
.clip(CircleShape)
.background(tint.chipBackground(dark))
.border(1.dp, tint.chipBorder(dark), CircleShape)
.padding(horizontal = 10.dp, vertical = 4.dp),
)
if (label.viaTag) {
@@ -263,6 +394,5 @@ fun EditorReminderRow(
}
}
private val SWATCH_DOT = 22.dp
private const val SNOOZE_HOUR = 60L
private const val SNOOZE_DAY = 1440L
@@ -1,11 +1,7 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -13,21 +9,16 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
@@ -42,7 +33,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
@@ -57,80 +47,17 @@ import java.time.LocalTime
import java.time.ZoneId
import java.time.temporal.TemporalAdjusters
// The three things you pick rather than type: a colour, a set of labels, a time.
// The two things you pick rather than type: a set of labels, and a time.
//
// It was three. The colour sheet went with `note.color` in M315 — a card is one neutral
// surface now and colour lives on the tag, so the swatch grid was a control with nothing
// behind it.
//
// All bottom sheets rather than dialogs. A dialog takes the middle of the screen
// and asks to be dismissed; a sheet rises from the bottom, under the thumb, with
// the note still visible above it — which matters when the choice you are making
// is about the thing you are looking at.
/** The note palette, as swatches. Order and colours come from [NOTE_TINTS]. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ColorSheet(
selected: String,
onPick: (String) -> Unit,
onDismiss: () -> Unit,
) {
val dark = isSystemInDarkTheme()
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.navigationBarsPadding(),
) {
SheetTitle(R.string.color_picker_title)
// Chunked into fixed rows rather than a flow layout: ten swatches
// always lay out as two rows of five on every phone width, and a flow
// would reshuffle them between devices for no gain.
NOTE_TINTS.entries.chunked(SWATCHES_PER_ROW).forEach { row ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
row.forEach { (key, tint) ->
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier
.size(SWATCH_SIZE)
.clip(CircleShape)
.background(tint.background(dark))
.border(
// The selected swatch gets a heavier ring
// as well as a tick: on the pale tints the
// tick alone is nearly invisible.
if (key == selected) 2.dp else 1.dp,
if (key == selected) {
MaterialTheme.colorScheme.primary
} else {
tint.border(dark)
},
CircleShape,
).clickable(onClickLabel = tint.label) { onPick(key) },
) {
if (key == selected) {
Icon(
Icons.Filled.Check,
contentDescription = tint.label,
modifier = Modifier.size(18.dp),
)
}
}
}
// Pad a short final row so its swatches line up with the row
// above instead of spreading across the full width.
repeat(SWATCHES_PER_ROW - row.size) {
Box(modifier = Modifier.size(SWATCH_SIZE))
}
}
}
}
}
}
/**
* Every label, ticked where it is on the note.
*
@@ -461,8 +388,6 @@ private val RECURRENCE_RULES: List<Pair<String?, Int>> =
"yearly" to R.string.recurrence_yearly,
)
private const val SWATCHES_PER_ROW = 5
private const val EVENING_HOUR = 18
private const val MORNING_HOUR = 8
private val SWATCH_SIZE = 44.dp
private val LABEL_LIST_MAX_HEIGHT = 320.dp
@@ -0,0 +1,121 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.core.LinkPreview
import com.fabledsword.thoughtsync.core.Note
private val PREVIEW_RADIUS = 8.dp
/**
* A URL that is the WHOLE body, whitespace either side allowed.
*
* Mirrors `LONE_URL_RE` in `NoteCard.vue` deliberately — the two surfaces have to
* agree on what counts as "this note is a link", or the same note reads as a card
* on one and a paragraph on the other. Someone pasting a link rarely trims it,
* which is why the surrounding whitespace is tolerated rather than rejected.
*/
private val LONE_URL = Regex("""^\s*(https?://[^\s<>"'\]\)]+)\s*$""")
/**
* The preview for a note that is nothing but a URL, or null.
*
* Null covers three different situations that all render the same way — the body
* is not a lone URL, the server has not unfurled it yet, or it never could. The
* card falls back to showing the URL as text in every one of them, so it is never
* blank and the link is never unreachable.
*
* A note written on the phone and not yet synced is permanently in the middle
* case: the unfurl happens server-side (`unfurl_queue.py`) and arrives on a later
* pull. That is the honest behaviour and it has to look deliberate, which showing
* the URL does.
*/
fun loneUrlPreview(note: Note): LinkPreview? {
if (!LONE_URL.matches(note.body)) return null
val url = note.body.trim()
return note.previews.firstOrNull { it.url == url }
}
/** True when the body is a lone URL, whether or not a preview has arrived for it. */
fun isLoneUrl(note: Note): Boolean = LONE_URL.matches(note.body)
/**
* A fetched link preview, in one of two sizes.
*
* [compact] is a single row — one line of title and the site — for a URL mentioned
* *inside* a note that has its own words. The note is the thing; the link is a
* footnote to it. Full size is for a note that IS a URL, where the link is the
* note and a compact strip would be a card with nothing on it.
*
* No image, unlike the web's `LinkPreview.vue`. `image_url` is a REMOTE
* third-party address, so drawing it would have this app fetch from whatever host
* a link happens to point at — on a phone, on possibly metered data, and as the
* first image loading anywhere in this client. That is a decision about privacy
* and data use rather than a rendering detail, so the text card ships and the
* image is left to be asked for (Scribe #3307).
*/
@Composable
fun LinkPreviewCard(
preview: LinkPreview,
compact: Boolean,
modifier: Modifier = Modifier,
) {
// Every element of the Modifier chain stays on ONE line, which is why the shape
// and the two paddings are named first. `standard:chain-method-continuation`
// wants a `.` that follows a MULTILINE element glued to its closing paren —
// `).padding(…)` — which is unreadable, so the multiline element is avoided
// instead (Scribe #3110).
val shape = RoundedCornerShape(PREVIEW_RADIUS)
val padH = if (compact) 8.dp else 10.dp
val padV = if (compact) 6.dp else 8.dp
Column(
modifier =
modifier
.fillMaxWidth()
.clip(shape)
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
.padding(horizontal = padH, vertical = padV),
) {
preview.siteName?.takeIf { it.isNotBlank() }?.let { site ->
Text(
text = site.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
// The URL stands in for a missing title so the row always says SOMETHING
// about where it goes.
text = preview.title?.takeIf { it.isNotBlank() } ?: preview.url,
style = if (compact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// First thing to go when there is no room — the compact row is a footnote and
// a description would make it the loudest part of the card.
if (!compact) {
preview.description?.takeIf { it.isNotBlank() }?.let { body ->
Text(
text = body,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@@ -3,8 +3,10 @@ package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -12,122 +14,373 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.ChecklistItem
import com.fabledsword.thoughtsync.core.BodyItem
import com.fabledsword.thoughtsync.core.Note
import com.fabledsword.thoughtsync.core.NoteLabel
import com.fabledsword.thoughtsync.core.bodyTags
import com.fabledsword.thoughtsync.core.checklistItems
@Composable
fun NoteCard(
note: Note,
onOpen: () -> Unit,
onToggleItem: (Int, Boolean) -> Unit,
onAction: (EditorAction) -> Unit,
onConfirmDelete: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = noteTint(note.color)
val haptics = LocalHapticFeedback.current
var menuOpen by remember { mutableStateOf(false) }
Column(
modifier =
Modifier
.fillMaxWidth()
// Clipped BEFORE clickable, so the ripple is bounded by the card's
// rounded corners instead of a rectangle overhanging them.
.clip(RoundedCornerShape(CARD_RADIUS))
.clickable(onClickLabel = stringResource(R.string.board_open_note), onClick = onOpen)
.background(tint.background(dark))
.border(1.dp, tint.border(dark), RoundedCornerShape(CARD_RADIUS))
.padding(12.dp),
) {
// Body then checklist, in order — a note can carry both (M13 step 2), and
// nothing above them: the first line of the body IS the note's name, at the
// same weight as the rest of it (M13 steps 3 and 4).
if (note.body.isNotBlank()) {
Text(
text = note.body,
style = MaterialTheme.typography.bodyMedium,
maxLines = MAX_PREVIEW_LINES,
overflow = TextOverflow.Ellipsis,
)
}
if (note.items.isNotEmpty()) {
if (note.body.isNotBlank()) Spacer(Modifier.height(4.dp))
Checklist(items = note.items)
// NAMED rather than written inline in the chain below, and not for taste: ktlint's
// chain-method-continuation wants the next `.` glued to the closing paren of a
// multiline element — `).background(…)` — which is worse to read than a modifier
// with a name. Every other multiline element in this codebase happens to be last
// in its chain, so this is the first place the rule bites.
val opening =
Modifier.combinedClickable(
onClickLabel = stringResource(R.string.board_open_note),
onLongClickLabel = stringResource(R.string.board_note_actions),
onLongClick = {
// Fired HERE rather than when the menu appears. A long press is
// confirmed by the system before the popup has laid out, and the whole
// point of the buzz is to say "that registered" at the moment your
// finger has been still long enough — a menu that arrives with no tick
// under it reads as a phone that missed the gesture and then changed
// its mind.
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
menuOpen = true
},
onClick = onOpen,
)
// The Box exists only to anchor the menu. A DropdownMenu is a popup and takes no
// space, so the card's size is still the Column's.
Box {
Column(
modifier =
Modifier
.fillMaxWidth()
// Depth, not the boundary — the edge below is that. 1dp: enough to
// separate a white card from a #fafafa board, and the web's own
// `shadow-sm` is the value it is matching.
.shadow(CARD_ELEVATION, RoundedCornerShape(CARD_RADIUS))
// Clipped BEFORE the click modifier, so the ripple is bounded by the
// card's rounded corners instead of a rectangle overhanging them —
// and BEFORE padding, so the padded edge is still a tap target.
.clip(RoundedCornerShape(CARD_RADIUS))
.then(opening)
// ONE surface and ONE edge on every card, both neutral, neither
// asking the note anything. See noteCardSurface and CARD_EDGE_DARK.
.background(noteCardSurface(dark))
.border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS))
.padding(12.dp),
) {
// TAGS FIRST. They used to sit under everything else, which on a tall note put
// the one thing that says what a note IS below the fold of a glance. A board is
// scanned, not read, and the answer to "which of these is about the thing I am
// looking for" should be the first thing the eye lands on rather than the last.
//
// Above the body rather than beside it, because the body's first line is the
// note's NAME (M13 steps 3 and 4) and a chip floated next to it would compete
// with the thing that identifies the note. A row of its own costs one line and
// only on notes that have tags at all.
//
// ONLY the labels whose text is not still in the note. `via_tag` means exactly
// "backed by body text" since M311, so a chip for one printed the same tag
// twice — once where it was typed, once up here — and the card was carrying
// furniture for information it was already showing. A tag left in prose is
// tinted in place instead; see [tintTags]. What reaches this row is what the
// body cannot say: a tag lifted off its own line, and a label added by hand.
val chips = note.labels.filterNot { it.viaTag }
if (chips.isNotEmpty()) {
LabelChips(labels = chips)
Spacer(Modifier.height(8.dp))
}
// A note that is NOTHING but a URL renders as its preview and nothing
// else — printing the raw address under a card that already says where it
// goes is saying the same thing twice, badly. Until the unfurl lands, or
// if it never does, `preview` is null and the body falls through to
// NoteBody, which shows the URL. Never a blank card.
val lonePreview = remember(note.body, note.previews) { loneUrlPreview(note) }
// Body then checklist, in order — a note can carry both (M13 step 2). The
// first line of the body IS the note's name, at the same weight as the rest of
// it (M13 steps 3 and 4).
if (lonePreview != null) {
LinkPreviewCard(preview = lonePreview, compact = false)
} else if (note.body.isNotBlank()) {
NoteBody(note = note, onToggleItem = onToggleItem)
}
// Links mentioned INSIDE a note: a compact strip at the foot of the card,
// under the note's own words rather than stacked on top of them. Putting
// them above would set a stranger's headline where the note's first line
// should be — the web learned that in M13 and moved them down.
if (!isLoneUrl(note) && note.previews.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
note.previews.forEach { preview ->
LinkPreviewCard(preview = preview, compact = true)
Spacer(Modifier.height(4.dp))
}
}
// A note with nothing in it still has to occupy the board legibly — otherwise
// it reads as a rendering bug.
if (note.body.isBlank() && note.previews.isEmpty()) {
Text(
text = stringResource(R.string.board_empty_note),
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
note.remindAt?.let { at ->
Spacer(Modifier.height(8.dp))
ReminderChip(instant = at, recurrence = note.recurrence)
}
}
// A note with no body and no items still has to occupy the board legibly —
// otherwise it reads as a rendering bug.
if (note.body.isBlank() && note.items.isEmpty()) {
Text(
text = stringResource(R.string.board_empty_note),
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
NoteMenu(
note = note,
expanded = menuOpen,
onDismiss = { menuOpen = false },
onAction = onAction,
onConfirmDelete = onConfirmDelete,
)
}
}
if (note.labels.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
LabelChips(labels = note.labels)
}
note.remindAt?.let { at ->
Spacer(Modifier.height(8.dp))
ReminderChip(instant = at, recurrence = note.recurrence)
/**
* What you can do to a note without opening it.
*
* The board used to have none of this, and the operator's read of that was not "the
* actions are in the editor" — it was *"there are no long hold context menus in the
* app I have no way to delete notes."* Trash was three interactions deep (open, ⋮,
* Move to trash), and on a phone that is far enough from the gesture people reach
* for that it may as well not exist.
*
* **The same items as the editor's overflow, in the same words, from the same string
* resources.** A note has one vocabulary of things that can be done to it, and two
* surfaces that named them differently would be describing two different apps. It
* dispatches [EditorAction] for the same reason — `BoardViewModel.onEditorAction` is
* already the exhaustive dispatcher for every one of them, so the board reuses the
* seam rather than growing a parallel one that could drift.
*
* **Gated on the NOTE, not on the destination.** `note.trashed` is what the editor
* gates its own read-only mode on, and it is the only reading that survives the views
* that mix piles: Reminders cuts across archived and active alike, and a search hits
* whatever matches. A menu that offered "Move to trash" on a note already in the
* trash would be offering to do something twice.
*
* **Colour is absent**, though #2946 suggested it. It was left out because `note.color`
* was already scheduled for removal; M315 removed it. There is no colour to set on a
* note any more — a card is one neutral surface and the only coloured thing on a board
* is a tag — so the row this menu never grew is a row that could not exist.
*
* Labels are absent too, for a duller reason: the picker they open is editor state,
* and hoisting it to the board is a bigger change than the friction actually reported.
*/
@Composable
private fun NoteMenu(
note: Note,
expanded: Boolean,
onDismiss: () -> Unit,
onAction: (EditorAction) -> Unit,
onConfirmDelete: () -> Unit,
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
if (note.trashed) {
MenuItem(R.string.editor_restore, onDismiss) { onAction(EditorAction.Restore) }
MenuItem(R.string.editor_delete_forever, onDismiss, onConfirmDelete)
} else {
MenuItem(
if (note.pinned) R.string.editor_unpin else R.string.editor_pin,
onDismiss,
) { onAction(EditorAction.SetPinned(!note.pinned)) }
MenuItem(
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
onDismiss,
) { onAction(EditorAction.SetArchived(!note.archived)) }
MenuItem(R.string.editor_trash, onDismiss) { onAction(EditorAction.Trash) }
}
}
}
/**
* The note's body, with its checklist drawn where it actually sits.
*
* Rendered line by line rather than as one block of text, because an item is a line
* of the body now (M304) and a card that showed the prose and then the list would put
* every list in the wrong place — and, since the body already contains those lines,
* would show each one twice.
*
* Which lines are items is asked of the core rather than matched here. The grammar is
* already written three times; a fourth in Compose would be a fourth place for a
* checklist to change shape when it syncs.
*/
@Composable
private fun Checklist(items: List<ChecklistItem>) {
private fun NoteBody(
note: Note,
onToggleItem: (Int, Boolean) -> Unit,
) {
val lines = remember(note.body) { note.body.split("\n") }
// Read from the BODY rather than from note.items, which is the same list by a
// longer route — and one that can lag the text by a save.
val itemAtLine =
remember(note.body) {
checklistItems(note.body)
.mapIndexed { index, item -> item.line.toInt() to (index to item) }
.toMap()
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
items.take(MAX_CHECKLIST_ROWS).forEach { item ->
Row(verticalAlignment = Alignment.Top) {
// A glyph rather than a real Checkbox: the card is a PREVIEW, and
// a live control here would invite taps that the board cannot yet
// honour. It becomes interactive with the editor.
Text(
text = if (item.checked) "" else "",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(end = 6.dp),
)
Text(
text = item.text,
style = MaterialTheme.typography.bodyMedium,
textDecoration = if (item.checked) TextDecoration.LineThrough else null,
color =
if (item.checked) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
lines.take(MAX_PREVIEW_LINES).forEachIndexed { n, line ->
val found = itemAtLine[n]
when {
found != null ->
ChecklistRow(note, found.second) { onToggleItem(found.first, !found.second.checked) }
// Kept as a gap rather than dropped: it is the paragraph break
// somebody typed, and the card reads as a wall without it.
line.isBlank() -> Spacer(Modifier.height(4.dp))
else ->
Text(
text = tintTags(line, note),
style = MaterialTheme.typography.bodyMedium,
maxLines = MAX_WRAPPED_LINES,
overflow = TextOverflow.Ellipsis,
)
}
}
val hidden = items.size - MAX_CHECKLIST_ROWS
if (hidden > 0) {
if (lines.size > MAX_PREVIEW_LINES) {
Text(
text = pluralStringResource(R.plurals.board_more_items, hidden, hidden),
style = MaterialTheme.typography.labelMedium,
text = "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
/**
* One checklist row on a card, with a box you can actually tick.
*
* A glyph rather than a Material Checkbox: it sits on a line of text and has to share
* that line's metrics, and a real Checkbox brings 48dp of touch target that would
* space a list out like a form. The tap target is the glyph's own padding, which is
* why it carries `clickable` rather than the row — clicking the TEXT should open the
* note, the way clicking anywhere else on the card does.
*/
@Composable
private fun ChecklistRow(
note: Note,
item: BodyItem,
onToggle: () -> Unit,
) {
Row(verticalAlignment = Alignment.Top) {
Text(
text = if (item.checked) "" else "",
style = MaterialTheme.typography.bodyMedium,
modifier =
Modifier
.clickable(onClick = onToggle)
.padding(end = 6.dp),
)
Text(
text = tintTags(item.text, note),
style = MaterialTheme.typography.bodyMedium,
textDecoration = if (item.checked) TextDecoration.LineThrough else null,
color =
if (item.checked) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = MAX_WRAPPED_LINES,
overflow = TextOverflow.Ellipsis,
)
}
}
/**
* One string of a note's own words, with every `#tag` in it drawn in that tag's colour.
*
* This is what replaced the chip for a tag still living in the prose. The card used to
* print such a tag twice — once where it was typed and once in the row above — and the
* duplicate was the loud copy, which made a tagged note read as "tag, then some text
* that happens to start with the same word". Colouring it in place says the same thing
* with no furniture, and says it more honestly: the token you can see IS the text you
* would delete to remove the tag.
*
* WHICH characters are a tag is asked of the core, exactly as [NoteBody] asks it which
* lines are checklist items. The grammar already exists three times (Rust, Python,
* TypeScript); a fourth in Compose would be a fourth thing to disagree — and this one
* would fail silently, as the wrong characters tinted rather than an error anywhere.
* The core's offsets are UTF-16 code units for this call site specifically, which is
* the only unit `addStyle` can take.
*
* Called per rendered STRING rather than once per body so a checklist item's text can
* be handled with no arithmetic: an item is a line minus a `- [ ] ` prefix of a length
* nothing carries, and shifting spans by a guessed prefix is the kind of off-by-one
* that shows up only on the one note that had a tag in a list.
*/
@Composable
private fun tintTags(
text: String,
note: Note,
): AnnotatedString {
val dark = isSystemInDarkTheme()
return remember(text, note.labels, dark) {
val spans = bodyTags(text)
if (spans.isEmpty()) {
AnnotatedString(text)
} else {
// A tag the note does not carry as a label yet — just typed, not yet
// derived — still gets a colour: `labelTint` falls back to deriving one
// from the name, which is what the chip would have shown anyway.
val picked = note.labels.associate { it.name.lowercase() to it.color }
buildAnnotatedString {
append(text)
spans.forEach { tag ->
val tint = labelTint(tag.name, picked[tag.name.lowercase()].orEmpty())
addStyle(
SpanStyle(color = tint.tagInk(dark), fontWeight = FontWeight.Medium),
tag.start.toInt(),
tag.end.toInt(),
)
}
}
}
}
}
@Composable
private fun LabelChips(labels: List<NoteLabel>) {
val dark = isSystemInDarkTheme()
@@ -135,17 +388,22 @@ private fun LabelChips(labels: List<NoteLabel>) {
// not grow taller than its content. The editor shows the full set.
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
labels.take(MAX_LABEL_CHIPS).forEach { label ->
val tint = noteTint(label.color)
val tint = labelTintFor(label.name, label.color)
Text(
text = label.name,
// The `#` is carried on every chip, because everything that reaches
// this row is a tag — a tag lifted off its own line, or one attached
// through the picker — and the hash is how you would type either. It
// also keeps a lifted chip reading as the `#todo` somebody wrote.
text = "#${label.name}",
style = MaterialTheme.typography.labelSmall,
color = tint.chipForeground(dark),
color = tint.tagInk(dark),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.clip(RoundedCornerShape(CHIP_RADIUS))
.background(tint.chipBackground(dark))
.border(1.dp, tint.chipBorder(dark), RoundedCornerShape(CHIP_RADIUS))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
@@ -182,7 +440,91 @@ private fun ReminderChip(
}
private const val MAX_PREVIEW_LINES = 8
private const val MAX_CHECKLIST_ROWS = 8
/** How far one long line of a card may wrap before it is cut. */
private const val MAX_WRAPPED_LINES = 2
private const val MAX_LABEL_CHIPS = 3
private val CARD_RADIUS = 12.dp
private val CARD_ELEVATION = 1.dp
// ---------------------------------------------------------------------------
// WHAT A CARD IS: one surface and one edge, neither of which asks the note anything.
//
// Both are constants HERE rather than columns in NoteTint precisely so the palette
// CANNOT vary them; uniformity is the feature. The editor reads the surface from here
// too, so a note opened is the same object as the note on the board.
/**
* THE CARD SURFACE — one neutral per theme (M315).
*
* This used to be a function of the note: a palette fill for a tagged one, a colour
* generated from the id for the rest. Both are gone. The operator's verdict after four
* passes — "my coloring attempt has failed and nothing looks right… we've tried a lot
* to make the color work and somehow it never seems to land" — and the diagnosis under
* it is that a card's fill was being asked to carry meaning it could not carry. Nine
* keys is too few to identify anything on a board of any size, and a generated fill
* identifies nothing by construction, so a coloured board taught the eye to read hue
* as significant and then handed it noise. Colour lives on the TAG now, where the
* thing it names is right beside it.
*
* The values are `neutral-900` on dark and white on light — exactly what the palette's
* `default` always was, and exactly what the web card and both editors already use, so
* this is a collapse onto a surface every surface already had rather than a new colour
* anybody has to like. Mirrored as `NOTE_CARD_SURFACE` in `frontend/src/notes/colors.ts`
* (`bg-white dark:bg-neutral-900`).
*
* NOT a colour-scheme role: `surface` is the BOARD in this theme (neutral-50 / -950),
* and Material's `surfaceContainer` roles are unset here so they would resolve to
* baseline M3 greys rather than to the web's neutrals. Two hexes matching the web beats
* a role that nearly does.
*
* Measured, against the operator's "not the same color as their background but close
* to it" — the card fill is deliberately the WEAKEST number on the card:
*
* card vs board light #FFFFFF on #FAFAFA 1.04
* dark #171717 on #0A0A0A 1.10
* edge vs card light #B8B8B8 on #FFFFFF 1.98
* dark #404040 on #171717 1.73
* body vs card light #171717 on #FFFFFF 17.93 (needs 4.5)
* dark #FAFAFA on #171717 17.17
* muted vs card light #404040 on #FFFFFF 10.37
* dark #E5E5E5 on #171717 14.23
*
* A card is not separated from the board by its fill and never was — the edge and the
* shadow do that, which is why 1.04 is enough and why it has to stay near 1. A fill
* that separated on its own would be a panel, and a board of panels is the wall this
* whole line of work started from.
*/
fun noteCardSurface(dark: Boolean): Color = if (dark) CARD_SURFACE_DARK else CARD_SURFACE_LIGHT
private val CARD_SURFACE_LIGHT = Color(0xFFFFFFFF)
private val CARD_SURFACE_DARK = Color(0xFF171717)
// THE CARD'S EDGE — one grey, every card, both themes. Since M315 it is the only thing
// that differs from the board by more than a hair, which makes it structure rather than
// decoration: it is what a card IS.
//
// It was already neutral before the fill was. The version that came from the palette
// was a `{hue}-900` border and failed twice over: the line measured 1.56-2.09 against
// its own fill while the fill managed only 1.03-1.05 against the board, so it was the
// loudest thing on the card — and it carried the same information the fill did, so a
// field of cards read as a grid of outlines however different the colours inside were.
// A neutral line carries no information at all, which is exactly what lets it be
// structure instead of content. The fill is that same argument one size up.
//
// MEASURED AGAINST ONE FILL NOW, and deliberately left where it was. #B8B8B8 on white
// is 1.98 and #404040 on #171717 is 1.73 — both inside the ranges these values already
// shipped at across twenty fills (light 1.57-1.98, dark 1.58-1.73), but at the top of
// them rather than the ~1.6-1.7 the pair was originally matched on. Softening the light
// edge to re-match would weaken the only boundary a white card on a #FAFAFA board has,
// and the complaint that started M315 was about fill, never about edge weight. If an
// operator pass disagrees it is one constant, in two files.
//
// NOT a translucent black/white edge, which is the tidier way to write this and was
// measured and rejected: a border composites over what is under it, so `White` at 20%
// came out #56396D on a purple card and #A3C9C1 on a teal one. With one fill that
// argument no longer bites — but an opaque grey is what NoteCard.vue must also write,
// and two surfaces stating the same hex is how they stay the same card.
private val CARD_EDGE_LIGHT = Color(0xFFB8B8B8)
private val CARD_EDGE_DARK = Color(0xFF404040)
private val CHIP_RADIUS = 6.dp
@@ -1,69 +1,89 @@
package com.fabledsword.thoughtsync.ui
import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Label
import com.fabledsword.thoughtsync.core.Note
import kotlinx.coroutines.delay
/**
* The note editor: a full screen, not a sheet.
* The one writing surface: a new note and an existing one are the same screen.
*
* A sheet works for capture, where the board behind it is reassurance that the
* thought landed somewhere. Editing is different — a sustained task with the
* keyboard up — and a sheet would spend the whole time fighting the IME for the
* bottom half of the display. Full screen also gives the actions a bottom bar,
* which is where a thumb already is.
* Shaped like the capture sheet it replaced — a rounded card that begins below the
* status bar — so opening a note still reads as something rising over the board
* rather than a place you navigated to. It is full height rather than a real
* `ModalBottomSheet`, and that is the whole trade: a sheet spends a writing session
* negotiating with the IME for the bottom half of the display, and the swipe-down it
* buys is a gesture back already does. The shape is what was worth keeping.
*
* The note's own colour paints the WHOLE screen rather than a card inside it, so
* opening a note reads as the same object growing to fill the display.
* The card's surface paints the WHOLE sheet rather than a panel inside it, so opening
* a note reads as the same object growing to fill the display — the more literally
* true since M315, where the board and the editor became the same one neutral.
*
* No save button, deliberately. Writes are continuous, so a button offering to do
* what already happened would be a lie with a tap attached; [EditorFooter] in the
* bottom corner says the same thing as a fact instead, beside the Done that
* leaves.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NoteEditorScreen(
note: Note,
sessionKey: Long,
labels: List<Label>,
saving: Boolean,
error: String?,
onAction: (EditorAction) -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = noteTint(note.color)
// Keyed by note id: the editor is reused across notes, and without the key the
// second note opened would show the first one's text.
var body by remember(note.id) { mutableStateOf(note.body) }
var picker by remember(note.id) { mutableStateOf(Picker.NONE) }
var confirmingDelete by remember(note.id) { mutableStateOf(false) }
// Keyed by the SESSION, not by note.id: the editor is reused across notes, so it
// needs a key — but a draft's id changes the moment it is first saved, and
// re-keying on that would reset this state to whatever the store just returned,
// throwing away every character typed during the write.
//
// BLOCKS rather than one string, because a checklist item is drawn as a real
// checkbox now and a widget cannot live inside a text field. The note is still one
// markdown body underneath — see EditorBlock.kt — and `bodyText` is what is saved.
//
// Saveable, because a new note has nothing to fall back on if the phone rotates
// mid-capture. The saver carries the TEXT and re-derives the shape, since a block's
// id means nothing across a process death.
var blocks by
rememberSaveable(sessionKey, stateSaver = blocksSaver) {
mutableStateOf(splitBlocks(note.body).focusedAtEnd())
}
// Which field the caret is wanted in, or null. Held HERE rather than inside
// BlockBody because the toolbar's checklist button also asks for one.
var focus by remember(sessionKey) { mutableStateOf<Long?>(null) }
val bodyText = remember(blocks) { joinBlocks(blocks) }
var picker by remember(sessionKey) { mutableStateOf(Picker.NONE) }
var confirmingDelete by remember(sessionKey) { mutableStateOf(false) }
// A note in the trash is a record, not a document: editing one would silently
// resurrect work that was meant to be thrown away. It renders read-only, with
@@ -75,8 +95,8 @@ fun NoteEditorScreen(
// would bump `updated_at`, mark the note dirty for sync, and snapshot a
// revision identical to the one before it.
val flush = {
if (!readOnly && body != note.body) {
onAction(EditorAction.SaveText(body))
if (!readOnly && bodyText != note.body) {
onAction(EditorAction.SaveText(bodyText))
}
}
val leave = {
@@ -84,6 +104,32 @@ fun NoteEditorScreen(
onAction(EditorAction.Close)
}
// Opening an existing note means continuing it. Without this the note arrives
// unfocused, and carrying on costs a tap into the last field.
//
// Not for a trashed note: it renders read-only, and a keyboard over a record you
// cannot edit is noise.
LaunchedEffect(sessionKey) {
if (!readOnly) focus = blocks.lastOrNull()?.id
}
// Idle-debounced autosave. LaunchedEffect cancels and restarts on every
// keystroke, so the delay only ever elapses once typing stops.
//
// Saving this often is affordable because a body write no longer costs a
// revision: history snapshots once per editing session rather than once per
// save. Before that, writing was expensive enough that this editor hoarded
// text until it closed — and an app kill mid-session lost the lot.
//
// For a note that does not exist yet this is also what CREATES it, which is why
// every toolbar button works moments after the first keystroke rather than
// needing the note to be saved by hand first.
LaunchedEffect(bodyText, sessionKey) {
if (readOnly || bodyText == note.body) return@LaunchedEffect
delay(AUTOSAVE_IDLE_MS)
onAction(EditorAction.SaveText(bodyText))
}
BackHandler(onBack = leave)
// Leaving the APP is not closing the editor, so the text has to be saved
@@ -91,83 +137,113 @@ fun NoteEditorScreen(
// is exactly the failure that makes someone stop trusting a notes app.
FlushOnStop(flush)
Scaffold(
containerColor = tint.background(dark),
topBar = {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = leave) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.editor_back),
// The sheet shape, kept. `windowInsetsPadding` both insets the card below the
// status bar AND consumes that inset, so the bar inside adds no second gap of
// its own — the strip above the rounded corner is what makes this read as a card
// over the board rather than a screen that replaced it.
Box(
modifier =
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars),
) {
Surface(
modifier = Modifier.fillMaxSize(),
shape = RoundedCornerShape(topStart = SHEET_CORNER, topEnd = SHEET_CORNER),
color = noteCardSurface(dark),
// Both content colours are spelled out for the reason the toolbar had to
// be: Surface and Scaffold each default theirs to contentColorFor(their
// container), which returns Unspecified for anything that is not a
// colour-SCHEME ROLE. The card surface is not one, so the default publishes
// Unspecified as LocalContentColor and everything inside that does not
// set its own colour draws black — which is how the last toolbar became
// invisible in dark mode.
contentColor = MaterialTheme.colorScheme.onSurface,
) {
Scaffold(
containerColor = noteCardSurface(dark),
contentColor = MaterialTheme.colorScheme.onSurface,
topBar = {
EditorTopBar(
note = note,
readOnly = readOnly,
onClose = leave,
onStartChecklist = {
val (next, id) = blocks.plusTask()
blocks = next
focus = id
},
onPicker = { picker = it },
onConfirmDelete = { confirmingDelete = true },
onAction = onAction,
)
},
// Where the action bar used to be, carrying the two things that
// belong within reach of a thumb: whether the note is safe, and the
// way out. See [EditorFooter] for why the exit is down here and not
// only in the top-left corner.
bottomBar = {
EditorFooter(
updatedAt = note.updatedAt,
saving = saving,
onClose = leave,
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
// No imePadding here: EditorFooter carries it, so
// Scaffold measures that row at its keyboard-lifted
// height and the inset already reaches this Column
// through `padding`. Adding it again would inset for the
// keyboard twice.
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
) {
// A failed save has to be visible HERE. The board renders the
// same banner, but a write that fails while the editor is open
// would otherwise report itself only after the user had already
// left.
error?.let { message ->
ErrorBanner(
message = message,
onDismiss = { onAction(EditorAction.DismissError) },
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = tint.background(dark)),
)
},
bottomBar = {
EditorBottomBar(
note = note,
readOnly = readOnly,
tint = tint,
onPicker = { picker = it },
onConfirmDelete = { confirmingDelete = true },
onAction = onAction,
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.imePadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
) {
// A one-pixel line, not a spinner: a save slow enough to see is worth
// showing, and one that isn't must not make the screen jump.
if (saving) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
// A failed save has to be visible HERE. The board renders the same
// banner, but a write that fails while the editor is open would
// otherwise report itself only after the user had already left.
error?.let { message ->
ErrorBanner(message = message, onDismiss = { onAction(EditorAction.DismissError) })
}
// A note is its body; its NAME is that body's first line, so there
// is nothing separate to type into and nothing rendered bolder than
// the line beneath it (M13 steps 3 and 4). What 2992 changed is only
// how the body is DRAWN — checklist items as boxes rather than as
// the markup for boxes.
BlockBody(
blocks = blocks,
readOnly = readOnly,
focus = focus,
onChange = { blocks = it },
onFocus = { focus = it },
)
// One field. A note is its body; its NAME is that body's first line, so
// there is nothing separate to type into and nothing to render bolder
// than the line beneath it (M13 steps 3 and 4).
EditorField(
value = body,
onValueChange = { body = it },
hint = R.string.editor_body_hint,
enabled = !readOnly,
minLines = MIN_BODY_LINES,
)
// No checklist section. The items ARE lines of the field above
// (M304) — rendering them again down here is what would put every
// list on screen twice.
// Below the body, not instead of it, and only once the note has items —
// the toolbar's add-checklist action is what puts the first one there.
if (note.items.isNotEmpty()) {
ChecklistEditor(note = note, readOnly = readOnly, onAction = onAction)
}
if (note.labels.isNotEmpty()) {
EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction)
}
if (note.labels.isNotEmpty()) {
EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction)
}
note.remindAt?.let { at ->
EditorReminderRow(
at = at,
recurrence = note.recurrence,
readOnly = readOnly,
onAction = onAction,
)
note.remindAt?.let { at ->
EditorReminderRow(
at = at,
recurrence = note.recurrence,
readOnly = readOnly,
onAction = onAction,
)
}
}
}
}
}
@@ -181,31 +257,18 @@ fun NoteEditorScreen(
)
if (confirmingDelete) {
// The only irreversible action in the app earns the only confirmation in
// it. Everything else — archive, trash, even unlinking a server — undoes.
AlertDialog(
onDismissRequest = { confirmingDelete = false },
title = { Text(stringResource(R.string.editor_delete_forever_title)) },
text = { Text(stringResource(R.string.editor_delete_forever_body)) },
confirmButton = {
TextButton(onClick = {
confirmingDelete = false
onAction(EditorAction.DeleteForever)
}) {
Text(stringResource(R.string.editor_delete_forever_confirm))
}
},
dismissButton = {
TextButton(onClick = { confirmingDelete = false }) {
Text(stringResource(R.string.editor_cancel))
}
ConfirmDeleteDialog(
onConfirm = {
confirmingDelete = false
onAction(EditorAction.DeleteForever)
},
onDismiss = { confirmingDelete = false },
)
}
}
/** Which overlay is open. One at a time, so they cannot stack on a phone screen. */
enum class Picker { NONE, COLOR, LABELS, REMINDER }
enum class Picker { NONE, LABELS, REMINDER }
/** The pickers, hoisted out so the screen above reads as a layout rather than a switch. */
@Composable
@@ -219,15 +282,6 @@ private fun EditorOverlays(
val dismiss = { onPicker(Picker.NONE) }
when (picker) {
Picker.NONE -> Unit
Picker.COLOR ->
ColorSheet(
selected = note.color,
onPick = {
onAction(EditorAction.SetColor(it))
dismiss()
},
onDismiss = dismiss,
)
Picker.LABELS ->
LabelSheet(
note = note,
@@ -245,32 +299,16 @@ private fun EditorOverlays(
}
/**
* The note's body field.
* How long typing has to stop before the note is written.
*
* Undecorated, via the shared [PlainTextField]: the screen is already painted in
* the note's colour, and a filled field would draw a second surface over the first
* and turn a note into a form.
*
* One weight throughout. The first line is the note's name, but it is not a
* different KIND of text from the line after it, and typing it should not feel like
* filling in a header.
* Long enough that a normal sentence is one write, short enough that nothing
* meaningful is at risk if the app dies. The flush on close and [FlushOnStop] still
* cover the window between the last keystroke and this elapsing.
*/
@Composable
private fun EditorField(
value: String,
onValueChange: (String) -> Unit,
@StringRes hint: Int,
enabled: Boolean,
minLines: Int = 1,
) {
PlainTextField(
value = value,
onValueChange = onValueChange,
hint = hint,
enabled = enabled,
minLines = minLines,
textStyle = MaterialTheme.typography.bodyLarge,
)
}
private const val AUTOSAVE_IDLE_MS = 1_000L
private const val MIN_BODY_LINES = 6
/**
* The card's top corner radius — Material's extra-large, which is what a bottom
* sheet uses. Same shape as the capture surface this replaced, on purpose.
*/
private val SHEET_CORNER = 28.dp
@@ -5,17 +5,23 @@ import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color
/**
* The note colour palette, matching `frontend/src/notes/colors.ts` VALUE FOR VALUE.
* The colour palette, matching `frontend/src/notes/colors.ts` VALUE FOR VALUE.
*
* A note's colour is stored by the core as a key ("red", "teal", …) and every
* surface resolves it to its own tints. The web app resolves through Tailwind
* classes; this table is those same Tailwind colours as literals, so a note that
* is amber on the desktop is the same amber on the phone rather than a near-miss.
* Generated from tailwindcss 3.4's palette rather than transcribed by eye.
* A colour is stored by the core as a key ("red", "teal", …) and every surface
* resolves it to its own tints. The web app resolves through Tailwind classes; this
* table is those same Tailwind colours as literals, so a tag that is amber on the
* desktop is the same amber on the phone rather than a near-miss. Generated from
* tailwindcss 3.4's palette rather than transcribed by eye.
*
* Dark tints keep the web's ALPHA (`dark:bg-red-950/40`) instead of a
* precomputed blend — Compose composites a translucent colour over what's beneath
* exactly as CSS does, so the card sits on the background the same way in both.
* Dark tints keep the web's ALPHA instead of a precomputed blend — Compose composites
* a translucent colour over what's beneath exactly as CSS does, so a panel sits on the
* background the same way in both.
*
* This table is the palette of MEANINGFUL colours: a tag's. A NOTE no longer has one
* at all (M315) — the card is one neutral per theme, held in NoteCard.kt beside the
* edge, where the palette cannot reach either of them. What is left here is the chip,
* the inline `#tag`, and the panels and banners that borrow a hue to say what they
* are.
*
* `yellow` maps to Tailwind's *amber*, matching colors.ts; plain yellow is too
* acid against the neutral surfaces.
@@ -27,19 +33,102 @@ data class NoteTint(
val darkBackground: Color,
val darkBorder: Color,
val lightChipBackground: Color,
val lightChipForeground: Color,
val darkChipBackground: Color,
/**
* The REMINDER pill's ink, and nothing else's — see [chipForeground].
*
* Only two of these ten are ever read (`red` when a reminder has passed, `default`
* otherwise). They stay a per-hue column because they are transcribed from the
* web's literals rather than derived from anything here.
*/
val lightChipForeground: Color,
val darkChipForeground: Color,
/**
* THE INK A TAG IS DRAWN IN — inline in the prose AND as a chip's text — see
* [tagInk].
*
* These were two columns until M315, and the split was real while it lasted: a chip
* brought its own `-100` fill and could afford `-700`, while inline text sat on
* whatever the card was, which included a gray-tagged card at `neutral-200` where
* `-700` measured 3.98 (green), 4.11 (orange) and 4.34 (teal), all under the 4.5
* body text needs. One step deeper cleared every fill at once.
*
* The twenty card fills that split was solving for are gone, so both jobs take this
* one value. The direction is deliberate: since M311 a tag whose text is in the body
* is drawn where it was typed and NOT repeated as a chip, so the inline token is the
* common case and collapsing onto ITS column leaves what is seen most exactly as it
* was. The chip is strictly better for the move — on its own fill it goes from
* 4.52-8.23 to 6.37-12.01 in light. Dark needed no decision: the two columns already
* held the same value for all ten hues.
*/
val lightTagInk: Color,
val darkTagInk: Color,
) {
/**
* The pale fill of a PANEL, a banner, an update card or a picker swatch — the
* places that borrow a hue to say what they are. Not a note's: since M315 a card
* has one neutral surface and does not come through this table at all.
*/
fun background(dark: Boolean): Color = if (dark) darkBackground else lightBackground
fun border(dark: Boolean): Color = if (dark) darkBorder else lightBorder
fun chipBackground(dark: Boolean): Color = if (dark) darkChipBackground else lightChipBackground
/**
* The REMINDER pill's ink. NOT a tag's — a tag takes [tagInk] wherever it is drawn.
*
* Kept apart from [tagInk] because the reminder pill is not a tag: it borrows the
* chip's shape and its `red-100`/`black-5` fills, and its `red-700`/`neutral-600`
* text is transcribed from NoteCard.vue's literal classes. The two happened to be
* one value; making the tag ink one step deeper (M315) is where they parted, and
* moving the reminder with it would have silently broken that mirror instead.
*/
fun chipForeground(dark: Boolean): Color = if (dark) darkChipForeground else lightChipForeground
/**
* The colour a `#tag` is drawn in — in the note's own words, or as a chip.
*
* A tag whose text is in the body is no longer repeated as a chip (the card was
* printing every tag twice — once where it was typed, once at the top). It is
* tinted in place instead, which is both less furniture and a more honest card:
* the thing you see IS the thing you would delete to remove the tag.
*/
fun tagInk(dark: Boolean): Color = if (dark) darkTagInk else lightTagInk
/**
* A hairline edge for a chip, in its own ink at low alpha.
*
* THE EDGE IS THE PILL. Against the one card surface a chip's fill measures
* 1.02-1.26 in light and 1.02-1.73 in dark — very nearly nothing, and dark red at
* 1.02 is literally invisible. Without this the tag name would read as loose text.
* The fill only tints a shape the edge is drawing.
*
* An edge rather than a heavier fill, because a fill loud enough to hold its own
* shape would be the loudest thing on a board whose whole point is now that the tag
* is the one coloured thing on it.
*/
fun chipBorder(dark: Boolean): Color = tagInk(dark).copy(alpha = CHIP_EDGE_ALPHA)
}
// How strongly a chip's edge is drawn, as a fraction of its own ink.
//
// SOLVED FOR, NOT GUESSED — and re-solved once the answer became solvable. 0.60 was
// picked against the worst case of the time: a chip on a card of its OWN colour, back
// when a note took its first tag's fill. It gave 2.32:1 there, missed the 3:1 of WCAG
// 1.4.11, and the comment here reasoned its way out of that on the grounds that a
// chip's information is its text.
//
// M315 removed that worst case. The edge is now the ink at alpha over a KNOWN fill, so
// the smallest alpha clearing 3:1 for all ten hues is arithmetic rather than judgment:
// 0.60 gives 2.75-3.82 in light and misses for six of the ten, 0.65 gives 3.03-4.36 and
// misses for none. Dark runs 4.52-5.76. The old comment named 0.80 as the fallback if
// the judgment were ever overruled; it is not needed, and it draws a hard outline where
// a hairline does the job.
//
// Mirrored on the web as the `/65` in LABEL_CHIP_SHELL's ring.
private const val CHIP_EDGE_ALPHA = 0.65f
/** Keyed by the core's colour vocabulary. Order matches the web's picker. */
val NOTE_TINTS: Map<String, NoteTint> =
mapOf(
@@ -54,6 +143,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF525252),
darkChipBackground = Color(0x1AFFFFFF),
darkChipForeground = Color(0xFFD4D4D4),
lightTagInk = Color(0xFF404040),
darkTagInk = Color(0xFFD4D4D4),
),
"red" to
NoteTint(
@@ -66,6 +157,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFFB91C1C),
darkChipBackground = Color(0x80450A0A),
darkChipForeground = Color(0xFFFCA5A5),
lightTagInk = Color(0xFF991B1B),
darkTagInk = Color(0xFFFCA5A5),
),
"orange" to
NoteTint(
@@ -78,6 +171,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFFC2410C),
darkChipBackground = Color(0x80431407),
darkChipForeground = Color(0xFFFDBA74),
lightTagInk = Color(0xFF9A3412),
darkTagInk = Color(0xFFFDBA74),
),
"yellow" to
NoteTint(
@@ -90,6 +185,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF92400E),
darkChipBackground = Color(0x80451A03),
darkChipForeground = Color(0xFFFCD34D),
lightTagInk = Color(0xFF92400E),
darkTagInk = Color(0xFFFCD34D),
),
"green" to
NoteTint(
@@ -102,6 +199,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF15803D),
darkChipBackground = Color(0x80052E16),
darkChipForeground = Color(0xFF86EFAC),
lightTagInk = Color(0xFF166534),
darkTagInk = Color(0xFF86EFAC),
),
"teal" to
NoteTint(
@@ -114,6 +213,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF0F766E),
darkChipBackground = Color(0x80042F2E),
darkChipForeground = Color(0xFF5EEAD4),
lightTagInk = Color(0xFF115E59),
darkTagInk = Color(0xFF5EEAD4),
),
"blue" to
NoteTint(
@@ -126,6 +227,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF1D4ED8),
darkChipBackground = Color(0x80172554),
darkChipForeground = Color(0xFF93C5FD),
lightTagInk = Color(0xFF1E40AF),
darkTagInk = Color(0xFF93C5FD),
),
"purple" to
NoteTint(
@@ -138,6 +241,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF7E22CE),
darkChipBackground = Color(0x803B0764),
darkChipForeground = Color(0xFFD8B4FE),
lightTagInk = Color(0xFF6B21A8),
darkTagInk = Color(0xFFD8B4FE),
),
"pink" to
NoteTint(
@@ -150,6 +255,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFFBE185D),
darkChipBackground = Color(0x80500724),
darkChipForeground = Color(0xFFF9A8D4),
lightTagInk = Color(0xFF9D174D),
darkTagInk = Color(0xFFF9A8D4),
),
"gray" to
NoteTint(
@@ -162,6 +269,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF404040),
darkChipBackground = Color(0xFF404040),
darkChipForeground = Color(0xFFE5E5E5),
lightTagInk = Color(0xFF262626),
darkTagInk = Color(0xFFE5E5E5),
),
)
@@ -175,3 +284,30 @@ val NOTE_TINTS: Map<String, NoteTint> =
@Composable
@ReadOnlyComposable
fun noteTint(key: String): NoteTint = NOTE_TINTS[key] ?: NOTE_TINTS.getValue("default")
/**
* The tint for a LABEL, derived from its name when nobody has picked one.
*
* Every `#tag` is born colourless, so without this a board of tags is a board of
* identical grey chips. See `DerivedTint.kt` for why this derives rather than
* persisting a colour when the tag is minted.
*/
@Composable
@ReadOnlyComposable
fun labelTintFor(
name: String,
color: String,
): NoteTint = labelTint(name, color)
/**
* [labelTintFor] with no composable context, for a caller building its value inside
* `remember` — where a `@Composable` call is not allowed. The card's inline tag
* colours are computed there, once per body rather than once per recomposition.
*
* One implementation, two entry points: the composable one delegates here rather than
* repeating the lookup, so the chip and the inline token cannot resolve differently.
*/
fun labelTint(
name: String,
color: String,
): NoteTint = NOTE_TINTS[resolvedLabelColor(name, color, NOTE_TINTS.keys)] ?: NOTE_TINTS.getValue("default")
@@ -1,5 +1,6 @@
package com.fabledsword.thoughtsync.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
@@ -8,6 +9,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -79,6 +82,66 @@ fun Notice(
}
}
/**
* One row of a dropdown menu, closing the menu before it acts.
*
* Lives here rather than beside either menu because there are two now — the
* editor's overflow and the board's long-press menu — and they offer the same
* actions in the same words. A second copy of this would be a second place for the
* closing order to be got wrong.
*
* Closing FIRST is the whole point: an action that raises a sheet or a dialog would
* otherwise do it underneath a menu still hanging over the screen. Doing it in here
* means no call site can forget.
*/
@Composable
fun MenuItem(
@StringRes labelRes: Int,
onClose: () -> Unit,
onClick: () -> Unit,
) {
DropdownMenuItem(
text = { Text(stringResource(labelRes)) },
onClick = {
onClose()
onClick()
},
)
}
/**
* The one confirmation in the app.
*
* Delete-forever is the only irreversible thing a note can be asked to do —
* archive, trash, even unlinking a server all undo — so it is the only one that
* interrupts. Both surfaces that offer it raise THIS dialog: the editor's overflow
* and the board's long-press menu are two ways to the same act, and two dialogs
* would be two chances to word the consequences differently.
*
* Nothing about a note is passed in. The caller already knows which note it is
* asking about and holds it while this is on screen; taking one here would only let
* the dialog and the action that follows it disagree.
*/
@Composable
fun ConfirmDeleteDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.editor_delete_forever_title)) },
text = { Text(stringResource(R.string.editor_delete_forever_body)) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text(stringResource(R.string.editor_delete_forever_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.editor_cancel)) }
},
)
}
/** The three tones a panel or notice can take, mapped onto the note palette. */
enum class Tone { NEUTRAL, WARN, ERROR }
@@ -9,6 +9,7 @@ import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -20,12 +21,16 @@ import androidx.compose.ui.text.input.VisualTransformation
/**
* A text field with no box around it.
*
* Every writing surface in the app — the capture sheet, the editor's title and
* body, each checklist row — sits on a surface that already has its own edges and
* its own colour. Material's filled field would draw a second, differently
* coloured box inside the first, which makes writing a note look like filling in a
* form. Stripping the container and the indicator in four places independently is
* how they drift apart, so it happens once, here.
* The search box, the label picker, the sync-pairing form: fields that sit on a
* surface which already has its own edges and its own colour, where Material's filled
* field would draw a second, differently coloured box inside the first. Stripping the
* container and the indicator at each site independently is how they drift apart, so
* it happens once, here.
*
* The note EDITOR no longer comes through this. It dropped to `BasicTextField`
* (see `EditorBlock.kt`) for density: Material's field puts 16dp above and below its
* text, which is right for a form and is the whole row height on a checklist. Nothing
* about "no box" was lost there — BasicTextField never had one.
*
* The disabled colours are stripped too: a trashed note is shown through this
* field read-only, and Material's disabled treatment would grey out text the user
@@ -58,18 +63,26 @@ fun PlainTextField(
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
visualTransformation = visualTransformation,
colors =
TextFieldDefaults.colors(
// Full-strength, not Material's 38%-alpha disabled treatment: a
// trashed note is rendered read-only through this field and its
// text is meant to be READ, not visually retired.
disabledTextColor = MaterialTheme.colorScheme.onSurface,
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
),
colors = plainFieldColors(),
)
}
/**
* One definition of "no box". Two copies of this is exactly the drift this file
* exists to prevent.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun plainFieldColors(): TextFieldColors =
TextFieldDefaults.colors(
// Full-strength, not Material's 38%-alpha disabled treatment: a trashed
// note is rendered read-only through this field and its text is meant to
// be READ, not visually retired.
disabledTextColor = MaterialTheme.colorScheme.onSurface,
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
)
@@ -31,12 +31,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.UpdateOutcome
import com.fabledsword.thoughtsync.installedVersionName
/**
* Opt-in server pairing.
@@ -120,10 +122,38 @@ fun SyncScreen(
onDismissRevokeNotice = onDismissRevokeNotice,
)
}
BuildLine()
}
}
}
/**
* The build, dim, at the foot of Sync — the same thing the web UI puts at the
* bottom of its rail (#3181).
*
* Note 3127 §5 is why it is here at all. With version tags gone, an artifact's own
* self-report is the only answer to "which build is this?" — so it renders
* "unknown" rather than nothing when the name is absent, because a blank line looks
* like a layout bug and a plausible default cannot be caught by anything.
*
* The read itself is `installedVersionName()`, shared with the client header the
* app sends its server: one answer to "which build is on this phone", so the line
* a person quotes in a bug report and the line in the server's log cannot disagree.
*/
@Composable
private fun BuildLine() {
val context = LocalContext.current
val unknown = stringResource(R.string.build_unknown)
val version = remember(context) { context.installedVersionName() ?: unknown }
Text(
text = version,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 16.dp),
)
}
// ───────────────────────────────── linked ─────────────────────────────────
@Composable
@@ -0,0 +1,584 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Label
/** The swatch shown beside a tag, and tapped to change its colour. */
private val SWATCH = 22.dp
/** What the row's overflow menu is currently asking about. */
private sealed interface TagDialog {
data class Rename(
val tag: Label,
) : TagDialog
/** A rename whose new name another tag already holds — see [RenameDialog]. */
data class ConfirmMerge(
val tag: Label,
val into: Label,
val name: String,
) : TagDialog
data class Merge(
val tag: Label,
) : TagDialog
data class Delete(
val tag: Label,
) : TagDialog
data class Colour(
val tag: Label,
) : TagDialog
}
/**
* Tag management: list, create, rename, recolour, delete, merge.
*
* A destination you go to, not a modal. The web's `LabelsModal.vue` is a modal
* because a desktop has room to float one over the board; on a phone this is a
* place you visit to tidy up, and a full screen is what that is.
*
* It is also, since the per-note colour picker was removed, the ONLY colour
* control in the product. That is why the swatch is a first-class tap target on
* every row rather than something behind the overflow menu.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TagsScreen(
state: TagsState,
onClose: () -> Unit,
onCreate: (String) -> Unit,
onRename: (String, String) -> Unit,
onColour: (String, String) -> Unit,
onDelete: (String) -> Unit,
onMerge: (String, String) -> Unit,
onDismissError: () -> Unit,
) {
var dialog by remember { mutableStateOf<TagDialog?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.tags_title)) },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.tags_back),
)
}
},
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.imePadding(),
) {
// An indeterminate bar rather than blocking the list: a tag write is a
// local SQLite call and usually finishes before this is seen at all.
if (state.busy) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
// The same banner the board and the editor use. A third way of saying
// "that did not work" would be a third thing to keep consistent.
state.error?.let { message ->
ErrorBanner(message = message, onDismiss = onDismissError)
}
NewTagField(
enabled = !state.busy,
onCreate = onCreate,
)
if (!state.loading && state.tags.isEmpty()) {
EmptyTags()
}
// weight, NOT fillMaxSize: this has siblings above it, and filling the
// whole height would measure the list against space the field and the
// banner have already taken — pushing the end of the list off-screen.
LazyColumn(modifier = Modifier.weight(1f)) {
items(state.tags, key = { it.id }) { tag ->
TagRow(
tag = tag,
enabled = !state.busy,
onColour = { dialog = TagDialog.Colour(tag) },
onRename = { dialog = TagDialog.Rename(tag) },
onMerge = { dialog = TagDialog.Merge(tag) },
onDelete = { dialog = TagDialog.Delete(tag) },
)
}
}
}
}
when (val open = dialog) {
null -> Unit
is TagDialog.Rename ->
RenameDialog(
tag = open.tag,
others = state.tags,
onDismiss = { dialog = null },
onRename = { name ->
dialog = null
onRename(open.tag.id, name)
},
// Renaming onto a name another tag holds MERGES the two, and that
// cannot be undone by repeating it, so the confirmation replaces
// this dialog rather than the rename just happening.
onWouldMerge = { into, name -> dialog = TagDialog.ConfirmMerge(open.tag, into, name) },
)
is TagDialog.ConfirmMerge ->
ConfirmDialog(
title = stringResource(R.string.tags_rename_merges_title, hash(open.into.name)),
body = stringResource(R.string.tags_rename_merges_body, hash(open.into.name)),
confirm = stringResource(R.string.tags_rename_merges_confirm),
onDismiss = { dialog = null },
onConfirm = {
dialog = null
onRename(open.tag.id, open.name)
},
)
is TagDialog.Merge ->
MergeDialog(
tag = open.tag,
others = state.tags.filter { it.id != open.tag.id },
onDismiss = { dialog = null },
onMerge = { target ->
dialog = null
onMerge(open.tag.id, target.id)
},
)
is TagDialog.Delete ->
ConfirmDialog(
title = stringResource(R.string.tags_delete_title, hash(open.tag.name)),
// The count is the part that makes the consequence real — "it is on
// 40 notes" is a different decision from "delete this tag?". It comes
// from the LIST, the only call the core populates a count on.
body =
open.tag.count
?.takeIf { it > 0 }
?.let { stringResource(R.string.tags_delete_body_counted, it) }
?: stringResource(R.string.tags_delete_body),
footnote = stringResource(R.string.tags_delete_from_text),
confirm = stringResource(R.string.tags_delete_confirm),
onDismiss = { dialog = null },
onConfirm = {
dialog = null
onDelete(open.tag.id)
},
)
is TagDialog.Colour ->
ColourDialog(
tag = open.tag,
onDismiss = { dialog = null },
onPick = { key ->
dialog = null
onColour(open.tag.id, key)
},
)
}
}
/**
* `#` on the name, everywhere it is spoken about.
*
* The chips already wear it (`NoteCard.kt`, `EditorChrome.kt`) and it is the
* reason these are called tags at all — a dialog that said "Delete grocery?" would
* be talking about something else.
*/
private fun hash(name: String): String = "#$name"
@Composable
private fun NewTagField(
enabled: Boolean,
onCreate: (String) -> Unit,
) {
var text by remember { mutableStateOf("") }
val submit = {
if (text.isNotBlank()) {
onCreate(text)
text = ""
}
}
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
PlainTextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.weight(1f),
hint = R.string.tags_new_hint,
enabled = enabled,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { submit() }),
)
TextButton(onClick = submit, enabled = enabled && text.isNotBlank()) {
Text(stringResource(R.string.tags_create))
}
}
}
/**
* Said out loud rather than left as a blank screen — and it names the `#` route,
* because the operator did not know `#tag` extraction existed at all (Scribe
* #2949) and this is the natural place to say so.
*/
@Composable
private fun EmptyTags() {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = stringResource(R.string.tags_empty_title),
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringResource(R.string.tags_empty_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun TagRow(
tag: Label,
enabled: Boolean,
onColour: () -> Unit,
onRename: () -> Unit,
onMerge: () -> Unit,
onDelete: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = labelTintFor(tag.name, tag.color)
var menuOpen by remember { mutableStateOf(false) }
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier =
Modifier
.size(SWATCH)
.clip(CircleShape)
.background(tint.chipBackground(dark))
.border(1.dp, tint.chipBorder(dark), CircleShape)
.clickable(enabled = enabled, onClick = onColour),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = hash(tag.name),
style = MaterialTheme.typography.bodyLarge,
color = tint.tagInk(dark),
)
Text(
text = countLabel(tag.count),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Column {
IconButton(onClick = { menuOpen = true }, enabled = enabled) {
Icon(
Icons.Filled.MoreVert,
contentDescription = stringResource(R.string.tags_actions),
)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
// Panel.kt's MenuItem, not a bare DropdownMenuItem: every one of
// these raises a dialog, and it closes the menu BEFORE acting so the
// dialog cannot open underneath a menu still hanging over it.
val close = { menuOpen = false }
MenuItem(R.string.tags_rename, close, onRename)
MenuItem(R.string.tags_merge, close, onMerge)
MenuItem(R.string.tags_delete, close, onDelete)
}
}
}
}
/**
* Zero is its own sentence, not "0 notes".
*
* A count of null means the core did not populate one — only `list_labels` does —
* which is a different thing from a tag with no notes, so it reads as unknown
* rather than as empty.
*/
@Composable
private fun countLabel(count: Long?): String =
when {
count == null -> ""
count <= 0L -> stringResource(R.string.tags_count_none)
count == 1L -> stringResource(R.string.tags_count_one)
else -> stringResource(R.string.tags_count, count.toInt())
}
/**
* Rename, with the merge caught before it happens.
*
* The collision is detected HERE, against the list, rather than from what the
* core returns: the merge survivor is whichever tag is older, so it may well be
* the one being renamed, and an unchanged id afterwards would prove nothing.
* Matching is case-insensitive because the core's is.
*/
@Composable
private fun RenameDialog(
tag: Label,
others: List<Label>,
onDismiss: () -> Unit,
onRename: (String) -> Unit,
onWouldMerge: (Label, String) -> Unit,
) {
var text by remember(tag.id) { mutableStateOf(tag.name) }
val trimmed = text.trim()
val clash =
others.firstOrNull { it.id != tag.id && it.name.equals(trimmed, ignoreCase = true) }
val submit = {
when {
trimmed.isEmpty() -> Unit
clash != null -> onWouldMerge(clash, trimmed)
else -> onRename(trimmed)
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.tags_rename_title, hash(tag.name))) },
text = {
PlainTextField(
value = text,
onValueChange = { text = it },
hint = R.string.tags_new_hint,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { submit() }),
)
},
confirmButton = {
TextButton(onClick = submit, enabled = trimmed.isNotEmpty()) {
Text(stringResource(R.string.tags_rename_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
},
)
}
/**
* Merge, with the direction stated and the survivor named.
*
* Unlike a rename — where the OLDER tag survives so that the outcome cannot
* depend on which way round it was typed — this one is deliberate, so the
* direction the person chooses IS the intent and is honoured. The price of that
* is that the direction has to be unmissable, which is why the body names the tag
* that stops existing and every row here is the one that survives.
*/
@Composable
private fun MergeDialog(
tag: Label,
others: List<Label>,
onDismiss: () -> Unit,
onMerge: (Label) -> Unit,
) {
val dark = isSystemInDarkTheme()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.tags_merge_title, hash(tag.name))) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(R.string.tags_merge_body, hash(tag.name)),
style = MaterialTheme.typography.bodyMedium,
)
if (others.isEmpty()) {
Text(
text = stringResource(R.string.tags_merge_none),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
others.forEach { other ->
val tint = labelTintFor(other.name, other.color)
Text(
text = hash(other.name),
style = MaterialTheme.typography.bodyLarge,
color = tint.tagInk(dark),
modifier =
Modifier
.fillMaxWidth()
.clickable { onMerge(other) }
.padding(vertical = 8.dp),
)
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
},
)
}
/**
* The palette, and since the per-note picker was removed this is the only place in
* the product a colour is chosen.
*
* Every key from [NOTE_TINTS], `default` included: a tag whose colour is
* `default` gets a hue derived from its name (`DerivedTint.kt`), so "default" here
* means "let it pick" rather than "grey", and taking it away would leave no way
* back to that.
*/
@Composable
private fun ColourDialog(
tag: Label,
onDismiss: () -> Unit,
onPick: (String) -> Unit,
) {
val dark = isSystemInDarkTheme()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.tags_colour_of, hash(tag.name))) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
NOTE_TINTS.forEach { (key, tint) ->
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { onPick(key) }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier =
Modifier
.size(SWATCH)
.clip(CircleShape)
.background(tint.chipBackground(dark))
.border(1.dp, tint.chipBorder(dark), CircleShape),
)
Text(
text = tint.label,
style = MaterialTheme.typography.bodyMedium,
color =
if (key == tag.color) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
)
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
},
)
}
/** A destructive confirmation: what it is, what it costs, and one way out. */
@Composable
private fun ConfirmDialog(
title: String,
body: String,
confirm: String,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
footnote: String? = null,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = body, style = MaterialTheme.typography.bodyMedium)
footnote?.let {
Text(
text = it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
confirmButton = {
TextButton(onClick = onConfirm) { Text(confirm) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
},
)
}
@@ -0,0 +1,181 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.fabledsword.thoughtsync.core.Label
import com.fabledsword.thoughtsync.core.ThoughtSync
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Everything the Tags screen renders from.
*
* [tags] comes from `list_labels`, which is the only call that populates a
* `count` — the single-tag returns leave it null by design. So the counts a
* confirmation dialog quotes are always the LIST's, never an operation's result.
*/
data class TagsState(
val loading: Boolean = true,
val tags: List<Label> = emptyList(),
/** An in-flight write, for disabling the controls that would race it. */
val busy: Boolean = false,
val error: String? = null,
)
/**
* Create, rename, recolour, delete and merge tags.
*
* A peer of the web's `LabelsModal.vue`, not a reduced companion — the same six
* operations over the same core the desktop uses.
*
* ## Why every write re-lists
*
* A tag operation changes more than the row it names. A merge deletes one tag and
* moves its notes; a delete changes nothing else's count but removes a drawer
* lens; a rename can MERGE (see below) and so can make a different row vanish.
* Re-listing after each write costs one cheap local SQLite read and removes a
* whole class of "the screen thinks there are still two" bugs. Patching the list
* in place would mean re-deriving, in Kotlin, rules the core already owns.
*
* ## Renaming can merge
*
* `rename_label` folds two tags together when the new name is one another tag
* already holds, and the OLDER row survives (Scribe #3324). So it can return a
* tag whose id is not the one passed in, and it can make another tag stop
* existing. The screen asks first; this view model does not, because a
* confirmation belongs to the surface with a person in front of it.
*
* ## Threading
*
* All of these are ordinary blocking FFI into SQLite — no async, no network — so
* they take [Dispatchers.IO], exactly like the board's calls. Sync happens later:
* the core marks the rows dirty and the next sync carries them.
*/
class TagsViewModel(
private val core: ThoughtSync,
/**
* Called after any write that landed.
*
* The board holds its own snapshot of the tag list for the drawer, and its
* current destination may BE one of these tags — deleting or merging that one
* leaves it looking at a lens that no longer exists. Wiring the two together
* explicitly is less magic than a shared event bus and makes the dependency
* visible at the construction site, the same way [SyncViewModel] does it.
*/
private val onStoreChanged: () -> Unit,
) : ViewModel() {
var state by mutableStateOf(TagsState())
private set
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
state =
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
.fold(
onSuccess = { state.copy(tags = it, loading = false, error = null) },
// Unlike the drawer, this screen cannot fail quietly: it is
// the only thing on the display, and an empty list here
// would read as "you have no tags" rather than "I couldn't
// look".
onFailure = { state.copy(loading = false, error = it.describeTagFailure()) },
)
}
}
fun create(name: String) {
val trimmed = name.trim()
if (trimmed.isEmpty()) return
// Find-or-create in the core: typing a name that exists in another case
// attaches the existing tag rather than minting a near-duplicate.
write { it.createLabel(trimmed) }
}
fun rename(
id: String,
name: String,
) {
val trimmed = name.trim()
if (trimmed.isEmpty()) return
write { it.renameLabel(id, trimmed) }
}
fun setColour(
id: String,
colour: String,
) = write { it.setLabelColor(id, colour) }
fun remove(id: String) = write { it.removeLabel(id) }
/**
* Fold [sourceId] into [targetId]. The source stops existing.
*
* Directional and not undone by repeating it — the caller has to have said
* which one survives before this runs, because afterwards there is nothing
* left to read the direction from.
*/
fun merge(
sourceId: String,
targetId: String,
) {
if (sourceId == targetId) return
write { it.mergeLabels(sourceId, targetId) }
}
fun dismissError() {
state = state.copy(error = null)
}
/**
* Run one store write, then re-list and tell the board.
*
* `busy` is cleared in the same assignment that stores the result, so no path
* out of here can leave the screen stuck with its controls disabled.
*/
private fun write(block: (ThoughtSync) -> Unit) {
if (state.busy) return
state = state.copy(busy = true, error = null)
viewModelScope.launch {
val failure =
runCatching { withContext(Dispatchers.IO) { block(core) } }
.exceptionOrNull()
val tags =
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
.getOrDefault(state.tags)
state =
state.copy(
tags = tags,
busy = false,
error = failure?.describeTagFailure(),
)
// Even a FAILED write can have changed the store — a merge that threw
// partway still moved rows — so the board is told either way.
onStoreChanged()
}
}
companion object {
fun factory(
core: ThoughtSync,
onStoreChanged: () -> Unit,
): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = TagsViewModel(core, onStoreChanged) as T
}
}
}
/**
* The core reports problems as one error type carrying a message meant to be
* shown, so the message is used when there is one.
*/
private fun Throwable.describeTagFailure(): String = message ?: "Something went wrong."
@@ -1,6 +1,5 @@
package com.fabledsword.thoughtsync.ui
import java.time.Instant
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneId
@@ -67,9 +66,14 @@ fun reminderLabel(
}
}
/** A stored instant as epoch milliseconds, or null if it will not parse. */
fun epochMillis(raw: String): Long? = runCatching { OffsetDateTime.parse(raw).toInstant().toEpochMilli() }.getOrNull()
/** Whether a stored reminder has already passed, for showing it as overdue. */
fun isPast(raw: String): Boolean =
runCatching { OffsetDateTime.parse(raw).toInstant() < Instant.now() }.getOrDefault(false)
fun isPast(raw: String): Boolean {
val at = epochMillis(raw) ?: return false
return at < System.currentTimeMillis()
}
/**
* Whether a timestamp is older than [minutes] ago — or absent entirely.
@@ -83,8 +87,8 @@ fun olderThan(
raw: String?,
minutes: Long,
): Boolean {
val at = raw?.let { runCatching { OffsetDateTime.parse(it).toInstant() }.getOrNull() }
return at == null || at < Instant.now().minusSeconds(minutes * SECONDS_PER_MINUTE)
val at = raw?.let { epochMillis(it) }
return at == null || at < System.currentTimeMillis() - minutes * MILLIS_PER_MINUTE
}
private const val SECONDS_PER_MINUTE = 60L
private const val MILLIS_PER_MINUTE = 60_000L
@@ -1,18 +1,27 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -113,6 +122,59 @@ fun UpdateCard(
}
}
/**
* The nag: an update is waiting, said where someone will actually see it.
*
* Until this existed the only way to learn about a new build was to open the sync
* screen and press Check — so the updates that got installed were the ones somebody
* went looking for, and the rest were simply never found.
*
* Only ever shown once the build is DOWNLOADED, so the offer is a single tap rather
* than the start of a wait — and so nothing is said at all until the app has been on
* wifi, which is where the fetch happens.
*
* Dismissible, but not permanently. "Later" clears it for this sitting; the next time
* the app comes forward it says so again. That is the difference between a reminder
* and a notice you can lose.
*/
@Composable
fun UpdateBanner(
version: String,
busy: Boolean,
onInstall: () -> Unit,
onDismiss: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = noteTint("blue")
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp)
.clip(RoundedCornerShape(BANNER_RADIUS))
.background(tint.background(dark))
.border(1.dp, tint.border(dark), RoundedCornerShape(BANNER_RADIUS))
.padding(start = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.update_banner_ready, version),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
if (busy) {
CircularProgressIndicator(modifier = Modifier.size(BANNER_SPINNER), strokeWidth = 2.dp)
Spacer(Modifier.size(12.dp))
} else {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.update_later)) }
TextButton(onClick = onInstall) { Text(stringResource(R.string.update_install)) }
}
}
}
private val BANNER_RADIUS = 12.dp
private val BANNER_SPINNER = 18.dp
/**
* The one line an unlinked device gets.
*
@@ -24,10 +24,28 @@ data class UpdateState(
val available: ClientUpdate? = null,
/** A check completed and found nothing. Distinct from "not checked yet". */
val upToDate: Boolean = false,
/** The available build has been fetched and is sitting in the cache. */
val ready: Boolean = false,
val downloading: Boolean = false,
val working: Boolean = false,
val error: String? = null,
/** The banner has been waved away — until the app next comes forward. */
val nagDismissed: Boolean = false,
) {
val busy: Boolean get() = checking || working
val busy: Boolean get() = checking || downloading || working
/**
* Worth interrupting the board for.
*
* Gated on [ready], so the banner never appears until the bytes are on disk. An
* update that has been FOUND is not news anyone can act on quickly — offering it
* off wifi would turn one tap into a download somebody did not plan.
*
* Deliberately still true while [working]: the install is the one moment the
* banner has something to report, and hiding it there would look like the tap
* did nothing.
*/
val nagging: Boolean get() = ready && available != null && !nagDismissed
}
/**
@@ -53,28 +71,103 @@ class UpdateViewModel(
var state by mutableStateOf(UpdateState(installedVersion = AppUpdate.installedVersionCode(context)))
private set
/** Ask the linked server what it has. */
fun check() {
/** When the last check ran, so coming back to the app twice in a minute is one. */
private var lastCheckAt = 0L
/** Ask the linked server what it has. The Check button on the sync screen. */
fun check() = runCheck(fetch = false)
/**
* The automatic path: look, fetch, then nag.
*
* Called when the app comes forward. Until this existed an update was only ever
* found by someone opening the sync screen and pressing a button — so the ones
* that mattered were the ones nobody went looking for.
*
* Skipped when a check is already in flight, when a build is already waiting, and
* when one ran recently: flicking between two apps is not a request to re-check.
*/
fun checkInBackground() {
val now = System.currentTimeMillis()
when {
state.busy -> Unit
// Already fetched and waved away — say so again. "Later" is for that
// sitting, not forever, and without this branch a single dismissal would
// silence the update permanently. Which is precisely the "lost" this whole
// path exists to prevent.
state.ready -> if (state.nagDismissed) state = state.copy(nagDismissed = false)
// Found one and never fetched it — almost always because the last look
// happened on mobile data. Retry the FETCH rather than the check, and
// ignore the interval: this is what makes an update found on the train
// arrive when the person gets home instead of waiting out six hours.
state.available != null ->
if (AppUpdate.onWifi(context)) viewModelScope.launch { download() }
// Flicking between two apps is not a request to re-check.
now - lastCheckAt < CHECK_INTERVAL_MS -> Unit
else -> {
lastCheckAt = now
runCheck(fetch = true)
}
}
}
private fun runCheck(fetch: Boolean) {
viewModelScope.launch {
state = state.copy(checking = true, error = null, upToDate = false)
state =
try {
val found = core.clientUpdate(state.installedVersion)
state.copy(checking = false, available = found, upToDate = found == null)
state.copy(
checking = false,
available = found,
upToDate = found == null,
// A build that is still there is worth mentioning again. The
// dismissal was for that sitting, not for this version.
nagDismissed = if (found == null) state.nagDismissed else false,
)
} catch (e: Exception) {
// Broad by intent, as everywhere the core is called: it reports
// every failure as one error type carrying a message written to
// be read, and a failed check must not take the screen down.
state.copy(checking = false, error = e.message ?: FALLBACK)
}
// Fetched before anything is said, so the banner is a one-tap install
// rather than the start of a wait. Off wifi this simply does not happen
// and the app stays quiet — the next foreground on wifi picks it up.
if (fetch && state.available != null && AppUpdate.onWifi(context)) {
download()
}
}
}
/** Fetch the waiting build into the cache, leaving it for [install]. */
private suspend fun download() {
state = state.copy(downloading = true, error = null)
state =
try {
core.downloadClientUpdate(AppUpdate.downloadTarget(context).absolutePath)
state.copy(downloading = false, ready = true)
} catch (e: Exception) {
state.copy(downloading = false, error = e.message ?: FALLBACK_DOWNLOAD)
}
}
/** Stop nagging for this sitting. The next trip to the foreground says it again. */
fun dismissNag() {
state = state.copy(nagDismissed = true)
}
/**
* Download the update and hand it to the system installer.
* Hand the update to the system installer, downloading first if it is not already
* in the cache.
*
* One action rather than two buttons: nobody wants a downloaded APK sitting
* around as an intermediate state they have to think about.
* Still one action from the outside. A downloaded APK is not a state anyone wants
* to think about, so whether the fetch already happened in the background is this
* class's problem rather than the person's.
*/
fun downloadAndInstall() {
viewModelScope.launch {
@@ -83,7 +176,7 @@ class UpdateViewModel(
val failure =
try {
val target = AppUpdate.downloadTarget(context)
core.downloadClientUpdate(target.absolutePath)
if (!state.ready) core.downloadClientUpdate(target.absolutePath)
// Off the main thread: this streams ~55 MiB into the session.
withContext(Dispatchers.IO) { AppUpdate.install(context, target) }
} catch (e: Exception) {
@@ -126,3 +219,14 @@ class UpdateViewModel(
}
private const val FALLBACK = "The update couldn't be checked."
private const val FALLBACK_DOWNLOAD = "The update couldn't be downloaded."
/**
* How long a background check stays good for.
*
* Long enough that switching to another app and back is not a re-check; short enough
* that a build published this morning is offered today. The same reasoning as sync's
* STALE_MINUTES, at a slower cadence — an app update is not urgent, it is just
* something that must not get lost.
*/
private const val CHECK_INTERVAL_MS = 6L * 60 * 60 * 1000
+78 -16
View File
@@ -6,20 +6,21 @@
<string name="search_hint">Search your notes</string>
<string name="search_clear">Clear search</string>
<string name="nav_open">Open navigation</string>
<string name="nav_labels">Labels</string>
<string name="nav_labels">Tags</string>
<!-- Compose sheet -->
<string name="compose_open">New note</string>
<string name="compose_body_hint">Take a note…</string>
<string name="compose_discard">Discard</string>
<string name="compose_save">Save</string>
<!-- Board -->
<string name="board_empty_note">Empty note</string>
<plurals name="board_more_items">
<item quantity="one">+%d more item</item>
<item quantity="other">+%d more items</item>
</plurals>
<!-- The long-press menu. Its ITEMS are the editor_* strings, deliberately: a
note has one vocabulary of things you can do to it, and a board that said
"Delete" where the editor says "Move to trash" would be describing two
different apps. Only the wrapper and the undo need words of their own. -->
<string name="board_note_actions">Note actions</string>
<string name="board_trashed">Moved to trash</string>
<string name="board_undo">Undo</string>
<!-- Empty states. Each destination says something true of ITSELF; a single
"nothing here" reads as encouragement on the board and as a fault in Trash. -->
@@ -38,15 +39,20 @@
<string name="board_open_note">Open note</string>
<string name="editor_back">Back to notes</string>
<string name="editor_add_checklist">Add a checklist</string>
<string name="editor_body_hint">Note</string>
<string name="editor_body_hint">Take a note</string>
<string name="editor_add_item">Add item</string>
<string name="editor_remove_item">Remove item</string>
<string name="editor_remove_label">Remove label</string>
<string name="editor_remove_label">Remove tag</string>
<string name="editor_reminder">Set a reminder</string>
<string name="editor_more">More actions</string>
<string name="editor_saving">Saving…</string>
<string name="editor_unsaved">Not saved yet</string>
<string name="editor_edited">Edited %1$s</string>
<string name="editor_just_now">just now</string>
<string name="editor_done">Done</string>
<string name="editor_pin">Pin</string>
<string name="editor_unpin">Unpin</string>
<string name="editor_labels">Labels…</string>
<string name="editor_labels">Tags…</string>
<string name="editor_archive">Archive</string>
<string name="editor_unarchive">Unarchive</string>
<string name="editor_trash">Move to trash</string>
@@ -60,12 +66,61 @@
<string name="editor_delete_forever_body">It will be removed from this device and from every device you sync with. This cannot be undone.</string>
<string name="editor_delete_forever_confirm">Delete</string>
<!-- Quick capture from outside the app: the share sheet and the text-selection
toolbar. "New note" says what happens; the activity's own label would say
who it happens in. -->
<string name="capture_process_text">New note</string>
<!-- Tag management. The whole vocabulary is "tag" (see Scribe #2966); the
schema still says Label, and no string here needs to know that. -->
<string name="tags_manage">Manage tags</string>
<string name="tags_title">Tags</string>
<string name="tags_back">Back</string>
<string name="tags_new_hint">New tag</string>
<string name="tags_create">Create</string>
<string name="tags_count">%1$d notes</string>
<string name="tags_count_one">1 note</string>
<string name="tags_count_none">No notes yet</string>
<string name="tags_empty_title">No tags yet</string>
<string name="tags_empty_body">Create one above, or write a #tag in a note and it becomes one.</string>
<string name="tags_actions">More actions</string>
<string name="tags_colour">Colour</string>
<string name="tags_colour_of">Colour for %1$s</string>
<string name="tags_rename">Rename</string>
<string name="tags_rename_title">Rename %1$s</string>
<string name="tags_rename_confirm">Rename</string>
<!-- Renaming onto an existing tag merges the two, older survives (Scribe
#3324). A merge cannot be undone by repeating it and is reachable here by
a typo, so it says so before it happens — same reasoning as #2116. -->
<string name="tags_rename_merges_title">Merge with %1$s?</string>
<string name="tags_rename_merges_body">A tag called %1$s already exists. Renaming will merge these two into one, carrying every note from both. The notes are kept; one of the two tags stops existing, and that cannot be undone.</string>
<string name="tags_rename_merges_confirm">Merge</string>
<string name="tags_merge">Merge into…</string>
<string name="tags_merge_title">Merge %1$s into…</string>
<!-- The survivor is named in the button, not just the title: this is the one
operation here that repeating does not undo. -->
<string name="tags_merge_body">Every note tagged %1$s will be tagged with the one you pick instead, and %1$s will stop existing. The notes are kept.</string>
<string name="tags_merge_none">There is no other tag to merge into.</string>
<string name="tags_delete">Delete</string>
<string name="tags_delete_title">Delete %1$s?</string>
<string name="tags_delete_body">It will be removed from every note that has it, on every device you sync with. The notes themselves are kept.</string>
<string name="tags_delete_body_counted">It is on %1$d notes. It will be removed from all of them, on every device you sync with. The notes themselves are kept.</string>
<string name="tags_delete_confirm">Delete</string>
<!-- A tag written as #tag in a note's body is owned by that text. Deleting the
row cannot un-write the word, so it comes back on that note's next edit —
said here rather than left as a surprise. -->
<string name="tags_delete_from_text">Tags written as #tag in a note come back when that note is next edited.</string>
<string name="tags_cancel">Cancel</string>
<!-- Pickers -->
<string name="color_picker_title">Color</string>
<string name="label_picker_title">Labels</string>
<string name="label_new_hint">Type a label and press enter</string>
<string name="label_from_tag">from #tag</string>
<string name="label_none_body">No labels yet. Type one above, or write a #tag in a note and it becomes one.</string>
<string name="label_picker_title">Tags</string>
<string name="label_new_hint">Type a tag and press enter</string>
<string name="label_from_tag">from the text</string>
<string name="label_none_body">No tags yet. Type one above, or write a #tag in a note and it becomes one.</string>
<string name="picker_next">Next</string>
<string name="picker_set">Set</string>
<string name="picker_time_title">Pick a time</string>
@@ -122,6 +177,8 @@
<string name="update_current">You\'re on the newest build this server has.</string>
<string name="update_check">Check for an update</string>
<string name="update_install">Update</string>
<string name="update_banner_ready">Build %1$s is downloaded and ready.</string>
<string name="update_later">Later</string>
<string name="update_failed_title">The update didn\'t install</string>
<string name="update_permission_title">Android needs your permission</string>
<string name="update_permission_body">ThoughtSync has to be allowed to install apps before it can update itself. This is a one-time setting.</string>
@@ -193,4 +250,9 @@
<!-- Errors -->
<string name="error_dismiss">Dismiss</string>
<!-- The build, at the foot of Sync. Never blank: an APK with no versionName is
a real state (a bare `gradlew assembleDebug` with no override) and saying
so is better than an empty line that reads as a layout bug. -->
<string name="build_unknown">unknown</string>
</resources>
@@ -0,0 +1,137 @@
package com.fabledsword.thoughtsync.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
/**
* Pins the derived-colour rule against `frontend/src/notes/colors.ts`.
*
* These are not tests of Kotlin — they are the ONE mechanical guard the mirrored pair
* has. The web side is TypeScript with no test runner (its CI lane is `vue-tsc
* --noEmit` and nothing else), so if these values drift, nothing on that surface will
* say so and a tag will simply be a different colour on the phone than in the browser.
* The same names and hashes are written into colors.ts as a comment; changing either
* side means changing both and re-checking here.
*
* SMALLER SINCE M315. Half of what this file used to pin — the generated card fill, and
* the resolution order that chose between a picked colour, a tag's and a generated one
* — went with the code it guarded when the card became one neutral. The four UUID
* hashes stay because they are what the hash ITSELF is pinned by; nothing derives a
* colour from an id any more, only from a tag's name.
*/
class DerivedTintTest {
@Test
fun `hashes match the fixture shared with the web`() {
// Kotlin's Int is signed, so the two hashes above 0x7FFFFFFF are written as
// their negative literal. The unsigned value in the comment is what colors.ts
// records and what an implementation of FNV-1a will actually produce.
assertEquals(-0x41B8712F, tintHash("00000000-0000-0000-0000-000000000000")) // 0xbe478ed1
assertEquals(0x3D75CC01, tintHash("11111111-1111-1111-1111-111111111111"))
assertEquals(-0x0EF71AD0, tintHash("6ba7b810-9dad-11d1-80b4-00c04fd430c8")) // 0xf108e530
assertEquals(0x5B651540, tintHash("f47ac10b-58cc-4372-a567-0e02b2c3d479"))
}
@Test
fun `colours match the fixture shared with the web`() {
assertEquals("purple", derivedTint("00000000-0000-0000-0000-000000000000"))
assertEquals("blue", derivedTint("11111111-1111-1111-1111-111111111111"))
assertEquals("orange", derivedTint("6ba7b810-9dad-11d1-80b4-00c04fd430c8"))
assertEquals("orange", derivedTint("f47ac10b-58cc-4372-a567-0e02b2c3d479"))
}
/** Half of all 32-bit hashes are negative as Kotlin Ints; a signed remainder would
* index out of the list for those. The bug this catches is a crash, not a wrong
* colour, so it is worth more than one name's worth of coverage. */
@Test
fun `every derived colour is a real palette key, over many names`() {
for (n in 0 until 2000) {
assertEquals(true, derivedTint("tag-$n") in DERIVED_TINT_KEYS)
}
}
@Test
fun `the derived palette excludes default`() {
assertEquals(false, "default" in DERIVED_TINT_KEYS)
assertEquals(9, DERIVED_TINT_KEYS.size)
}
/** The order IS the mapping — reordering silently recolours every tag on one
* surface only. Written out longhand so a reorder fails here loudly. */
@Test
fun `key order matches colors ts`() {
assertEquals(
listOf("red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray"),
DERIVED_TINT_KEYS,
)
}
/** A tag with no colour of its own derives one from its NAME, which is what makes
* every `#todo` chip the same colour rather than nine different ones. */
@Test
fun `a label with no colour derives one from its name`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
val todo = resolvedLabelColor("todo", "default", known)
assertEquals(derivedTint("todo"), todo)
assertNotEquals("default", todo)
}
/** Tags dedupe case-insensitively, so `#Todo` and `#todo` are one tag and must not
* be two colours. This is the whole reason the name is lowercased first. */
@Test
fun `label colour ignores case`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
assertEquals(
resolvedLabelColor("todo", "default", known),
resolvedLabelColor("ToDo", "default", known),
)
}
/** `teal` deliberately, NOT the colour "todo" derives to (pink) — asserting the
* derived value here would pass even with the explicit branch deleted. */
@Test
fun `an explicitly picked label colour still wins`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
assertNotEquals("teal", derivedTint("todo"))
assertEquals("teal", resolvedLabelColor("todo", "teal", known))
}
/** An unreadable key is not a choice — it is data from a server newer than this
* client, and the tag should still be drawn as something. */
@Test
fun `an unknown colour key falls back to the derived colour`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
assertEquals(derivedTint("todo"), resolvedLabelColor("todo", "chartreuse", known))
}
/** A label with no name at all has nothing to hash. Neutral, not a random hue. */
@Test
fun `a nameless label stays default`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
assertEquals("default", resolvedLabelColor("", "", known))
assertEquals("default", resolvedLabelColor("", "default", known))
}
/**
* The spread is real, and collisions are real too.
*
* Nine keys means two tags sharing a colour is not a bug and cannot be designed
* out — in this very sample `home`/`reading` are both gray and `work`/`ideas` are
* both green. Colour is a hint that two chips are distinct, never a claim that two
* of one colour are the same tag; the chip's TEXT is what says which tag it is.
*/
@Test
fun `different tag names spread across the palette`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
val names = listOf("todo", "grocery", "work", "home", "ideas", "reading", "urgent")
val colours = names.map { resolvedLabelColor(it, "default", known) }
assertEquals(true, colours.toSet().size >= 5)
}
/** The reason the feature exists: two tags on one board should not look identical. */
@Test
fun `the derived colour spreads across the palette`() {
val seen = (0 until 500).map { derivedTint("spread-$it") }.toSet()
assertEquals(DERIVED_TINT_KEYS.size, seen.size)
}
}
+199 -7
View File
@@ -43,8 +43,8 @@ use thoughtsync_core::sync::blobs::BlobStore;
use thoughtsync_core::sync::{client, compat, engine, push, state};
use models::{
patch_from, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery, ProbeResult,
RevokeOutcome, SyncOutcome, SyncStatus,
patch_from, BodyItem, BodyTag, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit,
NoteQuery, ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus,
};
uniffi::setup_scaffolding!();
@@ -333,6 +333,65 @@ impl ThoughtSync {
.map_err(CoreError::store)
}
/// Rename a label. Every note carrying it follows, because notes reference it
/// by id and never by name.
///
/// Renaming onto a name another tag already holds MERGES the two, and the OLDER
/// row is the survivor — it keeps its id and colour and takes the new spelling.
/// Matching is case-insensitive, like `find_or_create_label`.
///
/// So this call can return a label whose id is NOT the one passed in, and it can
/// make another label stop existing. A UI over it should say so before calling:
/// the merge cannot be undone by repeating it, and here it is reachable by a
/// typo in a text field. The web asks first (`stores/labels.ts`); this binding
/// deliberately does not, because a confirmation belongs to the surface that has
/// a person in front of it, not to the store.
///
/// `store::rename_label` and the server's PATCH implement the same rule, so the
/// phone, the desktop and the web agree on which row survives.
pub fn rename_label(&self, id: String, name: String) -> Result<Label, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::rename_label(&conn, &id, &name)
.map(Label::from)
.map_err(CoreError::store)
}
/// Recolour a label.
///
/// `color` is a palette KEY from the shared vocabulary (`NoteTint.kt` on this
/// side), not a hex value — the point of the shared palette is that a colour
/// picked on the phone resolves to the same swatch on the web and the desktop,
/// which a literal colour could not promise across themes.
pub fn set_label_color(&self, id: String, color: String) -> Result<Label, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::set_label_color(&conn, &id, &color)
.map(Label::from)
.map_err(CoreError::store)
}
/// Delete a label. The notes that carried it are NOT deleted — they simply stop
/// carrying it, which is the thing a confirmation dialog has to say out loud.
///
/// A `#tag` in a body will re-derive the label on the next edit of that note.
/// That is correct rather than a leak: the text mandates it, and deleting the
/// row cannot un-write the word.
pub fn remove_label(&self, id: String) -> Result<(), CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::remove_label(&conn, &id).map_err(CoreError::store)
}
/// Fold `source` into `target` and return the survivor.
///
/// DIRECTIONAL and NOT reversible by repeating it: source stops existing. Any
/// UI over this has to name the survivor before it runs, because afterwards
/// there is nothing left to read the direction from.
pub fn merge_labels(&self, source_id: String, target_id: String) -> Result<Label, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::merge_labels(&conn, &source_id, &target_id)
.map(Label::from)
.map_err(CoreError::store)
}
// ─────────────────────────────── sync ────────────────────────────────
pub fn sync_status(&self) -> Result<SyncStatus, CoreError> {
@@ -499,6 +558,66 @@ impl ThoughtSync {
}
}
// ── checklist text, as pure functions ───────────────────────────────────────
//
// The pair the block editor is built on: one to read a body apart, one to put a line
// back together. Between them, Kotlin can render a checklist as real checkboxes and
// write the markdown back without owning the grammar — which is the point. Three
// implementations of it is the price already being paid (Rust, Python, TypeScript);
// a fourth in Compose would be one more place for a checklist to change shape when
// it syncs.
//
// Free functions rather than methods, because they touch no database. The editor's
// body is LOCAL state — autosaved on an idle debounce, not written per keystroke —
// so editing a checklist there has to rewrite the text the editor is holding, not a
// row the store would hand back a moment later and overwrite the typing with.
/// One checklist item as the body line that stores it. For an editor that shows a
/// checkbox instead of the markup and has to write the markup back.
#[uniffi::export]
pub fn checklist_render(text: String, checked: bool) -> String {
local::derive::render_item(&text, checked)
}
/// Tell the core which app it is running inside, and which build of it.
///
/// Android has to say so because the core cannot: the same crate is compiled into
/// the desktop app, and it used to announce every phone in the field as
/// `thoughtsync-desktop` carrying the CORE crate's version — a number no build
/// stamps and nobody has seen. The honest value is the installed package's own
/// `versionName`, which is what Kotlin passes here.
///
/// Called once from `ThoughtSyncApplication.onCreate`, before anything can sync.
#[uniffi::export]
pub fn set_client_agent(name: String, version: String) {
compat::set_client_agent(&name, &version);
}
/// Every checklist item in a body, with the line each one sits on — so a renderer
/// walking the body line by line knows which lines are boxes and what is in them.
#[uniffi::export]
pub fn checklist_items(body: String) -> Vec<BodyItem> {
local::derive::extract_items(&body)
.into_iter()
.map(BodyItem::from)
.collect()
}
/// Every `#tag` in a body, with the line and the UTF-16 span each one occupies — so
/// a card can colour the tag where it was typed instead of printing it twice.
///
/// The same argument as `checklist_items` above, and the same answer: the grammar for
/// what a `#tag` is already exists in Rust, Python and TypeScript. Matching it a
/// fourth time in Compose would be a fourth place for a tag to change shape when it
/// syncs — and this one would fail silently, as the wrong characters tinted.
#[uniffi::export]
pub fn body_tags(body: String) -> Vec<BodyTag> {
local::derive::extract_tag_spans(&body)
.into_iter()
.map(BodyTag::from)
.collect()
}
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
/// block names, so these stay Rust-side.
impl ThoughtSync {
@@ -571,7 +690,6 @@ mod tests {
fn draft(body: &str) -> NoteDraft {
NoteDraft {
body: body.to_string(),
color: "default".to_string(),
items: None,
}
}
@@ -625,7 +743,6 @@ mod tests {
let created = app
.create_note(NoteDraft {
body: String::new(),
color: "default".to_string(),
items: Some(vec!["milk".to_string(), "eggs".to_string()]),
})
.expect("create should succeed");
@@ -661,7 +778,6 @@ mod tests {
let note = app
.create_note(NoteDraft {
body: "Packing".to_string(),
color: "default".to_string(),
items: Some(vec!["socks".to_string()]),
})
.expect("create");
@@ -682,8 +798,9 @@ mod tests {
assert!(ticked.items[1].checked);
assert_eq!(
ticked.items[1].text, "charger",
"ticking a box must not disturb its text — the two setters write \
different columns and neither may clear the other"
"ticking a box must not disturb its text — both setters rewrite the \
same line of the body now, so one clobbering the other is a live risk \
rather than a theoretical one"
);
let renamed = app
@@ -848,6 +965,81 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
/// Renaming a tag onto a name another tag already holds MERGES the two, and the
/// OLDER row is the survivor.
///
/// Before this, the bare UPDATE met `idx_labels_name` — unique on `lower(name)` —
/// and the user got a raw "UNIQUE constraint failed" from SQLite. Merging is what
/// a person means by typing an existing tag's name onto this one.
#[test]
fn renaming_onto_an_existing_tag_merges_into_the_older_one() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let older = app.create_label("grocery".to_string()).expect("older");
// `created_at` is RFC3339 to the MILLISECOND. Without a gap the two rows can
// share a timestamp, and then the tie-break is under test instead of the age
// rule this test is about.
std::thread::sleep(std::time::Duration::from_millis(5));
let newer = app.create_label("errands".to_string()).expect("newer");
let one = app.create_note(draft("milk")).expect("note one");
let two = app.create_note(draft("stamps")).expect("note two");
app.set_note_labels(one.id.clone(), vec![older.id.clone()])
.expect("tag one");
app.set_note_labels(two.id.clone(), vec![newer.id.clone()])
.expect("tag two");
// The YOUNGER one is renamed onto the older's name, in a different case —
// matching is case-insensitive, and the survivor takes the spelling asked for.
let survivor = app
.rename_label(newer.id.clone(), "Grocery".to_string())
.expect("a rename onto an existing name merges instead of failing");
assert_eq!(
survivor.id, older.id,
"the older row is the one that survives"
);
assert_eq!(survivor.name, "Grocery", "spelled the way the caller asked");
let all = app.list_labels().expect("list");
assert_eq!(all.len(), 1, "the two became one");
assert_eq!(all[0].id, older.id);
assert_eq!(all[0].count, Some(2), "carrying every note from both sides");
std::fs::remove_dir_all(&dir).ok();
}
/// The mirror of the test above. Renaming the OLDER one onto the younger's name
/// still leaves the older row standing — it just changes its name.
///
/// This is the whole reason age decides rather than "whoever already held the
/// name": otherwise the survivor depends on which way round someone typed it,
/// and two devices tidying the same pair would disagree about which id exists.
#[test]
fn the_rename_merge_survivor_does_not_depend_on_the_direction() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let older = app.create_label("grocery".to_string()).expect("older");
std::thread::sleep(std::time::Duration::from_millis(5));
let newer = app.create_label("errands".to_string()).expect("newer");
let survivor = app
.rename_label(older.id.clone(), "errands".to_string())
.expect("rename");
assert_eq!(survivor.id, older.id, "age wins in this direction too");
assert_eq!(survivor.name, "errands");
assert_ne!(
survivor.id, newer.id,
"the younger row is the one that went"
);
assert_eq!(app.list_labels().expect("list").len(), 1);
std::fs::remove_dir_all(&dir).ok();
}
/// A crude RFC3339 sanity check that doesn't pull a date crate into this
/// crate's dev-dependencies to assert one field is well-formed.
fn chrono_free_parse(raw: &str) -> usize {
+59 -12
View File
@@ -33,7 +33,6 @@ pub struct Note {
/// Always present. Derived by the core, never stored.
pub display_title: String,
pub body: String,
pub color: String,
pub position: i64,
pub pinned: bool,
pub archived: bool,
@@ -49,6 +48,63 @@ pub struct Note {
pub updated_at: Option<String>,
}
/// A checklist item as it sits in a note's body.
///
/// Mirrors `derive::DerivedItem`. Carries the LINE because every renderer that walks
/// a body line by line needs the text, the state and the position together — the card
/// to draw a box in the right place, the block editor to know where one block ends.
#[derive(Debug, Clone, uniffi::Record)]
pub struct BodyItem {
pub line: u32,
pub text: String,
pub checked: bool,
}
impl From<thoughtsync_core::local::derive::DerivedItem> for BodyItem {
fn from(i: thoughtsync_core::local::derive::DerivedItem) -> Self {
let thoughtsync_core::local::derive::DerivedItem {
text,
checked,
line,
} = i;
BodyItem {
line,
text,
checked,
}
}
}
/// One `#tag` and where it sits in a note's body.
///
/// Mirrors `derive::DerivedTag`. The card colours the tag where it was typed rather
/// than repeating it as a chip, so it needs the SPAN — and the offsets are UTF-16
/// code units precisely because Kotlin's `AnnotatedString` counts that way.
#[derive(Debug, Clone, uniffi::Record)]
pub struct BodyTag {
pub line: u32,
pub start: u32,
pub end: u32,
pub name: String,
}
impl From<thoughtsync_core::local::derive::DerivedTag> for BodyTag {
fn from(t: thoughtsync_core::local::derive::DerivedTag) -> Self {
let thoughtsync_core::local::derive::DerivedTag {
line,
start,
end,
name,
} = t;
BodyTag {
line,
start,
end,
name,
}
}
}
/// An Android build the linked server is offering, already judged to be newer.
///
/// A mirror rather than a re-export of `client::ClientRelease`, for the same
@@ -130,7 +186,6 @@ impl From<core_models::Note> for Note {
id,
display_title,
body,
color,
position,
pinned,
archived,
@@ -149,7 +204,6 @@ impl From<core_models::Note> for Note {
id,
display_title,
body,
color,
position,
pinned,
archived,
@@ -287,7 +341,6 @@ pub struct NoteQuery {
#[derive(Debug, Clone, uniffi::Record)]
pub struct NoteFacets {
pub q: Option<String>,
pub color: Option<String>,
pub label: Option<Vec<String>>,
pub has_reminder: Option<bool>,
pub has_attachment: Option<bool>,
@@ -316,7 +369,6 @@ impl From<NoteFacets> for core_models::Facets {
fn from(value: NoteFacets) -> Self {
let NoteFacets {
q,
color,
label,
has_reminder,
has_attachment,
@@ -325,7 +377,6 @@ impl From<NoteFacets> for core_models::Facets {
} = value;
core_models::Facets {
q,
color,
label,
has_reminder,
has_attachment,
@@ -339,8 +390,6 @@ impl From<NoteFacets> for core_models::Facets {
#[derive(Debug, Clone, uniffi::Record)]
pub struct NoteDraft {
pub body: String,
/// "default" unless the user picked a colour.
pub color: String,
/// Checklist lines. A note can carry both a body and items (M13 step 2), so this
/// is not an alternative to `body` — it is an addition to it.
pub items: Option<Vec<String>>,
@@ -348,8 +397,8 @@ pub struct NoteDraft {
impl From<NoteDraft> for core_models::NoteCreateInput {
fn from(value: NoteDraft) -> Self {
let NoteDraft { body, color, items } = value;
core_models::NoteCreateInput { body, color, items }
let NoteDraft { body, items } = value;
core_models::NoteCreateInput { body, items }
}
}
@@ -364,7 +413,6 @@ impl From<NoteDraft> for core_models::NoteCreateInput {
#[derive(Debug, Clone, uniffi::Enum)]
pub enum NoteEdit {
Body { value: String },
Color { value: String },
Pinned { value: bool },
Archived { value: bool },
RemindAt { value: String },
@@ -384,7 +432,6 @@ impl NoteEdit {
use serde_json::Value;
match self {
NoteEdit::Body { value } => ("body", Value::String(value)),
NoteEdit::Color { value } => ("color", Value::String(value)),
NoteEdit::Pinned { value } => ("pinned", Value::Bool(value)),
NoteEdit::Archived { value } => ("archived", Value::Bool(value)),
NoteEdit::RemindAt { value } => ("remind_at", Value::String(value)),
+707 -9
View File
@@ -1,19 +1,31 @@
//! Deriving `#tags` from a note's body — the local mirror of what the server computes
//! on save. Pure string scanning (no regex dependency), kept in lockstep with the
//! frontend's inline rules (see frontend notes/markdown.ts):
//! Deriving structure from a note's body — the local mirror of what the server
//! computes on save. Pure string scanning (no regex dependency), kept in lockstep
//! with the frontend's inline rules (see frontend notes/markdown.ts):
//!
//! - `#tag`: `#` at a word boundary followed by tag characters (letter first).
//! On save these become labels attached with `via_tag = true`.
//! - `- [ ] item`: a checklist item. The body IS the checklist (M304) — there is no
//! table of items beside it, so a list can sit between two paragraphs instead of
//! only after them.
//!
//! The two are the same idea at different strengths. Tags MATERIALISE into label
//! rows, because the board queries by label. Items materialise into nothing,
//! because nothing queries them: their only readers are the card, the editor and
//! `display_title`. So `extract_items` is the whole storage layer for a checklist,
//! and the rewriters below are how one is edited.
//!
//! Dedupes case-insensitively, preserving first-seen order.
//!
//! Also derived `[[wiki-links]]` until they were removed (note 2897) — this is a
//! capture-and-recall surface, and a linking system is organization.
/// Extract every `#tag` name (without the leading `#`) from `body`.
pub fn extract_tags(body: &str) -> Vec<String> {
let chars: Vec<char> = body.chars().collect();
let mut out: Vec<String> = Vec::new();
/// Every `#tag` in ONE line, as `(start, end, name)` in char indices.
///
/// Char indices rather than byte offsets so the spans can be used to cut the tags
/// back out of the line without ever landing mid-codepoint — see
/// [`lift_standalone_tags`], which is the only reason the spans exist.
fn line_tags(chars: &[char]) -> Vec<(usize, usize, String)> {
let mut out: Vec<(usize, usize, String)> = Vec::new();
let mut i = 0;
while i < chars.len() {
if chars[i] == '#' {
@@ -24,8 +36,7 @@ pub fn extract_tags(body: &str) -> Vec<String> {
while j < chars.len() && is_tag_char(chars[j]) {
j += 1;
}
let tag: String = chars[i + 1..j].iter().collect();
push_unique(&mut out, &tag);
out.push((i, j, chars[i + 1..j].iter().collect()));
i = j;
continue;
}
@@ -35,6 +46,183 @@ pub fn extract_tags(body: &str) -> Vec<String> {
out
}
/// Extract every `#tag` name (without the leading `#`) from `body`.
///
/// Line by line, which changes nothing: a line start and a `\n` are both boundaries,
/// so the same tags come out. It means there is ONE scanner rather than two — this and
/// [`lift_standalone_tags`] cannot disagree about what a tag is.
pub fn extract_tags(body: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for line in body.split('\n') {
let chars: Vec<char> = line.chars().collect();
for (_, _, name) in line_tags(&chars) {
push_unique(&mut out, &name);
}
}
out
}
/// One `#tag` and exactly where it sits, for a renderer drawing the body itself.
///
/// The card no longer prints a chip for a tag whose text is still in the note — it
/// colours the token where it was typed instead. To do that a renderer needs the
/// SPAN, not just the name, and asking it to find the name again would be a second
/// grammar quietly disagreeing with this one about what `##a` or `#1` is.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DerivedTag {
/// Which body line it sits on, like [`DerivedItem::line`].
pub line: u32,
/// Offsets into that line, in UTF-16 code units — INCLUDING the leading `#`.
///
/// UTF-16 rather than chars or bytes because the two languages that consume this
/// both index strings that way: Kotlin's `AnnotatedString` and JavaScript. A char
/// index is right up until somebody puts an emoji before a tag, and then it lands
/// mid-token with no error anywhere.
pub start: u32,
pub end: u32,
pub name: String,
}
/// Every `#tag` in `body` with its position — the same scan [`extract_tags`] does,
/// keeping the spans instead of throwing them away.
///
/// Not deduped, unlike `extract_tags`: two mentions of `#todo` are two pieces of text
/// to colour. Fences are not skipped either, and that is deliberate — `extract_tags`
/// does not skip them, so a `#tag` inside a code block IS a label on the note, and a
/// renderer that left it plain would be the only surface disagreeing.
pub fn extract_tag_spans(body: &str) -> Vec<DerivedTag> {
let mut out = Vec::new();
for (n, line) in body.split('\n').enumerate() {
let chars: Vec<char> = line.chars().collect();
let spans = line_tags(&chars);
if spans.is_empty() {
continue;
}
// Prefix sums, built once per tagged line: char index -> UTF-16 offset.
let mut units: Vec<u32> = Vec::with_capacity(chars.len() + 1);
let mut total: u32 = 0;
units.push(0);
for c in &chars {
total += c.len_utf16() as u32;
units.push(total);
}
for (start, end, name) in spans {
out.push(DerivedTag {
line: n as u32,
start: units[start],
end: units[end],
name,
});
}
}
out
}
/// Whether a line opens or closes a fenced code block.
fn is_fence(line: &str) -> bool {
let trimmed = line.trim_start();
trimmed.starts_with("```") || trimmed.starts_with("~~~")
}
/// Runs of three or more newlines become two, and the ends are trimmed.
///
/// Removing a line must not leave a hole where it was.
fn collapse_blank_runs(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut run = 0;
for c in text.chars() {
if c == '\n' {
run += 1;
if run <= 2 {
out.push(c);
}
} else {
run = 0;
out.push(c);
}
}
out.trim_matches('\n').to_string()
}
/// Split a body's tags by whether the text around them can be taken away.
///
/// Returns `(standalone, inline, lifted_body)`.
///
/// THE RULE: a line containing nothing but tags and whitespace is removed. Anything
/// else is left exactly as written.
///
/// The MIRROR of `split_body_tags` in the server's `notes/tags.py`, and it has to stay
/// one: a note lifted differently here than there would change under the operator the
/// moment it synced. Same discipline, and the same reason, as `DerivedTint`.
///
/// The conservative reading of "standalone" is deliberate. A trailing tag is
/// ambiguous and the text does not say which it is — `buy milk #grocery` is filing,
/// `remember to call #mom` is the sentence's object, and lifting the second leaves
/// "remember to call". A tag sharing a line with words keeps its words.
///
/// `standalone` tags become ORDINARY labels (`via_tag = 0`): nothing is left to derive
/// them from, so the row becomes the record and the chip's × becomes the way to remove
/// one. `inline` tags stay derived exactly as before. That is what `via_tag` means from
/// here on — backed by text still in the body.
pub fn lift_standalone_tags(body: &str) -> (Vec<String>, Vec<String>, String) {
let mut standalone: Vec<String> = Vec::new();
let mut inline: Vec<String> = Vec::new();
let mut kept: Vec<&str> = Vec::new();
let mut in_fence = false;
for line in body.split('\n') {
if is_fence(line) {
in_fence = !in_fence;
kept.push(line);
continue;
}
let chars: Vec<char> = line.chars().collect();
let spans = line_tags(&chars);
// Cut the tags out and see whether anything is left. That is what
// "standalone" means, and it is the whole rule.
let mut remainder = String::new();
let mut pos = 0;
for (start, end, _) in &spans {
remainder.extend(chars[pos..*start].iter());
pos = *end;
}
remainder.extend(chars[pos..].iter());
// A fence's contents are CODE: a `#tag` there is a shell comment in somebody's
// snippet, and deleting the line would eat part of their example.
if in_fence || spans.is_empty() || !remainder.trim().is_empty() {
for (_, _, name) in &spans {
push_unique(&mut inline, name);
}
kept.push(line);
} else {
for (_, _, name) in &spans {
push_unique(&mut standalone, name);
}
}
}
let lifted = collapse_blank_runs(&kept.join("\n"));
if !body.trim().is_empty() && lifted.trim().is_empty() {
// The note was NOTHING but tags. Lifting would leave a blank card, which is a
// worse outcome than a duplicated chip — so leave it alone.
let mut all = standalone;
for name in &inline {
push_unique(&mut all, name);
}
return (Vec::new(), all, body.to_string());
}
// A tag that ALSO appears in prose stays derived: the prose copy still backs it,
// so deleting that copy should still detach the label.
let inline_lower: Vec<String> = inline.iter().map(|n| n.to_lowercase()).collect();
let standalone = standalone
.into_iter()
.filter(|n| !inline_lower.contains(&n.to_lowercase()))
.collect();
(standalone, inline, lifted)
}
fn is_tag_char(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '-'
}
@@ -45,6 +233,239 @@ fn push_unique(out: &mut Vec<String>, candidate: &str) {
}
}
// ── checklist items ─────────────────────────────────────────────────────────
//
// The grammar, in one place, because three languages implement it (here,
// `notes/checklist.py`, `notes/markdown.ts`) and a difference between any two of
// them is a checklist that changes shape when it syncs:
//
// optional indent, `-` or `*`, one-or-more spaces, `[ ]`/`[x]`/`[X]`,
// then either end-of-line or one-or-more spaces and the text.
//
// `*` is accepted because markdown.ts already accepts it for a plain bullet, and a
// grammar that takes `* item` but not `* [ ] item` would be a rule with no reason
// anyone could guess. `- [ ]` with nothing after it IS an item with empty text:
// that is exactly what pressing Enter on a list leaves behind, and refusing to
// parse it would make a half-typed list stop being a list.
/// A checklist item, as found in the body. Its position in the returned vector is
/// its identity — the same thing `position` meant when these were rows, and all the
/// wire ever carried (`push.rs` sent text and checked, never an id).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DerivedItem {
pub text: String,
pub checked: bool,
/// Which body line it sits on.
///
/// Carried here rather than offered as a second function, because every renderer
/// that walks a body line by line — the Android card, the block editor — needs the
/// text, the state AND the position together, and asking for them separately is
/// how two calls come to disagree about a body that changed between them.
pub line: u32,
}
/// One parsed task line, holding enough to put it back exactly as it was found.
struct TaskLine<'a> {
indent: &'a str,
/// Preserved rather than normalised to `-`: rewriting someone's `*` bullets
/// because they ticked a box would be an edit they did not ask for.
bullet: char,
checked: bool,
text: &'a str,
}
fn parse_task_line(line: &str) -> Option<TaskLine<'_>> {
let indent_len = line.len() - line.trim_start().len();
let (indent, rest) = line.split_at(indent_len);
let bullet = rest.chars().next()?;
if bullet != '-' && bullet != '*' {
return None;
}
// At least one space after the bullet. `-[ ] x` is not a list item in any
// markdown either, so it stays prose here too.
let rest = &rest[bullet.len_utf8()..];
let gap = rest.len() - rest.trim_start_matches(' ').len();
if gap == 0 {
return None;
}
let rest = &rest[gap..];
let mut chars = rest.chars();
if chars.next()? != '[' {
return None;
}
let mark = chars.next()?;
if chars.next()? != ']' {
return None;
}
// Decided BEFORE the slice below, which is what guarantees `mark` is one byte
// and `[?]` is exactly three.
let checked = match mark {
' ' => false,
'x' | 'X' => true,
_ => return None,
};
let rest = &rest[3..];
let text = if rest.is_empty() {
// "- [ ]" — an empty item, which is what an unfinished list line is.
rest
} else {
let gap = rest.len() - rest.trim_start_matches(' ').len();
// "- [ ]x" is prose: without the space this is not a marker, it is a
// sentence that happens to start with brackets.
if gap == 0 {
return None;
}
&rest[gap..]
};
Some(TaskLine {
indent,
bullet,
checked,
text,
})
}
/// One item as the line that stores it, in canonical form.
///
/// Public because a block editor has to write a line back after someone edits it in a
/// widget that never showed them the marker. Rendering is trivial where PARSING is
/// not, but it still belongs here: this is the file that decides what canonical looks
/// like, and a caller inventing its own `- [x] ` would be a fourth opinion on it.
pub fn render_item(text: &str, checked: bool) -> String {
render_task_line("", '-', checked, text)
}
fn render_task_line(indent: &str, bullet: char, checked: bool, text: &str) -> String {
// Always lowercase `x`, whatever was parsed: one canonical output is what makes
// a round trip stable, so `- [X]` normalises the first time it is touched and
// never again.
let mark = if checked { 'x' } else { ' ' };
if text.is_empty() {
format!("{indent}{bullet} [{mark}]")
} else {
format!("{indent}{bullet} [{mark}] {text}")
}
}
/// The text of a line with its task marker removed, or the line as it was.
///
/// For naming a note: a list-only note is named by its first item, and calling one
/// "- [ ] milk" would be showing someone the storage instead of the note.
pub fn strip_marker(line: &str) -> &str {
match parse_task_line(line) {
Some(t) => t.text,
None => line,
}
}
/// Every checklist item in `body`, in the order they appear.
pub fn extract_items(body: &str) -> Vec<DerivedItem> {
let mut out = Vec::new();
for (n, line) in body.split('\n').enumerate() {
if let Some(t) = parse_task_line(line) {
out.push(DerivedItem {
text: t.text.to_string(),
checked: t.checked,
line: n as u32,
});
}
}
out
}
/// Rewrite the `index`-th task line, or drop it when `f` returns None.
///
/// A body with fewer task lines than that is returned UNCHANGED rather than
/// panicking: the index comes from a UI that may be a moment behind the store, and
/// a stale tap should do nothing rather than take the app down.
fn map_task_line<F>(body: &str, index: usize, f: F) -> String
where
F: FnOnce(&TaskLine<'_>) -> Option<String>,
{
let lines: Vec<&str> = body.split('\n').collect();
let mut target: Option<usize> = None;
let mut seen = 0usize;
for (n, line) in lines.iter().enumerate() {
if parse_task_line(line).is_some() {
if seen == index {
target = Some(n);
break;
}
seen += 1;
}
}
let target = match target {
Some(n) => n,
None => return body.to_string(),
};
let replacement = match parse_task_line(lines[target]) {
Some(parsed) => f(&parsed),
None => return body.to_string(),
};
let mut out: Vec<String> = Vec::with_capacity(lines.len());
for (n, line) in lines.iter().enumerate() {
if n != target {
out.push((*line).to_string());
} else if let Some(new_line) = &replacement {
out.push(new_line.clone());
}
// None at the target line drops it, which is `remove_item`.
}
out.join("\n")
}
/// Tick or untick the `index`-th item.
pub fn set_item_checked(body: &str, index: usize, checked: bool) -> String {
map_task_line(body, index, |t| {
Some(render_task_line(t.indent, t.bullet, checked, t.text))
})
}
/// Replace the text of the `index`-th item, keeping its state and its bullet.
pub fn set_item_text(body: &str, index: usize, text: &str) -> String {
map_task_line(body, index, |t| {
Some(render_task_line(t.indent, t.bullet, t.checked, text.trim()))
})
}
/// Delete the `index`-th item, line and all.
pub fn remove_item(body: &str, index: usize) -> String {
map_task_line(body, index, |_| None)
}
/// Add an item at the end of the body.
///
/// Spaced exactly as `import_export.py:_note_markdown` writes a list — a blank line
/// between prose and the list, and nothing between consecutive items. That is not
/// cosmetic: the server migration folds existing rows into bodies using the same
/// layout, so an export taken before the migration and one taken after have to
/// agree byte for byte.
///
/// `checked` is a parameter rather than always false because the two migrations that
/// fold existing rows into bodies have to carry the state those rows were in. A new
/// item from the UI passes false.
pub fn append_item(body: &str, text: &str, checked: bool) -> String {
let line = render_task_line("", '-', checked, text.trim());
let trimmed = body.trim_end_matches('\n');
if trimmed.trim().is_empty() {
return line;
}
let follows_a_list = trimmed
.split('\n')
.next_back()
.is_some_and(|l| parse_task_line(l).is_some());
if follows_a_list {
format!("{trimmed}\n{line}")
} else {
format!("{trimmed}\n\n{line}")
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -72,4 +493,281 @@ mod tests {
fn empty_body() {
assert!(extract_tags("").is_empty());
}
// ── tag spans, for the renderer that draws them in place ─────────────────
#[test]
fn tag_spans_carry_the_hash_and_the_line() {
let spans = extract_tag_spans("buy milk #grocery\nand call #mom about #mom");
assert_eq!(spans.len(), 3);
assert_eq!((spans[0].line, spans[0].start, spans[0].end), (0, 9, 17));
assert_eq!(spans[0].name, "grocery");
// Not deduped: two mentions are two pieces of text to colour.
assert_eq!(spans[1].line, 1);
assert_eq!(spans[2].name, "mom");
assert_eq!((spans[2].start, spans[2].end), (20, 24));
}
#[test]
fn tag_spans_are_utf16_offsets_not_char_indices() {
// The emoji is ONE char and TWO UTF-16 code units. Kotlin and JS both index
// the second way, so a char index would highlight one character too early.
let spans = extract_tag_spans("🎁 #gift");
assert_eq!(spans.len(), 1);
assert_eq!((spans[0].start, spans[0].end), (3, 8));
}
#[test]
fn tag_spans_agree_with_extract_tags_about_what_a_tag_is() {
let body = "#1 nope a#b no but #Yes ##no";
let names: Vec<String> = extract_tag_spans(body)
.into_iter()
.map(|t| t.name)
.collect();
assert_eq!(names, extract_tags(body));
}
// ── lifting standalone tags ──────────────────────────────────────────────
//
// The MIRROR of `split_body_tags` in the server's notes/tags.py, case for case.
// A note lifted differently here than there would change under the operator the
// moment it synced, so these are the cases that file agrees to.
#[test]
fn lifts_a_line_that_is_nothing_but_tags() {
let (standalone, inline, body) = lift_standalone_tags("#todo\nreorganize the homepage");
assert_eq!(standalone, vec!["todo"]);
assert!(inline.is_empty());
assert_eq!(body, "reorganize the homepage");
let (standalone, _, body) = lift_standalone_tags("needs a tauri app\n#todo");
assert_eq!(standalone, vec!["todo"]);
assert_eq!(body, "needs a tauri app");
let (standalone, _, body) = lift_standalone_tags("#todo #work\nreal text");
assert_eq!(standalone, vec!["todo", "work"]);
assert_eq!(body, "real text");
}
/// The cases that must come back byte-identical. Getting any of these wrong
/// destroys somebody's words, which is why the rule is the conservative one:
/// a trailing tag is ambiguous and the text does not say which kind it is.
#[test]
fn leaves_a_tag_that_shares_its_line_with_words() {
for prose in [
"remember to call #mom tomorrow",
"buy milk #grocery",
"#2024\nreal",
] {
let (standalone, _, body) = lift_standalone_tags(prose);
assert!(standalone.is_empty(), "{prose}");
assert_eq!(body, prose, "{prose}");
}
}
#[test]
fn removing_a_line_leaves_no_hole() {
let (_, _, body) = lift_standalone_tags("foo\n\n#todo\n\nbar");
assert_eq!(body, "foo\n\nbar");
}
/// A `#tag` in a fence is a shell comment in somebody's snippet. It still becomes
/// a label — it always has — but the line is never touched.
#[test]
fn never_touches_a_fenced_line() {
let fenced = "code:\n```\n#!/bin/sh\n#deploy\n```\ndone";
let (standalone, inline, body) = lift_standalone_tags(fenced);
assert!(standalone.is_empty());
assert_eq!(inline, vec!["deploy"]);
assert_eq!(body, fenced);
}
/// Lifting would leave a blank card, which is worse than the duplication this
/// removes. So the note keeps its text and its tags stay derived.
#[test]
fn will_not_blank_a_note_that_is_only_tags() {
let (standalone, inline, body) = lift_standalone_tags("#todo");
assert!(standalone.is_empty());
assert_eq!(inline, vec!["todo"]);
assert_eq!(body, "#todo");
}
/// Appearing on its own line does NOT lift a tag also written in a sentence — the
/// sentence still backs it, so deleting the sentence should still detach it.
#[test]
fn a_tag_still_in_prose_stays_derived() {
let (standalone, inline, body) = lift_standalone_tags("#todo\nremember the #todo list");
assert!(standalone.is_empty());
assert_eq!(inline, vec!["todo"]);
assert_eq!(body, "remember the #todo list");
}
#[test]
fn lifting_an_empty_body_is_a_no_op() {
let (standalone, inline, body) = lift_standalone_tags("");
assert!(standalone.is_empty());
assert!(inline.is_empty());
assert_eq!(body, "");
}
// ── checklist items ─────────────────────────────────────────────────────
fn item(text: &str, checked: bool, line: u32) -> DerivedItem {
DerivedItem {
text: text.to_string(),
checked,
line,
}
}
#[test]
fn items_basic() {
let body = "shopping\n\n- [ ] milk\n- [x] eggs";
assert_eq!(
extract_items(body),
vec![item("milk", false, 2), item("eggs", true, 3)]
);
}
#[test]
fn items_may_sit_between_paragraphs() {
// The whole reason the body owns the list: a table of rows could only ever
// render after the prose.
let body = "before\n- [ ] middle\nafter";
assert_eq!(extract_items(body), vec![item("middle", false, 1)]);
}
#[test]
fn items_reject_near_misses() {
// Each of these is prose, and each has been someone's bug report somewhere.
for body in [
"-[ ] no space after the dash",
"- [] empty brackets",
"- [ ]no space after the brackets",
"- [y] not a mark",
"a [ ] mid sentence",
"[ ] no bullet at all",
] {
assert!(extract_items(body).is_empty(), "should be prose: {body}");
}
}
#[test]
fn items_accept_star_bullets_and_indentation() {
// `*` because markdown.ts already takes it for a plain bullet.
let body = "* [ ] star\n - [x] indented";
assert_eq!(
extract_items(body),
vec![item("star", false, 0), item("indented", true, 1)]
);
}
#[test]
fn an_empty_item_is_still_an_item() {
// What pressing Enter on a list leaves behind.
assert_eq!(extract_items("- [ ]"), vec![item("", false, 0)]);
assert_eq!(extract_items("- [ ] "), vec![item("", false, 0)]);
}
#[test]
fn uppercase_x_parses_and_normalises_on_rewrite() {
assert_eq!(extract_items("- [X] done"), vec![item("done", true, 0)]);
// Touching it once canonicalises it, and never again.
assert_eq!(set_item_checked("- [X] done", 0, true), "- [x] done");
}
#[test]
fn checking_preserves_indent_bullet_and_text() {
assert_eq!(set_item_checked(" * [ ] milk", 0, true), " * [x] milk");
assert_eq!(set_item_checked("- [x] milk", 0, false), "- [ ] milk");
}
#[test]
fn checking_addresses_items_not_lines() {
let body = "note\n- [ ] a\nprose\n- [ ] b";
assert_eq!(
set_item_checked(body, 1, true),
"note\n- [ ] a\nprose\n- [x] b"
);
}
#[test]
fn set_text_keeps_state() {
assert_eq!(set_item_text("- [x] old", 0, "new"), "- [x] new");
}
#[test]
fn remove_takes_the_whole_line() {
let body = "keep\n- [ ] drop\n- [ ] stay";
assert_eq!(remove_item(body, 0), "keep\n- [ ] stay");
}
#[test]
fn append_spaces_like_the_exporter() {
// Prose then a blank line then the list — byte-for-byte what
// import_export.py:_note_markdown writes, which is what the server
// migration will fold existing rows into.
assert_eq!(append_item("a note", "milk", false), "a note\n\n- [ ] milk");
// Nothing between consecutive items.
let one = "a note\n\n- [ ] milk";
assert_eq!(
append_item(one, "eggs", false),
format!("{one}\n- [ ] eggs")
);
// A list-only note starts at the first line.
assert_eq!(append_item("", "milk", false), "- [ ] milk");
assert_eq!(append_item("\n\n", "milk", false), "- [ ] milk");
// Carries state, which is what the two migrations need of it.
assert_eq!(append_item("", "done", true), "- [x] done");
}
#[test]
fn strip_marker_names_a_list_only_note() {
assert_eq!(strip_marker("- [x] milk"), "milk");
assert_eq!(strip_marker("just prose"), "just prose");
}
#[test]
fn render_item_is_what_extract_reads_back() {
assert_eq!(render_item("milk", false), "- [ ] milk");
assert_eq!(render_item("done", true), "- [x] done");
// An empty item has no trailing space, so a round trip does not grow it.
assert_eq!(render_item("", false), "- [ ]");
let line = render_item("milk", true);
assert_eq!(extract_items(&line), vec![item("milk", true, 0)]);
}
#[test]
fn items_carry_the_line_they_sit_on() {
let found = extract_items("a\n- [ ] x\nb\n- [x] y");
assert_eq!(found.iter().map(|i| i.line).collect::<Vec<_>>(), vec![1, 3]);
}
#[test]
fn a_stale_index_does_nothing() {
// The index comes from a UI that may be a moment behind the store. A tap
// that arrives late should be inert, not fatal.
let body = "- [ ] only";
assert_eq!(set_item_checked(body, 7, true), body);
assert_eq!(remove_item(body, 7), body);
assert_eq!(set_item_text(body, 7, "x"), body);
}
#[test]
fn a_plain_body_is_returned_byte_identical() {
let body = "just prose\nwith two lines";
assert_eq!(set_item_checked(body, 0, true), body);
assert_eq!(set_item_text(body, 0, "x"), body);
assert_eq!(remove_item(body, 0), body);
}
#[test]
fn round_trip_is_stable() {
let body = "- [ ] a\n- [x] b\n- [ ] c";
let items = extract_items(body);
// Ticking and unticking returns the original bytes.
let touched = set_item_checked(&set_item_checked(body, 0, true), 0, false);
assert_eq!(touched, body);
assert_eq!(extract_items(&touched), items);
}
}
-9
View File
@@ -13,7 +13,6 @@ pub struct Note {
/// never stored.
pub display_title: String,
pub body: String,
pub color: String,
pub position: i64,
pub pinned: bool,
pub archived: bool,
@@ -121,16 +120,10 @@ pub struct User {
pub is_admin: bool,
}
fn default_color() -> String {
"default".to_string()
}
#[derive(Deserialize)]
pub struct NoteCreateInput {
#[serde(default)]
pub body: String,
#[serde(default = "default_color")]
pub color: String,
#[serde(default)]
pub items: Option<Vec<String>>,
}
@@ -154,8 +147,6 @@ pub struct Facets {
#[serde(default)]
pub q: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub label: Option<Vec<String>>,
#[serde(default)]
pub has_reminder: Option<bool>,
+285 -2
View File
@@ -6,14 +6,16 @@
//!
//! Migrations are gated on `PRAGMA user_version`; bump it and add a block per change.
use rusqlite::Connection;
use rusqlite::{params, Connection, OptionalExtension};
use crate::local::derive;
const SCHEMA_V1: &str = r#"
CREATE TABLE notes (
id TEXT PRIMARY KEY,
title TEXT,
body TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'default',
color TEXT NOT NULL DEFAULT 'default', -- dropped in v9; kept so DROP COLUMN has something to drop
kind TEXT NOT NULL DEFAULT 'text', -- dropped in v6; kept so DROP COLUMN has something to drop
position INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
@@ -54,6 +56,8 @@ CREATE TABLE checklist_items (
position INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_items_note ON checklist_items (note_id);
-- Both dropped in v8; kept here so an existing database has something to migrate
-- FROM, exactly as `kind` above is kept for v6.
CREATE TABLE attachments (
id TEXT PRIMARY KEY,
@@ -180,7 +184,96 @@ ALTER TABLE notes DROP COLUMN title;
ALTER TABLE note_revisions DROP COLUMN title;
"#;
// v8 (M304): `checklist_items` is gone. The body IS the checklist — a `- [ ] milk`
// line is the item — so a list can sit between two paragraphs instead of only after
// them, which a side table could never express no matter how it was styled.
//
// Rust rather than a SQL const, for two reasons. The fold has to produce EXACTLY what
// `derive::append_item` produces, and expressing that in SQL would be a second
// implementation of the layout rule. And `group_concat` only gained a guaranteed
// ORDER BY in SQLite 3.44 — a checklist that silently reordered itself during the
// migration would be a poor way to find that out.
//
// `updated_at` and `dirty` are deliberately NOT touched. The server's Alembic
// migration folds the same rows with the same spacing, so both sides land on
// identical bodies and this needs no sync at all; marking every note dirty would
// push a body the server already has, and would do it for every device at once.
fn migrate_v8(conn: &Connection) -> rusqlite::Result<()> {
// Grouped in one pass — the query is ordered by note, so a change of note_id is
// the group boundary. `rowid` breaks ties, because `position` was only ever
// advisory and two rows sharing one is not a reason to reorder someone's list.
let mut grouped: Vec<(String, Vec<(String, bool)>)> = Vec::new();
{
let mut stmt = conn.prepare(
"SELECT note_id, text, checked FROM checklist_items
ORDER BY note_id ASC, position ASC, rowid ASC",
)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let note_id: String = row.get(0)?;
let text: String = row.get(1)?;
let checked: bool = row.get(2)?;
match grouped.last_mut() {
Some((id, items)) if *id == note_id => items.push((text, checked)),
_ => grouped.push((note_id, vec![(text, checked)])),
}
}
}
for (note_id, items) in grouped {
let existing: Option<String> = conn
.query_row("SELECT body FROM notes WHERE id = ?1", [&note_id], |r| {
r.get(0)
})
.optional()?;
// An item whose note is already gone has nothing to fold into. The foreign key
// should make this impossible; skipping costs nothing, and failing here would
// leave the only copy of someone's notes half-migrated.
let mut body = match existing {
Some(b) => b,
None => continue,
};
for (text, checked) in items {
body = derive::append_item(&body, &text, checked);
}
conn.execute(
"UPDATE notes SET body = ?1 WHERE id = ?2",
params![body, note_id],
)?;
}
conn.execute_batch(
"DROP INDEX IF EXISTS idx_items_note;
DROP TABLE checklist_items;",
)?;
Ok(())
}
/// Bring the database up to the latest schema. Idempotent.
// v9 (M315): `notes.color` is gone. A card is one neutral surface now and colour lives
// only on a tag, so the column was written by a picker nothing read and read by nothing
// at all. `labels.color` is untouched — that is the colour that survived.
//
// The saved-filter sweep is the second half and not optional. `params` is opaque JSON
// and a stored view could carry `"color": "teal"`; with the facet gone that key would
// sit there forever, and a view that silently filters on a field the app no longer has
// is worse than one that visibly lost a criterion. Guarded on `json_valid` because a
// corrupt blob must keep whatever it holds, not become NULL.
//
// The second guard is a LIKE and not `json_extract(...) IS NOT NULL`, which is the
// obvious way to write it and is a trap: SQLite does not promise to short-circuit AND,
// so `json_extract` can be evaluated against the very rows `json_valid` was there to
// exclude — and on malformed input it does not return NULL, it RAISES, which would
// abort the migration for every other row too. `LIKE` is total over any text.
const SCHEMA_V9: &str = r#"
ALTER TABLE notes DROP COLUMN color;
UPDATE saved_filters
SET params = json_remove(params, '$.color')
WHERE json_valid(params)
AND params LIKE '%"color"%';
"#;
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
@@ -212,5 +305,195 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(SCHEMA_V7)?;
conn.execute_batch("PRAGMA user_version = 7;")?;
}
if version < 8 {
migrate_v8(conn)?;
conn.execute_batch("PRAGMA user_version = 8;")?;
}
if version < 9 {
conn.execute_batch(SCHEMA_V9)?;
conn.execute_batch("PRAGMA user_version = 9;")?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// A database as it stood before M304 — items still in their own table.
fn v7_db() -> Connection {
let conn = Connection::open_in_memory().expect("open");
conn.execute_batch("PRAGMA foreign_keys = ON;").expect("fk");
for batch in [
SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4, SCHEMA_V5, SCHEMA_V6, SCHEMA_V7,
] {
conn.execute_batch(batch).expect("batch");
}
conn.execute_batch("PRAGMA user_version = 7;").expect("v7");
conn
}
fn add_note(conn: &Connection, id: &str, body: &str) {
conn.execute(
"INSERT INTO notes (id, body, created_at, updated_at)
VALUES (?1, ?2, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')",
params![id, body],
)
.expect("note");
}
fn add_item(conn: &Connection, note: &str, text: &str, checked: bool, pos: i64) {
conn.execute(
"INSERT INTO checklist_items (id, note_id, text, checked, position)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![format!("{note}-{pos}"), note, text, checked, pos],
)
.expect("item");
}
fn body_of(conn: &Connection, id: &str) -> String {
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))
.expect("body")
}
#[test]
fn v8_folds_items_into_the_body() {
let conn = v7_db();
add_note(&conn, "n1", "shopping");
add_item(&conn, "n1", "milk", false, 0);
add_item(&conn, "n1", "eggs", true, 1);
migrate(&conn).expect("migrate");
// Prose, blank line, list — the layout _note_markdown already exports, so an
// export taken before this migration and one taken after agree byte for byte.
assert_eq!(body_of(&conn, "n1"), "shopping\n\n- [ ] milk\n- [x] eggs");
}
#[test]
fn v8_keeps_a_list_only_note_whole() {
let conn = v7_db();
add_note(&conn, "n1", "");
add_item(&conn, "n1", "milk", false, 0);
migrate(&conn).expect("migrate");
assert_eq!(body_of(&conn, "n1"), "- [ ] milk");
}
#[test]
fn v8_leaves_timestamps_alone() {
// The whole reason this needs no sync: the server folds the same rows the same
// way, so both sides already agree. Marking notes dirty would push a body the
// server has, from every device at once.
let conn = v7_db();
add_note(&conn, "n1", "note");
add_item(&conn, "n1", "milk", false, 0);
migrate(&conn).expect("migrate");
let (updated, dirty): (String, i64) = conn
.query_row(
"SELECT updated_at, dirty FROM notes WHERE id = 'n1'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.expect("row");
assert_eq!(updated, "2026-01-01T00:00:00.000Z");
assert_eq!(dirty, 1); // as inserted, not raised by the migration
}
#[test]
fn v8_drops_the_table_and_is_idempotent() {
let conn = v7_db();
add_note(&conn, "n1", "note");
migrate(&conn).expect("migrate");
migrate(&conn).expect("again");
let exists: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='checklist_items'",
[],
|r| r.get(0),
)
.expect("count");
assert_eq!(exists, 0);
}
#[test]
fn a_fresh_database_reaches_the_latest_version() {
let conn = Connection::open_in_memory().expect("open");
migrate(&conn).expect("migrate");
let version: i64 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.expect("version");
assert_eq!(version, 9);
}
/// The column is gone, not merely unread. Asserted by asking SQLite rather than by
/// reading a row: a SELECT that omits `color` would pass either way.
#[test]
fn v9_drops_the_note_colour_column() {
let conn = Connection::open_in_memory().expect("open");
migrate(&conn).expect("migrate");
let mut stmt = conn.prepare("PRAGMA table_info(notes)").expect("pragma");
let columns: Vec<String> = stmt
.query_map([], |r| r.get::<_, String>(1))
.expect("query")
.collect::<rusqlite::Result<Vec<String>>>()
.expect("collect");
assert!(!columns.iter().any(|c| c == "color"));
// The one that survived. Getting this wrong would take every tag's colour with
// it, which is the whole thing M315 was keeping.
let mut stmt = conn.prepare("PRAGMA table_info(labels)").expect("pragma");
let label_columns: Vec<String> = stmt
.query_map([], |r| r.get::<_, String>(1))
.expect("query")
.collect::<rusqlite::Result<Vec<String>>>()
.expect("collect");
assert!(label_columns.iter().any(|c| c == "color"));
}
/// A stored view that filtered on colour loses that criterion and keeps the rest.
/// The alternative — leaving the key — is a lens that silently narrows on a field
/// the app no longer has and never says why it returned nothing.
#[test]
fn v9_sweeps_colour_out_of_saved_filters() {
let conn = Connection::open_in_memory().expect("open");
conn.execute_batch("PRAGMA foreign_keys = ON;").expect("fk");
for batch in [
SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4, SCHEMA_V5, SCHEMA_V6, SCHEMA_V7,
] {
conn.execute_batch(batch).expect("schema");
}
conn.execute_batch("PRAGMA user_version = 8;").expect("v8");
for (id, params) in [
("a", r#"{"color":"teal","q":"milk"}"#),
("b", r#"{"q":"eggs"}"#),
// Not JSON at all. It must come out UNCHANGED rather than NULL — a blob
// this migration cannot read is not a blob it gets to destroy.
("c", "not json"),
] {
conn.execute(
"INSERT INTO saved_filters (id, name, params, created_at)
VALUES (?1, ?1, ?2, '2026-08-28T00:00:00.000Z')",
params![id, params],
)
.expect("seed");
}
migrate(&conn).expect("migrate");
let read = |id: &str| -> String {
conn.query_row(
"SELECT params FROM saved_filters WHERE id = ?1",
[id],
|r| r.get(0),
)
.expect("read")
};
assert_eq!(read("a"), r#"{"q":"milk"}"#);
assert_eq!(read("b"), r#"{"q":"eggs"}"#);
assert_eq!(read("c"), "not json");
}
}
+256 -102
View File
@@ -9,7 +9,7 @@
use chrono::{DateTime, Duration, SecondsFormat, Utc};
use rusqlite::{params, params_from_iter, Connection, OptionalExtension};
use serde_json::Value;
use serde_json::{json, Value};
use uuid::Uuid;
use crate::local::derive;
@@ -24,24 +24,25 @@ fn new_id() -> String {
Uuid::new_v4().to_string()
}
/// The note's NAME: its first non-blank body line, else its first checklist item.
/// The note's NAME: the first line of its body that says anything.
///
/// Mirrors `derive_display_title` in the server's notes/helpers.py — one rule written
/// twice, and they have to agree or a synced note is called different things on either
/// side of the wire.
///
/// Pure, and given the items rather than fetching them: every caller has already
/// loaded them, so a query here would be a second trip for something already in hand.
fn display_title(body: &str, items: &[ChecklistItem]) -> String {
if let Some(line) = body.lines().map(str::trim).find(|l| !l.is_empty()) {
return line.to_string();
/// It no longer needs the items, because the items ARE lines of the body now (M304).
/// What it needs instead is to strip the task marker off: a list-only note is still
/// named by its first item, and calling that note "- [ ] milk" would be showing
/// someone the storage rather than the note. An empty item is skipped rather than
/// naming the note "", which is what a half-typed list would otherwise do.
fn display_title(body: &str) -> String {
for line in body.lines() {
let text = derive::strip_marker(line.trim()).trim();
if !text.is_empty() {
return text.to_string();
}
}
items
.iter()
.map(|i| i.text.trim())
.find(|t| !t.is_empty())
.unwrap_or("")
.to_string()
String::new()
}
fn escape_like(s: &str) -> String {
@@ -69,19 +70,24 @@ fn load_labels(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<NoteLab
rows.collect()
}
fn load_items(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<ChecklistItem>> {
let mut stmt = conn.prepare(
"SELECT id, text, checked, position FROM checklist_items WHERE note_id = ?1 ORDER BY position ASC",
)?;
let rows = stmt.query_map([note_id], |r| {
Ok(ChecklistItem {
id: r.get(0)?,
text: r.get(1)?,
checked: r.get(2)?,
position: r.get(3)?,
/// The note's checklist, read out of its body. No query, because there is no table.
///
/// A `- [ ] milk` line IS the item (M304). The id is the item's ORDINAL rather than a
/// uuid — which is all it ever amounted to anyway, since `push.rs` sent text and
/// checked and never an id, and both sides replaced the whole list on every sync. It
/// is also exactly what the rewriters in `derive` take, so a UI holding an id can act
/// on it directly.
fn items_of(body: &str) -> Vec<ChecklistItem> {
derive::extract_items(body)
.into_iter()
.enumerate()
.map(|(i, item)| ChecklistItem {
id: i.to_string(),
text: item.text,
checked: item.checked,
position: i as i64,
})
})?;
rows.collect()
.collect()
}
fn load_attachments(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<Attachment>> {
@@ -135,7 +141,7 @@ fn load_previews(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<LinkP
fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
let mut note = conn.query_row(
"SELECT id, body, color, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
"SELECT id, body, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
FROM notes WHERE id = ?1",
[id],
|r| {
@@ -144,14 +150,13 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
id: r.get(0)?,
display_title: String::new(), // filled below — it may need a query
body,
color: r.get(2)?,
position: r.get(3)?,
pinned: r.get(4)?,
archived: r.get(5)?,
trashed: r.get(6)?,
deleted_at: r.get(11)?,
remind_at: r.get(7)?,
recurrence: r.get(8)?,
position: r.get(2)?,
pinned: r.get(3)?,
archived: r.get(4)?,
trashed: r.get(5)?,
deleted_at: r.get(10)?,
remind_at: r.get(6)?,
recurrence: r.get(7)?,
labels: Vec::new(),
items: Vec::new(),
attachments: Vec::new(),
@@ -162,11 +167,10 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
},
)?;
note.labels = load_labels(conn, id)?;
note.items = load_items(conn, id)?;
note.items = items_of(&note.body);
note.attachments = load_attachments(conn, id)?;
note.previews = load_previews(conn, id)?;
// After the items, because a body-only-empty note is named by its first one.
note.display_title = display_title(&note.body, &note.items);
note.display_title = display_title(&note.body);
Ok(note)
}
@@ -200,34 +204,89 @@ fn find_or_create_label(conn: &Connection, name: &str) -> rusqlite::Result<Strin
Ok(id)
}
/// Re-sync the note's `via_tag` labels to exactly the `#tags` in its body.
fn sync_tags(conn: &Connection, note_id: &str, body: &str) -> rusqlite::Result<()> {
let tags = derive::extract_tags(body);
let mut desired: Vec<String> = Vec::with_capacity(tags.len());
for t in &tags {
desired.push(find_or_create_label(conn, t)?);
/// Attach the note's tag labels, LIFT its standalone tags out of the body, and write
/// the shortened body back.
///
/// NAMED FOR THE MUTATION. It used to be `sync_tags` and only touched label rows; it
/// now rewrites `notes.body`, and every caller writes the body just before calling —
/// so this overwrites what they wrote, on purpose.
///
/// `display_title` needs no attention here, unlike on the server: the core derives it
/// on READ (see `display_title` above, called from `load_note`) rather than storing
/// it, so there is no persisted copy to go stale.
///
/// The two kinds of tag are handled differently, and that difference IS what `via_tag`
/// means from here on — backed by text still in the body:
///
/// standalone lifted out, attached as an ORDINARY label. Nothing derives it any
/// more, and the way to remove it becomes the chip's ×.
/// inline left in place, attached via_tag = 1, still detached when its text
/// goes. Unchanged from before.
///
/// Mirrors `_lift_and_reconcile_tags` in the server's `notes/tags.py`.
fn lift_and_sync_tags(conn: &Connection, note_id: &str, body: &str) -> rusqlite::Result<()> {
let (standalone, inline, lifted) = derive::lift_standalone_tags(body);
let mut standalone_ids: Vec<String> = Vec::with_capacity(standalone.len());
for name in &standalone {
standalone_ids.push(find_or_create_label(conn, name)?);
}
let mut inline_ids: Vec<String> = Vec::with_capacity(inline.len());
for name in &inline {
inline_ids.push(find_or_create_label(conn, name)?);
}
let current: Vec<String> = {
let current: Vec<(String, bool)> = {
let mut stmt =
conn.prepare("SELECT label_id FROM note_labels WHERE note_id = ?1 AND via_tag = 1")?;
let rows = stmt.query_map([note_id], |r| r.get::<_, String>(0))?;
rows.collect::<rusqlite::Result<Vec<String>>>()?
conn.prepare("SELECT label_id, via_tag FROM note_labels WHERE note_id = ?1")?;
let rows = stmt.query_map([note_id], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, bool>(1)?))
})?;
rows.collect::<rusqlite::Result<Vec<(String, bool)>>>()?
};
for lid in &current {
if !desired.contains(lid) {
for (lid, via_tag) in &current {
if !*via_tag {
continue; // manual already: a #tag of the same name changes nothing
}
if standalone_ids.contains(lid) {
// It GRADUATED. The text backing it is about to go, so the row has to
// become the record instead — and BEFORE the delete below, or the same row
// is dropped for no longer being in the body. That is the bug a naive lift
// has, and it silently loses the tag.
conn.execute(
"UPDATE note_labels SET via_tag = 0 WHERE note_id = ?1 AND label_id = ?2",
params![note_id, lid],
)?;
} else if !inline_ids.contains(lid) {
conn.execute(
"DELETE FROM note_labels WHERE note_id = ?1 AND label_id = ?2 AND via_tag = 1",
params![note_id, lid],
)?;
}
}
for lid in &desired {
// OR IGNORE leaves a label already attached in ANY form alone, which is what keeps
// a manually-added label of the same name manual.
for lid in &standalone_ids {
conn.execute(
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 0)",
params![note_id, lid],
)?;
}
for lid in &inline_ids {
conn.execute(
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 1)",
params![note_id, lid],
)?;
}
if lifted != body {
conn.execute(
"UPDATE notes SET body = ?1 WHERE id = ?2",
params![lifted, note_id],
)?;
}
Ok(())
}
@@ -267,10 +326,6 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note
binds.push(pat.clone());
binds.push(pat);
}
if let Some(c) = f.color.as_deref().filter(|s| !s.is_empty()) {
sql.push_str(" AND color = ?");
binds.push(c.to_string());
}
if f.has_reminder == Some(true) {
sql.push_str(" AND remind_at IS NOT NULL");
}
@@ -358,23 +413,62 @@ pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Resu
[],
|r| r.get(0),
)?;
conn.execute(
"INSERT INTO notes (id, body, color, position, created_at, updated_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?5, ?5, 1)",
params![id, input.body, input.color, position, ts],
)?;
// Items fold into the body rather than into rows of their own. Callers still hand
// them over separately — the importer has a list, not a blob — but where they end
// up is one place.
let mut body = input.body.clone();
if let Some(items) = &input.items {
for (i, text) in items.iter().enumerate() {
conn.execute(
"INSERT INTO checklist_items (id, note_id, text, position) VALUES (?1, ?2, ?3, ?4)",
params![new_id(), id, text, i as i64],
)?;
for text in items {
body = derive::append_item(&body, text, false);
}
}
sync_tags(conn, &id, &input.body)?;
conn.execute(
"INSERT INTO notes (id, body, position, created_at, updated_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?4, 1)",
params![id, body, position, ts],
)?;
// The FOLDED body, not the input one: an item can carry a #tag too.
lift_and_sync_tags(conn, &id, &body)?;
load_note(conn, &id)
}
/// How long one editing session is assumed to last.
///
/// Inside this window a note's body may be written any number of times and only the
/// FIRST write snapshots. That is what makes an idle-debounced autosave affordable:
/// a write costs a write, not a write plus a revision.
const REVISION_WINDOW_MINUTES: i64 = 10;
/// Whether a body change earns a snapshot of the pre-edit body.
///
/// Two conditions. The body must actually differ — re-saving identical text is not a
/// version of anything. And the note must not already carry a revision from this
/// editing session.
///
/// The session rule is what keeps version history worth reading. Because
/// [`snapshot_revision`] stores the body as it was BEFORE the edit, the first write
/// of a session captures the note as you found it, and every write after it inside
/// the window adds nothing. One revision per sitting falls out of the window on its
/// own — no "commit" the client has to declare, and no protocol surface to carry it,
/// which matters because sync-apply takes this same path.
fn should_snapshot(conn: &Connection, id: &str, new_body: &str) -> rusqlite::Result<bool> {
let current: String =
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?;
if current == new_body {
return Ok(false);
}
// String comparison, not date maths: timestamps are RFC3339 UTC with a fixed
// millisecond field (see the module header), so lexical order IS chronological.
let cutoff = (Utc::now() - Duration::minutes(REVISION_WINDOW_MINUTES))
.to_rfc3339_opts(SecondsFormat::Millis, true);
let recent: i64 = conn.query_row(
"SELECT COUNT(*) FROM note_revisions WHERE note_id = ?1 AND created_at >= ?2",
params![id, cutoff],
|r| r.get(0),
)?;
Ok(recent == 0)
}
fn snapshot_revision(conn: &Connection, id: &str) -> rusqlite::Result<()> {
let body: String =
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?;
@@ -391,9 +485,12 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
.as_object()
.ok_or_else(|| rusqlite::Error::InvalidParameterName("changes must be an object".into()))?;
// Snapshot the pre-edit body before changing it (version history).
if obj.contains_key("body") {
snapshot_revision(conn, id)?;
// Snapshot the pre-edit body before changing it (version history) — but only
// once per editing session, and only if it actually changed. See should_snapshot.
if let Some(body) = obj.get("body").and_then(|v| v.as_str()) {
if should_snapshot(conn, id, body)? {
snapshot_revision(conn, id)?;
}
}
for (k, v) in obj {
@@ -404,12 +501,7 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
"UPDATE notes SET body = ?1 WHERE id = ?2",
params![body, id],
)?;
sync_tags(conn, id, body)?;
}
"color" => {
if let Some(s) = v.as_str() {
conn.execute("UPDATE notes SET color = ?1 WHERE id = ?2", params![s, id])?;
}
lift_and_sync_tags(conn, id, body)?;
}
"pinned" => {
if let Some(b) = v.as_bool() {
@@ -508,18 +600,32 @@ pub fn set_labels(conn: &Connection, id: &str, label_ids: &[String]) -> rusqlite
load_note(conn, id)
}
// ---- checklist items: every one of these is a body edit ---------------------
//
// They keep their own names and signatures because the FFI, the Tauri commands and
// the REST shape all speak in items, and a checklist is still a thing a note HAS.
// What changed is where it is kept. Routing all three through `update_note` rather
// than writing the body directly is what gives them revision snapshotting, `#tag`
// re-derivation and the dirty/updated_at bookkeeping without any of it being
// written a second time here.
fn note_body(conn: &Connection, id: &str) -> rusqlite::Result<String> {
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))
}
/// An item's id is its ordinal (see [items_of]). Anything else is a stale id from a
/// UI that has not reloaded, and the right answer to those is to do nothing.
fn item_index(item_id: &str) -> Option<usize> {
item_id.parse::<usize>().ok()
}
fn set_body(conn: &Connection, id: &str, body: String) -> rusqlite::Result<Note> {
update_note(conn, id, &json!({ "body": body }))
}
pub fn add_item(conn: &Connection, id: &str, text: &str) -> rusqlite::Result<Note> {
let pos: i64 = conn.query_row(
"SELECT COALESCE(MAX(position), -1) + 1 FROM checklist_items WHERE note_id = ?1",
[id],
|r| r.get(0),
)?;
conn.execute(
"INSERT INTO checklist_items (id, note_id, text, position) VALUES (?1, ?2, ?3, ?4)",
params![new_id(), id, text, pos],
)?;
touch(conn, id)?;
load_note(conn, id)
let body = note_body(conn, id)?;
set_body(conn, id, derive::append_item(&body, text, false))
}
pub fn update_item(
@@ -528,29 +634,27 @@ pub fn update_item(
item_id: &str,
changes: &Value,
) -> rusqlite::Result<Note> {
let index = match item_index(item_id) {
Some(i) => i,
None => return load_note(conn, id),
};
let mut body = note_body(conn, id)?;
if let Some(text) = changes.get("text").and_then(Value::as_str) {
conn.execute(
"UPDATE checklist_items SET text = ?1 WHERE id = ?2 AND note_id = ?3",
params![text, item_id, id],
)?;
body = derive::set_item_text(&body, index, text);
}
if let Some(checked) = changes.get("checked").and_then(Value::as_bool) {
conn.execute(
"UPDATE checklist_items SET checked = ?1 WHERE id = ?2 AND note_id = ?3",
params![checked, item_id, id],
)?;
body = derive::set_item_checked(&body, index, checked);
}
touch(conn, id)?;
load_note(conn, id)
set_body(conn, id, body)
}
pub fn delete_item(conn: &Connection, id: &str, item_id: &str) -> rusqlite::Result<Note> {
conn.execute(
"DELETE FROM checklist_items WHERE id = ?1 AND note_id = ?2",
params![item_id, id],
)?;
touch(conn, id)?;
load_note(conn, id)
let index = match item_index(item_id) {
Some(i) => i,
None => return load_note(conn, id),
};
let body = note_body(conn, id)?;
set_body(conn, id, derive::remove_item(&body, index))
}
pub fn delete_attachment(conn: &Connection, id: &str, att_id: &str) -> rusqlite::Result<Note> {
@@ -668,7 +772,7 @@ pub fn restore_revision(conn: &Connection, id: &str, rev_id: &str) -> rusqlite::
"UPDATE notes SET body = ?1 WHERE id = ?2",
params![body, id],
)?;
sync_tags(conn, id, &body)?;
lift_and_sync_tags(conn, id, &body)?;
touch(conn, id)?;
load_note(conn, id)
}
@@ -716,7 +820,57 @@ pub fn create_label(conn: &Connection, name: &str) -> rusqlite::Result<Label> {
load_label(conn, &id)
}
/// Rename a label. Renaming ONTO a name another label already holds MERGES the two.
///
/// It cannot simply be an UPDATE: `idx_labels_name` is unique on `lower(name)`, so
/// the bare statement failed with a raw SQLite "UNIQUE constraint failed" that
/// reached the user as database internals. Merging is the operator's call, and it
/// is the reading that matches what a person means — typing an existing tag's name
/// onto this one says "these are the same thing."
///
/// THE OLDER ROW SURVIVES, and takes the new spelling. Older rather than "the one
/// that already held the name" because age is the property neither participant's
/// role can change: rename A→B and rename B→A must land on the same survivor, or
/// the result depends on which way round someone happened to type it. Ties (two
/// labels minted in the same millisecond) go to the incumbent, so the outcome is
/// still deterministic.
///
/// Matching is case-insensitive, agreeing with `find_or_create_label` — "Groceries"
/// finds "groceries", and the survivor ends up spelled the way the caller asked.
pub fn rename_label(conn: &Connection, id: &str, name: &str) -> rusqlite::Result<Label> {
let clash: Option<(String, String)> = conn
.query_row(
"SELECT id, created_at FROM labels WHERE lower(name) = lower(?1) AND id <> ?2",
params![name, id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
if let Some((other_id, other_created)) = clash {
let mine_created: String =
conn.query_row("SELECT created_at FROM labels WHERE id = ?1", [id], |r| {
r.get(0)
})?;
// `created_at` is RFC3339 to the millisecond with a `Z`, so it is fixed-width
// and lexicographic order IS chronological order — no parsing needed.
let (survivor, doomed) = if other_created <= mine_created {
(other_id, id.to_string())
} else {
(id.to_string(), other_id)
};
// Reuse the merge rather than re-implement it: it is the only place that
// knows to mark every affected NOTE dirty before the delete cascades the
// membership rows away, which is what makes the merge reach the server.
merge_labels(conn, &doomed, &survivor)?;
// The survivor may still carry the old spelling — it is the one that keeps
// existing, so it is the one that has to end up named what was asked for.
conn.execute(
"UPDATE labels SET name = ?1, updated_at = ?2, dirty = 1 WHERE id = ?3",
params![name, now(), survivor],
)?;
return load_label(conn, &survivor);
}
conn.execute(
"UPDATE labels SET name = ?1, updated_at = ?2, dirty = 1 WHERE id = ?3",
params![name, now(), id],
+66 -4
View File
@@ -17,13 +17,28 @@
//! `docs/sync.md` for the policy that governs when those numbers move.
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
/// The sync wire protocol this client speaks.
pub const CLIENT_PROTOCOL_VERSION: u32 = 2;
///
/// v4 (M315): `color` left the note. NOT a floor raise on either side — see the note
/// on [`MIN_SERVER_PROTOCOL_VERSION`].
pub const CLIENT_PROTOCOL_VERSION: u32 = 4;
/// The oldest server protocol this client can drive — the symmetric half of the
/// server's `min_client_protocol_version`.
pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 2;
///
/// STAYS AT 3 ACROSS v4, and the v2 precedent is the reason to say why rather than
/// leave it looking like an oversight. v2 dropped `kind` and `title` and DID move both
/// floors, on the rule that "dropping a field a client sends and expects back is
/// breaking". `color` fails that test on the second half: a v3 client reading a v4
/// server gets `"default"` from serde's default and draws the colour it derives
/// locally, which is a board that looks exactly like the one it drew yesterday. A v3
/// client PUSHING `color` to a v4 server has the key ignored — the server reads its
/// payload key by key and never validates the shape. Neither direction errors, and
/// neither loses anything a person can see; `title` was the note's NAME, and this is a
/// field that no longer renders anywhere.
pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 3;
/// Capabilities without which syncing is meaningless, so their absence BLOCKS the
/// link rather than degrading it.
@@ -158,10 +173,41 @@ pub fn evaluate(info: &ServerInfo) -> Compatibility {
}
}
/// Who this client says it is, set once by the host application at startup.
///
/// THE CORE CANNOT KNOW THIS, and the value it used to invent was wrong twice. It
/// was `thoughtsync-desktop/{CARGO_PKG_VERSION}`, and this crate is compiled into
/// the desktop app AND the Android app — so every phone in the field announced
/// itself as a desktop. The version was worse: `CARGO_PKG_VERSION` here is the
/// version of the CORE crate, a number no build stamps and no user has ever seen,
/// while the thing a reader of that header wants is the app's own build (note 3127
/// §5 — with no version tags, the artifact's self-report is the only answer to
/// "which build is this?").
///
/// So the host names itself. `OnceLock` because identity is fixed for the life of
/// the process and a second caller should be ignored rather than race the first.
static CLIENT_AGENT: OnceLock<String> = OnceLock::new();
/// Name this client for the servers it talks to — `("thoughtsync-android", "2026.08.31.1204")`.
///
/// Call once at startup, before any sync. Calling twice is not an error and the
/// first name wins; not calling it at all is visible in the header rather than
/// silently plausible.
pub fn set_client_agent(name: &str, version: &str) {
let _ = CLIENT_AGENT.set(format!("{name}/{version}"));
}
/// Headers this client puts on every request to a linked server, so the server can
/// log or gate on client identity without a separate handshake round-trip.
pub fn client_headers() -> [(&'static str, String); 2] {
let agent = format!("thoughtsync-desktop/{}", env!("CARGO_PKG_VERSION"));
// `unidentified/unknown`, never a plausible default. Nothing reads this header
// today, which is exactly why a wrong value could sit in it for months: the
// first person to look at a server log is the first person who could catch it,
// and only if what they see is obviously a host that never introduced itself.
let agent = CLIENT_AGENT
.get()
.cloned()
.unwrap_or_else(|| "thoughtsync-unidentified/unknown".to_string());
[
("X-ThoughtSync-Client", agent),
(
@@ -360,10 +406,26 @@ mod tests {
#[test]
fn client_headers_identify_app_and_protocol() {
// Sets the process-wide agent, which is why this test also owns the
// assertion about it: a second test calling `set_client_agent` would race
// this one for the OnceLock, and whichever lost would see the other's name.
// One test, both branches, in order.
assert!(
client_headers()[0]
.1
.starts_with("thoughtsync-unidentified/"),
"a host that never introduced itself must say so"
);
set_client_agent("thoughtsync-test", "2026.08.31.1204");
let headers = client_headers();
assert_eq!(headers[0].0, "X-ThoughtSync-Client");
assert!(headers[0].1.starts_with("thoughtsync-desktop/"));
assert_eq!(headers[0].1, "thoughtsync-test/2026.08.31.1204");
assert_eq!(headers[1].1, CLIENT_PROTOCOL_VERSION.to_string());
// First name wins — a second host cannot rename a running process.
set_client_agent("thoughtsync-impostor", "0");
assert_eq!(client_headers()[0].1, "thoughtsync-test/2026.08.31.1204");
}
#[test]
+23 -76
View File
@@ -240,13 +240,12 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
// `created_at` is deliberately absent from the UPDATE clause: a note's birth time
// never changes, and the server's copy is the same value anyway.
conn.execute(
"INSERT INTO notes (id, body, color, position, pinned, archived,
"INSERT INTO notes (id, body, position, pinned, archived,
trashed, remind_at, recurrence, created_at, updated_at,
sync_revision, trashed_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 0)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 0)
ON CONFLICT(id) DO UPDATE SET
body = excluded.body,
color = excluded.color,
position = excluded.position,
pinned = excluded.pinned,
archived = excluded.archived,
@@ -260,7 +259,6 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
params![
note.id,
note.body,
note.color,
note.position,
note.pinned,
note.archived,
@@ -277,34 +275,12 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
// Children are replaced wholesale: a delta carries the note's FULL current state,
// so "what the server sent" IS the complete set. Diffing would be more code and
// could leave behind a row the server no longer has.
replace_items(conn, note)?;
replace_attachments(conn, note)?;
replace_previews(conn, note)?;
replace_labels(conn, note)?;
Ok(())
}
fn replace_items(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
conn.execute(
"DELETE FROM checklist_items WHERE note_id = ?1",
params![note.id],
)?;
for (index, item) in note.items.iter().enumerate() {
conn.execute(
"INSERT INTO checklist_items (id, note_id, text, checked, position)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
item.id,
note.id,
item.text,
item.checked,
position_of(item.position, index)
],
)?;
}
Ok(())
}
fn replace_attachments(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
conn.execute(
"DELETE FROM attachments WHERE note_id = ?1",
@@ -393,16 +369,6 @@ fn ensure_label_stub(conn: &Connection, label: &wire::NoteLabel) -> rusqlite::Re
Ok(())
}
/// Trust an explicit position; fall back to arrival order when the server sent 0 for
/// everything (which is what an unordered list looks like on the wire).
fn position_of(explicit: i64, index: usize) -> i64 {
if explicit > 0 {
explicit
} else {
index as i64
}
}
/// Loop the feed to exhaustion, starting from the persisted cursor.
///
/// NOTE ON ORDERING: the full cycle is push-then-pull (docs/sync.md). Running this
@@ -495,7 +461,6 @@ mod tests {
wire::Note {
id: id.to_string(),
body: "Body".into(),
color: "default".into(),
position: 0,
pinned: false,
archived: false,
@@ -508,12 +473,22 @@ mod tests {
sync_revision: revision,
purged_at: None,
labels: vec![],
items: vec![],
attachments: vec![],
previews: vec![],
}
}
fn attachment(id: &str) -> wire::Attachment {
wire::Attachment {
id: id.to_string(),
url: "/blob/x".into(),
filename: None,
mime: "image/png".into(),
size: None,
sha256: None,
}
}
fn page(notes: Vec<wire::Note>, labels: Vec<wire::Label>, cursor: i64) -> wire::ChangesPage {
wire::ChangesPage {
notes,
@@ -612,35 +587,20 @@ mod tests {
#[test]
fn children_are_replaced_not_merged() {
// Was written over checklist items; they are lines of the body now (M304), so
// attachments carry the point instead. It is the same property either way: a
// delta is the note's FULL current state, so a child the server dropped has to
// disappear locally rather than linger.
let conn = db();
let mut first = note("n1", 1);
first.items = vec![
wire::Item {
id: "i1".into(),
text: "one".into(),
checked: false,
position: 0,
},
wire::Item {
id: "i2".into(),
text: "two".into(),
checked: false,
position: 1,
},
];
first.attachments = vec![attachment("a1"), attachment("a2")];
apply_page(&conn, &page(vec![first], vec![], 1)).expect("apply");
assert_eq!(count(&conn, "SELECT COUNT(*) FROM checklist_items"), 2);
assert_eq!(count(&conn, "SELECT COUNT(*) FROM attachments"), 2);
// The server dropped an item; the local copy must drop it too.
let mut second = note("n1", 2);
second.items = vec![wire::Item {
id: "i1".into(),
text: "one".into(),
checked: true,
position: 0,
}];
second.attachments = vec![attachment("a1")];
apply_page(&conn, &page(vec![second], vec![], 2)).expect("apply");
assert_eq!(count(&conn, "SELECT COUNT(*) FROM checklist_items"), 1);
assert_eq!(count(&conn, "SELECT COUNT(*) FROM attachments"), 1);
}
#[test]
@@ -810,23 +770,10 @@ mod tests {
fn a_page_that_fails_leaves_the_cursor_untouched() {
// Atomicity is the whole resumability story: a cursor committed ahead of its
// data would skip those rows forever. Force a failure with a duplicate
// checklist-item id inside one page.
// attachment id inside one page.
let conn = db();
let mut n = note("n1", 3);
n.items = vec![
wire::Item {
id: "dup".into(),
text: "one".into(),
checked: false,
position: 0,
},
wire::Item {
id: "dup".into(),
text: "two".into(),
checked: false,
position: 1,
},
];
n.attachments = vec![attachment("dup"), attachment("dup")];
assert!(apply_page(&conn, &page(vec![n], vec![], 3)).is_err());
assert_eq!(state::read(&conn).expect("state").last_cursor, 0);
assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 0);
+15 -38
View File
@@ -64,6 +64,8 @@ pub struct Change {
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
/// A LABEL's colour. A note has none since M315, so a note change leaves this
/// `None` and the key never reaches the wire.
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -79,8 +81,6 @@ pub struct Change {
#[serde(skip_serializing_if = "Option::is_none")]
pub position: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub items: Option<Vec<ItemOut>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
@@ -103,7 +103,6 @@ impl Change {
remind_at: None,
recurrence: None,
position: None,
items: None,
label_ids: None,
created_at: None,
name: None,
@@ -111,12 +110,6 @@ impl Change {
}
}
#[derive(Debug, Serialize)]
pub struct ItemOut {
pub text: String,
pub checked: bool,
}
// --- incoming results --------------------------------------------------------
#[derive(Debug, Deserialize)]
@@ -201,7 +194,6 @@ fn collect_labels(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rus
remind_at: None,
recurrence: None,
position: None,
items: None,
label_ids: None,
created_at: None,
})
@@ -230,7 +222,6 @@ fn collect_notes(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rusq
/// field-to-column mapping stays readable at the call site.
struct NoteRow {
body: String,
color: String,
position: i64,
pinned: bool,
archived: bool,
@@ -243,22 +234,21 @@ struct NoteRow {
fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
conn.query_row(
"SELECT body, color, position, pinned, archived, trashed,
"SELECT body, position, pinned, archived, trashed,
remind_at, recurrence, created_at, updated_at
FROM notes WHERE id = ?1",
params![id],
|r| {
Ok(NoteRow {
body: r.get(0)?,
color: r.get(1)?,
position: r.get(2)?,
pinned: r.get::<_, i64>(3)? != 0,
archived: r.get::<_, i64>(4)? != 0,
trashed: r.get::<_, i64>(5)? != 0,
remind_at: r.get(6)?,
recurrence: r.get(7)?,
created_at: r.get(8)?,
updated_at: r.get(9)?,
position: r.get(1)?,
pinned: r.get::<_, i64>(2)? != 0,
archived: r.get::<_, i64>(3)? != 0,
trashed: r.get::<_, i64>(4)? != 0,
remind_at: r.get(5)?,
recurrence: r.get(6)?,
created_at: r.get(7)?,
updated_at: r.get(8)?,
})
},
)
@@ -267,19 +257,6 @@ fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> {
let row = note_row(conn, id)?;
let items = {
let mut stmt = conn.prepare(
"SELECT text, checked FROM checklist_items WHERE note_id = ?1 ORDER BY position",
)?;
let rows = stmt.query_map(params![id], |r| {
Ok(ItemOut {
text: r.get(0)?,
checked: r.get::<_, i64>(1)? != 0,
})
})?;
rows.collect::<rusqlite::Result<Vec<ItemOut>>>()?
};
// MANUAL memberships only. Tag-sourced ones (`via_tag = 1`) are re-derived by the
// server from the body; sending them as label_ids would convert them into manual
// assignments that no longer disappear when the #tag is removed from the text.
@@ -298,14 +275,14 @@ fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> {
// server's last-write-wins comparison runs against.
edited_at: row.updated_at,
body: Some(row.body),
color: Some(row.color),
// A note has no colour to send. See the field on `Change`.
color: None,
pinned: Some(row.pinned),
archived: Some(row.archived),
trashed: Some(row.trashed),
remind_at: row.remind_at,
recurrence: row.recurrence,
position: Some(row.position),
items: Some(items),
label_ids: Some(label_ids),
created_at: Some(row.created_at),
name: None,
@@ -521,9 +498,9 @@ mod tests {
fn seed_note(conn: &Connection, id: &str, dirty: i64) {
conn.execute(
"INSERT INTO notes (id, body, color, position, pinned, archived,
"INSERT INTO notes (id, body, position, pinned, archived,
trashed, created_at, updated_at, sync_revision, dirty)
VALUES (?1, 'B', 'default', 0, 0, 0, 0,
VALUES (?1, 'B', 0, 0, 0, 0,
'2026-07-26T00:00:00.000Z', '2026-07-26T00:00:00.000Z', 3, ?2)",
params![id, dirty],
)
-15
View File
@@ -25,8 +25,6 @@ pub struct Note {
pub id: String,
#[serde(default)]
pub body: String,
#[serde(default = "default_color")]
pub color: String,
#[serde(default)]
pub position: i64,
#[serde(default)]
@@ -58,8 +56,6 @@ pub struct Note {
#[serde(default)]
pub labels: Vec<NoteLabel>,
#[serde(default)]
pub items: Vec<Item>,
#[serde(default)]
pub attachments: Vec<Attachment>,
#[serde(default)]
pub previews: Vec<Preview>,
@@ -87,17 +83,6 @@ pub struct NoteLabel {
pub via_tag: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Item {
pub id: String,
#[serde(default)]
pub text: String,
#[serde(default)]
pub checked: bool,
#[serde(default)]
pub position: i64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Attachment {
pub id: String,
+1 -1
View File
@@ -18,7 +18,7 @@ pacman system:
curl -fsSL https://git.fabledsword.com/bvandeusen/thoughtsync/raw/branch/dev/desktop/packaging/install.sh | sh
```
That installs the newest tagged release. To follow the rolling development
That installs the newest build from `main`. To follow the rolling development
channel instead, pass the flag through the pipe:
```sh
+3 -1
View File
@@ -64,7 +64,9 @@ DEPENDS=(webkit2gtk-4.1 gtk3)
# this?" question unanswerable.
# `|| true` so a miss falls through to the explicit error below rather than
# aborting on pipefail with no explanation.
PKGVER="$(sh "$SCRIPT_DIR/../build-version.sh" || true)"
# The ORDERING KEY: pacman compares this, and it must match the filename the
# bundle build produced (write-manifest.sh selects on it).
PKGVER="$(sh "$SCRIPT_DIR/../../../packaging/version.sh" key desktop || true)"
[ -n "$PKGVER" ] || { echo "ERROR: could not determine the build version" >&2; exit 1; }
# Reproducible-ish: prefer the commit date over "now" so rebuilding the same
-32
View File
@@ -1,32 +0,0 @@
#!/usr/bin/env sh
#
# Echo the version this build should carry. One definition, used in three places
# (both bundle jobs and the manifest writer) — if they ever disagreed, the app would
# compare its own version against a manifest describing a different build, and the
# updater would either offer nothing or loop forever offering the same thing.
#
# WHY DEV BUILDS NEED THEIR OWN VERSION AT ALL:
# an updater decides by comparing semver. Every dev build carries the version in
# Cargo.toml, so without this they'd all be `0.1.0` — an installed build would see a
# manifest advertising the version it already has, conclude it was current, and never
# update. The rolling channel needs a number that actually rises.
#
# The CI run number is that number: monotonic, already unique per build, and it needs
# no state carried between runs. `0.1.0` + run 2932 becomes `0.1.2932`.
#
# Plain semver on purpose, NOT a `-dev.N` prerelease tag: prerelease versions sort
# BELOW the release they qualify (`0.1.0-dev.5` < `0.1.0`), so a tagged build would
# never update to a newer dev one, and Windows installer metadata wants a numeric
# X.Y.Z anyway. Bumping the minor in Cargo.toml still wins over any dev build on the
# old line, which is the ordering you want: 0.2.0 > 0.1.2932.
set -eu
CARGO_TOML="$(dirname "$0")/../src-tauri/Cargo.toml"
base="$(grep -m1 '^version' "$CARGO_TOML" | sed -E 's/.*"([^"]+)".*/\1/')"
# Dev builds only. Anything else (a v* tag, main) ships the version as written.
if [ "${GITHUB_REF_NAME:-}" = "dev" ] && [ -n "${GITHUB_RUN_NUMBER:-}" ]; then
printf '%s.%s\n' "${base%.*}" "$GITHUB_RUN_NUMBER"
else
printf '%s\n' "$base"
fi
+15 -31
View File
@@ -5,8 +5,15 @@
# curl -fsSL https://git.fabledsword.com/bvandeusen/thoughtsync/raw/branch/dev/desktop/packaging/install.sh | sh
#
# Two channels, the SAME two the app's own updater offers (src-tauri/src/update.rs):
# stable (default) — the newest tagged v* release.
# stable (default) — the rolling build from every merge to `main`.
# dev — the rolling build from every green push to `dev`.
# Both are fixed-tag releases: the tag never moves and the assets are pruned to the
# current build, so the tag alone names the newest one. `stable` only became one in
# M314 step 3, when `main` started publishing — before that it was a manifest-only
# pointer at whatever `v*` tag somebody had last cut, and this script carried a
# fallback that chased the `v*` release its manifest named. That came out once
# `main` had published to `stable` for real (`b6673c6`); the two channels are the
# same shape now and nothing here should special-case one of them again.
# Pick one with `--channel dev` or `TS_CHANNEL=dev`. Through a pipe the options go
# after a `--`: curl -fsSL <url> | sh -s -- --channel dev
#
@@ -41,7 +48,7 @@ ThoughtSync desktop installer.
install.sh [--channel stable|dev]
--channel stable newest tagged release (default)
--channel stable newest build from main (default)
--channel dev rolling build from the latest green push to `dev`
-h, --help this text
@@ -82,35 +89,12 @@ esac
# --- resolve the release for this channel -----------------------------------
say "Finding the latest ThoughtSync build on the $channel channel…"
if [ "$channel" = "dev" ]; then
# A release whose tag never moves and whose assets are pruned to the current
# build — so the tag alone always names the newest dev build.
json="$(curl -fsSL "$API/releases/tags/dev" 2>/dev/null)" ||
die "the dev channel has nothing published yet."
else
# Ask the stable channel's own manifest which version is current, then install
# THAT release. This is the same file the in-app updater reads, so the installer
# and the updater can never disagree about what `stable` means.
#
# Not `/releases/latest`: that returns the newest non-prerelease release by date,
# and the `stable` pointer release (manifest only, no bundles — see
# write-manifest.sh) is itself a non-prerelease created moments after the
# versioned one. It would win, and it carries nothing installable.
manifest="$(curl -fsSL "$INSTANCE/$REPO/releases/download/stable/latest.json" 2>/dev/null || true)"
stable_version="$(printf '%s' "$manifest" |
grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 |
sed -E 's/.*"([^"]+)"$/\1/')"
if [ -n "$stable_version" ]; then
json="$(curl -fsSL "$API/releases/tags/v$stable_version" 2>/dev/null)" ||
die "the stable channel names $stable_version, but there is no v$stable_version release to install."
else
# No stable pointer yet — the channel predates the updater. Fall back to the
# newest non-prerelease release, which is what stable meant before there was
# a manifest to ask.
json="$(curl -fsSL "$API/releases/latest" 2>/dev/null)" ||
die "no stable release published yet — try --channel dev, or ask the maintainer to tag one."
fi
fi
# ONE lookup, both channels. Each is a release whose tag never moves and whose assets
# are pruned to the current build, so the tag alone names the newest build on that
# channel — which is exactly what an installer wants and what the in-app updater
# already reads.
json="$(curl -fsSL "$API/releases/tags/$channel" 2>/dev/null)" ||
die "the $channel channel has nothing published yet."
# Pull asset URLs straight out of the release JSON (no jq). Anchored on the closing
# quote so a `…AppImage.sig` URL can't be truncated into a match of its own.
+24
View File
@@ -118,6 +118,11 @@ first_id() { grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE
# install.sh defaults to stable, so the rolling dev release must opt in explicitly
# — otherwise someone following the instructions here lands on a tagged build and
# wonders why the version they were sent isn't what they got.
#
# Both CHANNELS are rolling pointer releases (M314 step 3): `dev` republishes on
# every green push to dev, `stable` on every merge to main. Each says so, because a
# release that prunes its own assets behaves differently from a versioned one and a
# reader deserves to know which they are looking at.
if [ "$TAG" = "dev" ]; then
INSTALL_TAIL='sh -s -- --channel dev'
# Backticks BARE, not `\``. The heredoc below is unquoted, so there the backslash
@@ -125,6 +130,10 @@ if [ "$TAG" = "dev" ]; then
# Here single quotes already do that job, so a backslash would survive into the
# body as `\``, which is not a legal JSON escape: Forgejo answers 422.
CHANNEL_NOTE='\n\nThis is the rolling **dev** channel: republished on every green push to `dev`, and pruned to the current build.'
elif [ "$TAG" = "stable" ]; then
# install.sh defaults to stable, so no flag.
INSTALL_TAIL='sh'
CHANNEL_NOTE='\n\nThis is the rolling **stable** channel: republished on every merge to `main`, and pruned to the current build. No tag is required for a build to arrive here.'
else
INSTALL_TAIL='sh'
CHANNEL_NOTE=''
@@ -136,6 +145,21 @@ BODY=$(cat <<JSON
"body":"ThoughtSync $TAG.\n\n**Desktop**\n\n- **Debian / Ubuntu** — native \`.deb\`\n- **Arch / CachyOS** — native \`.pkg.tar.*\`\n- **everything else** — \`.AppImage\` (de-bundled graphics: renders on any GPU/Wayland setup)\n\nInstall / update — picks the right one for your system:\n\`\`\`\ncurl -fsSL $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/branch/dev/desktop/packaging/install.sh | $INSTALL_TAIL\n\`\`\`\n\n**Android** — \`thoughtsync.apk\`. Copy it and \`thoughtsync-android.json\` into your server's \`/var/thoughtsync/client/\` and the server will offer it to your devices; see docs/android-distribution.md.$CHANNEL_NOTE"}
JSON
)
# An explicit body, replacing the install instructions above.
#
# Used by the RELEASE lane, whose job is a changelog rather than artifacts (M314
# step 7). It goes through this script rather than making its own API calls so that
# the create-or-PATCH-on-409 path is shared: a fixed-tag release that only ever
# POSTs keeps whatever text its FIRST build wrote, which is #2182 exactly, and
# re-implementing that correctly in a second place is how it comes back.
#
# CONTRACT: already JSON-escaped, without surrounding quotes. The caller knows
# whether it has a JSON encoder; this script cannot assume python3 is on PATH in
# every image that sources it.
if [ -n "${RELEASE_BODY_JSON:-}" ]; then
BODY="{\"tag_name\":\"$TAG\",\"name\":\"ThoughtSync $TAG\",\"draft\":false,\"prerelease\":$RELEASE_PRERELEASE,\"body\":\"$RELEASE_BODY_JSON\"}"
fi
# 409 = a release for this tag already exists (re-run) — fall through to lookup.
release="$(ALLOW_CODES=409 api POST "$API/releases" -H "Content-Type: application/json" -d "$BODY")"
RELEASE_ID="$(printf '%s' "$release" | first_id || true)"
+51 -39
View File
@@ -24,15 +24,21 @@ set -euo pipefail
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required (owner/repo)}"
: "${RELEASE_TAG:?RELEASE_TAG is required (the release holding the bundles)}"
: "${APP_VERSION:?APP_VERSION is required (the version the bundles carry)}"
# The version a PERSON reads, published beside the manifest so the image build can
# describe the bundles it bakes in without re-deriving anything. Required rather
# than defaulted: a missing value here would silently publish a sidecar naming the
# wrong build, and there is nothing downstream that could catch it.
: "${DISPLAY_VERSION:?DISPLAY_VERSION is required (the human-readable version)}"
# Where the manifest is PUBLISHED, which need not be where the bundles live.
# The manifest is published to the release that HOLDS the bundles. There is no
# second place any more.
#
# That split is what makes the stable channel work at all. A versioned release
# (`v0.2.0`) holds the real assets, but the app can only read a URL that never
# changes — so the same manifest is also attached to a `stable` release whose tag is
# permanent and whose only content is this file. It points back at the versioned
# assets, so nothing is duplicated.
MANIFEST_TAG="${MANIFEST_TAG:-$RELEASE_TAG}"
# There used to be: `MANIFEST_TAG` let the manifest live on a `stable` pointer
# release while the bundles sat on a versioned `v*` one, because the app can only
# read a URL that never changes and a versioned tag is not that. M314 step 3 made
# `stable` a rolling release that holds its own bundles, exactly like `dev`, so the
# split had nothing left to bridge — and a parameter that can only ever be passed
# its own default is a branch nobody exercises and a comment that goes stale.
API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
AUTH=(-H "Authorization: token $GITHUB_TOKEN")
@@ -115,41 +121,47 @@ pub_date="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
echo "==> Manifest:"
cat "$work/latest.json"
# --- resolve the release the manifest is published TO ------------------------
if [ "$MANIFEST_TAG" = "$RELEASE_TAG" ]; then
target_id="$release_id"
target_assets="$assets"
else
echo "==> Resolving the $MANIFEST_TAG channel release"
target="$(curl -sS "${AUTH[@]}" "$API/releases/tags/$MANIFEST_TAG")"
target_id="$(printf '%s' "$target" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+' || true)"
if [ -z "${target_id:-}" ]; then
# First publish to this channel. A pointer release: no bundles of its own, just
# a permanent tag for the manifest to live under.
echo " creating it (pointer release, manifest only)"
body="{\"tag_name\":\"$MANIFEST_TAG\",\"name\":\"ThoughtSync ($MANIFEST_TAG channel)\",\"draft\":false,\"prerelease\":false,\"body\":\"Update channel pointer. The installable builds live on the versioned releases; this holds only the updater manifest.\"}"
target="$(curl -sS -X POST "${AUTH[@]}" -H "Content-Type: application/json" -d "$body" "$API/releases")"
target_id="$(printf '%s' "$target" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')"
fi
[ -n "${target_id:-}" ] || { echo "ERROR: could not resolve the $MANIFEST_TAG release" >&2; exit 1; }
target_assets="$(curl -sS "${AUTH[@]}" "$API/releases/$target_id/assets")"
fi
# Both files go on the same release the bundles were just read from — which is also
# the one `publish-release.sh` created or refreshed moments earlier, so it is
# guaranteed to exist by the time this runs.
# Replace rather than duplicate: Forgejo rejects a second asset with the same name,
# and this file is rewritten on every publish by design.
old_id="$(printf '%s' "$target_assets" \
| grep -oE "\"id\"[[:space:]]*:[[:space:]]*[0-9]+[^}]*\"name\"[[:space:]]*:[[:space:]]*\"latest\.json\"" \
| head -1 | grep -oE '[0-9]+' | head -1 || true)"
if [ -n "${old_id:-}" ]; then
echo "==> Removing the previous latest.json (id $old_id)"
curl -fsS -X DELETE "${AUTH[@]}" "$API/releases/$target_id/assets/$old_id" >/dev/null
fi
# and these files are rewritten on every publish by design.
replace_asset() {
local path="$1" name="$2" escaped old_id
escaped="${name//./\\.}"
old_id="$(printf '%s' "$assets" \
| grep -oE "\"id\"[[:space:]]*:[[:space:]]*[0-9]+[^}]*\"name\"[[:space:]]*:[[:space:]]*\"$escaped\"" \
| head -1 | grep -oE '[0-9]+' | head -1 || true)"
if [ -n "${old_id:-}" ]; then
echo "==> Removing the previous $name (id $old_id)"
curl -fsS -X DELETE "${AUTH[@]}" "$API/releases/$release_id/assets/$old_id" >/dev/null
fi
echo "==> Uploading $name to $RELEASE_TAG"
curl -fsS -X POST "${AUTH[@]}" "$API/releases/$release_id/assets?name=$name" \
-F "attachment=@$path" >/dev/null
}
echo "==> Uploading latest.json to $MANIFEST_TAG"
curl -fsS -X POST "${AUTH[@]}" "$API/releases/$target_id/assets?name=latest.json" \
-F "attachment=@$work/latest.json" >/dev/null
replace_asset "$work/latest.json" "latest.json"
echo "==> Done. $MANIFEST_TAG now advertises $APP_VERSION for ${#entries[@]} platform(s)."
# The version pair, for whoever needs to describe these bundles without rebuilding
# them — today the image build, which bakes the desktop clients in and writes each
# one a sidecar (`packaging/fetch-clients.sh`).
#
# It is published HERE, beside the manifest, because this is the step that speaks
# for what the channel serves: both files are written in the same breath from the
# same two values, so they cannot disagree about which build is current. A consumer
# deriving the version from its own checkout instead would describe these bytes
# with whatever commit it happened to be on.
#
# No `size` or `sha256` — those are per-artifact and there are four. Whoever
# downloads a bundle measures the bytes it actually got, which is the only way to
# tell a truncated download from a whole one.
printf '{\n "version_name": "%s",\n "version_code": "%s"\n}\n' \
"$DISPLAY_VERSION" "$APP_VERSION" > "$work/thoughtsync-desktop.json"
replace_asset "$work/thoughtsync-desktop.json" "thoughtsync-desktop.json"
echo "==> Done. $RELEASE_TAG now advertises $DISPLAY_VERSION ($APP_VERSION) for ${#entries[@]} platform(s)."
# --- prune superseded builds from a rolling channel ---------------------------
#
@@ -182,7 +194,7 @@ if [ "${PRUNE_OLD_ASSETS:-false}" = "true" ]; then
# every desktop push regardless. That is exactly what happened on run
# 4098, which swept the APK run 4092 had just published.
case "$asset_name" in
latest.json|thoughtsync.apk|thoughtsync-android.json) continue ;;
latest.json|thoughtsync-desktop.json|thoughtsync.apk|thoughtsync-android.json) continue ;;
*"$APP_VERSION"*) continue ;;
esac
echo " removing $asset_name"
+17
View File
@@ -1,5 +1,17 @@
[package]
name = "thoughtsync-desktop"
# NOT THE SHIPPED VERSION, and bumping it has no effect on anything a user sees.
#
# Cargo requires a version here, and Tauri reads one from `tauri.conf.json` — both
# are overridden per build by `cargo tauri build --config '{"version": ...}'` with
# the value `packaging/version.sh key desktop` derives. See #3144.
#
# It used to matter: the old scheme took its base from this line and appended the CI
# run number on dev, so `0.2.<run>` on dev sat against a bare `0.2.0` on main and
# every dev build outranked every stable one. The remedy was "remember to bump the
# minor before tagging" — documented in a comment, enforced nowhere, and #2183 is
# what that looked like in the field. A scheme needing a human to remember something
# before each release has not removed the decision, only hidden it.
version = "0.2.0"
description = "ThoughtSync desktop — local-first Keep-style thought capture"
authors = ["bvandeusen"]
@@ -52,3 +64,8 @@ tauri-plugin-log = "2"
# the plugin declares android support level "none", which is why the Android client
# gets a server-served update path instead (Scribe note 2725).
tauri-plugin-updater = "2"
# The system-wide quick-capture hotkey. Desktop only by nature — Android has no
# concept of a global shortcut, and its half of this feature is a share-sheet
# intent filter instead.
tauri-plugin-global-shortcut = "2"
+10
View File
@@ -1,3 +1,13 @@
fn main() {
// Cargo does NOT track an `option_env!` variable on its own — the macro is
// expanded at compile time and nothing records that the crate depends on it.
// So without this line, a cached `target/` would keep a binary reporting
// whatever version the previous build baked, and the footer would confidently
// name the wrong build. The desktop lane has no cache today, which is exactly
// why this is easy to forget the day one is added.
//
// See DISPLAY_VERSION in `src/commands/local.rs`.
println!("cargo::rerun-if-env-changed=THOUGHTSYNC_DISPLAY_VERSION");
tauri_build::build()
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Core capability for the main ThoughtSync window.",
"windows": ["main"],
"description": "Core capability for the ThoughtSync windows: the board and the quick-capture window.",
"windows": ["main", "capture"],
"permissions": ["core:default"]
}
+226
View File
@@ -0,0 +1,226 @@
//! Quick capture: a system-wide hotkey that opens a small window to type into.
//!
//! The point is capture WITHOUT the app. Bringing the whole board forward to write
//! one line is the friction this removes, so the shortcut opens a small window of
//! its own rather than focusing `main` — and that window closes itself the moment
//! the note is saved.
//!
//! ## Why the shortcut is configurable, and why it starts unset
//!
//! A global shortcut is the one setting in this app that can collide with software
//! it knows nothing about. Whatever default is picked is a key combination taken
//! away from something on somebody's machine, silently, at install time. So there
//! is no default: the feature is off until someone chooses a combination, and
//! choosing one is how it turns on.
//!
//! The suggestion the settings screen offers (`CommandOrControl+Shift+N`) lives in
//! the frontend, not here. It is a UI affordance — a starting point put in front of
//! someone — and this side accepts any combination the OS will take, so a constant
//! here would be a second copy of a string only the UI ever reads.
//!
//! ## Failure has to be visible
//!
//! Registering can fail — the combination may already be held by the window
//! manager or another app, and on Wayland a compositor may refuse global grabs
//! outright. A hotkey that quietly does nothing is worse than one that was never
//! offered, because there is nothing to look at and nothing to fix. So the stored
//! shortcut and the LIVE registration are reported separately: see
//! [`CaptureShortcut`].
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
use thoughtsync_core::local::{store, Db};
const SHORTCUT_PREF: &str = "capture_shortcut";
/// The window the hotkey opens. Also the label the capability file grants to.
pub const CAPTURE_WINDOW: &str = "capture";
/// Emitted to the main window after a capture is saved, so the board reloads.
///
/// The two windows hold separate copies of the frontend and therefore separate
/// Pinia stores; nothing in the capture window's store can reach the board's. The
/// note is already in SQLite by the time this fires — this only says "look again".
pub const CAPTURED_EVENT: &str = "thoughtsync://captured";
/// The stored shortcut and whether it is actually live.
///
/// Two fields rather than one because they genuinely disagree: a combination can
/// be saved and refuse to register, and the person needs to be told which of those
/// they are looking at. `registered: false` with a non-empty `shortcut` is the
/// "something else already has this" case.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaptureShortcut {
/// The stored combination, or empty when quick capture is off.
pub shortcut: String,
/// Whether the OS accepted it. Always false when `shortcut` is empty.
pub registered: bool,
}
fn stored(db: &Db) -> Result<String, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
Ok(store::pref(&conn, SHORTCUT_PREF)
.map_err(|e| e.to_string())?
.unwrap_or_default())
}
/// Open (or focus) the capture window.
///
/// Reused rather than recreated: holding one window and showing it is what makes
/// the second press feel instant, and it means a half-typed capture survives the
/// window being dismissed and reopened.
///
/// `always_on_top` and `center` because this is summoned over whatever you were
/// doing — a capture window that opens behind the app you called it from has
/// failed at the only thing it does.
fn open_capture_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window(CAPTURE_WINDOW) {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
return;
}
// `index.html?capture=1` rather than a `/capture` path: the bundled assets are
// served as files, so a path with no file behind it is a 404 in the production
// build even though it routes fine under the dev server. A query string is
// carried through untouched and the router reads it on boot.
let built = WebviewWindowBuilder::new(
app,
CAPTURE_WINDOW,
WebviewUrl::App("index.html?capture=1".into()),
)
.title("Quick capture")
.inner_size(520.0, 220.0)
.min_inner_size(360.0, 160.0)
.resizable(true)
.always_on_top(true)
.center()
.skip_taskbar(true)
.build();
match built {
Ok(window) => {
let _ = window.set_focus();
}
// Never a panic and never fatal: failing to open a capture window must not
// take down an app whose board is working fine.
Err(e) => log::error!("could not open the capture window: {e}"),
}
}
/// Register `shortcut`, replacing whatever was live.
///
/// Unregisters everything first rather than tracking the previous binding: this
/// app owns exactly one global shortcut, so "all of ours" and "the old one" are
/// the same set, and keeping a copy of it is one more thing to get out of step.
fn register(app: &AppHandle, shortcut: &str) -> Result<(), String> {
let manager = app.global_shortcut();
let _ = manager.unregister_all();
if shortcut.is_empty() {
return Ok(());
}
let parsed: Shortcut = shortcut
.parse()
.map_err(|_| format!("'{shortcut}' is not a shortcut this system understands."))?;
manager
.on_shortcut(parsed, |app, _shortcut, event| {
// Pressed only. Without this the window is opened on the press AND on
// the release, and the second one lands on the window the first opened.
if event.state == ShortcutState::Pressed {
open_capture_window(app);
}
})
.map_err(|e| format!("Something else on this system is already using it ({e})."))
}
/// Restore the stored shortcut at startup.
///
/// Best-effort by construction: a combination that worked when it was chosen can
/// be taken by something installed later, and the app must still open. The failure
/// is logged and the UI will show it as not registered when the settings screen is
/// next opened.
pub fn restore(app: &AppHandle, db: &Db) {
let shortcut = match stored(db) {
Ok(s) if !s.is_empty() => s,
Ok(_) => return,
Err(e) => {
log::warn!("could not read the capture shortcut: {e}");
return;
}
};
match register(app, &shortcut) {
Ok(()) => log::info!("quick capture is on: {shortcut}"),
Err(e) => log::warn!("quick capture shortcut '{shortcut}' did not register: {e}"),
}
}
#[tauri::command]
pub fn capture_shortcut_get(app: AppHandle, db: State<'_, Db>) -> Result<CaptureShortcut, String> {
let shortcut = stored(&db)?;
// Asked of the manager rather than remembered from startup: the answer can
// have changed since, and a settings screen that reports a stale success is
// the exact thing this pair of fields exists to prevent.
let registered = !shortcut.is_empty()
&& shortcut
.parse::<Shortcut>()
.map(|s| app.global_shortcut().is_registered(s))
.unwrap_or(false);
Ok(CaptureShortcut {
shortcut,
registered,
})
}
/// Store a shortcut and make it live, or clear it with an empty string.
///
/// Registers BEFORE storing, so a combination the system refuses is not written
/// down as though it worked — the person would reopen the settings and find it
/// listed as their shortcut while nothing happened when they pressed it.
#[tauri::command]
pub fn capture_shortcut_set(
shortcut: String,
app: AppHandle,
db: State<'_, Db>,
) -> Result<CaptureShortcut, String> {
let wanted = shortcut.trim().to_string();
register(&app, &wanted)?;
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::set_pref(&conn, SHORTCUT_PREF, &wanted).map_err(|e| e.to_string())?;
log::info!(
"quick capture shortcut {}",
if wanted.is_empty() {
"cleared".to_string()
} else {
format!("set to {wanted}")
}
);
Ok(CaptureShortcut {
shortcut: wanted.clone(),
registered: !wanted.is_empty(),
})
}
/// Hide the capture window and tell the board to reload.
///
/// Hidden rather than closed so the next press has a window to show instead of one
/// to build. Called after a save and on Escape alike; `saved` is what decides
/// whether the board is told to look again.
#[tauri::command]
pub fn capture_done(saved: bool, app: AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window(CAPTURE_WINDOW) {
window.hide().map_err(|e| e.to_string())?;
}
if saved {
if let Some(main) = app.get_webview_window("main") {
// Failure here is cosmetic — the note is saved either way and the board
// will show it on its next load — so it is logged, not raised.
if let Err(e) = main.emit(CAPTURED_EVENT, ()) {
log::warn!("could not tell the board about a capture: {e}");
}
}
}
Ok(())
}
+3 -1
View File
@@ -31,7 +31,9 @@ pub fn config_get(db: State<'_, Db>) -> PublicConfig {
PublicConfig {
site_name: "ThoughtSync".to_string(),
allow_registration: false,
version: env!("CARGO_PKG_VERSION").to_string(),
// The build a person reads, baked at compile time — see crate::display_version
// for why this is neither CARGO_PKG_VERSION nor the updater's ordering key.
version: crate::display_version().to_string(),
enable_url_unfurl: false,
trash_retention_days: retention_days.max(0) as u32,
}
+50 -2
View File
@@ -9,10 +9,42 @@
//! remains here is the Tauri command surface (`commands`), desktop integration
//! (menu-entry install for the Linux AppImage), the in-app updater, and boot.
mod capture;
mod commands;
mod integration;
mod update;
/// The build a PERSON reads, baked in by the desktop lane at compile time.
///
/// Lives at the crate root because it has two readers — `config_get`, which puts it
/// in the UI, and `log_environment`, which puts it in the log — and this repo has
/// spent several issues on one fact held in two places (2181, 2182, 2183).
///
/// `option_env!`, not `env!`: a local `cargo tauri build` sets nothing, and this has
/// to keep compiling. `None` becomes "unknown" at each call site rather than a
/// plausible-looking default — note 3127 §5 makes this string the only answer to
/// "which build is this?" now that there are no version tags, so there is nothing
/// left to contradict it if it lies. An honest "I cannot say" is the only safe wrong
/// answer.
///
/// NOT `CARGO_PKG_VERSION`, which both readers used to use, and which was wrong on
/// every build ever shipped: `cargo tauri build --config '{"version": ...}'`
/// overrides `tauri.conf.json`, not Cargo's own metadata, so the literal `0.2.0` in
/// Cargo.toml is what reached the UI and the log regardless of what was built.
///
/// NOT the ordering key either. That value — `1.0.<minutes>`, which the override
/// above does set — is the opaque value Tauri's updater compares; it lands in bundle
/// filenames and `latest.json` and must never be shown to a person (#3144). Two
/// values, two audiences. `update.rs` deliberately still reads the key, through
/// `app.package_info().version`, because a comparator is exactly what it is.
const DISPLAY_VERSION: Option<&str> = option_env!("THOUGHTSYNC_DISPLAY_VERSION");
/// The baked build, or the honest "I cannot say". The only way in — the const is
/// private so no caller can reach past the fallback.
pub(crate) fn display_version() -> &'static str {
DISPLAY_VERSION.unwrap_or("unknown")
}
// The store and the sync engine live in the shared `thoughtsync-core` crate, which
// the Android client binds through uniffi (Scribe note 2730). Aliased to their old
// names so every call site below reads exactly as it did when they were modules of
@@ -22,6 +54,12 @@ use thoughtsync_core::{local, sync};
pub fn run() {
use tauri_plugin_log::{Target, TargetKind};
// Introduce ourselves to any server this app links to, BEFORE anything can sync.
// The core cannot work this out — it is compiled into the Android app too — so
// the header says "desktop" only because the desktop says so here, and carries
// the build a person can read rather than the core crate's own version.
sync::compat::set_client_agent("thoughtsync-desktop", display_version());
#[cfg(target_os = "linux")]
harden_linux_webkit_rendering();
@@ -43,6 +81,10 @@ pub fn run() {
// build without a signing key still starts normally and simply reports that
// updates aren't configured.
.plugin(tauri_plugin_updater::Builder::new().build())
// The quick-capture hotkey. Registering the combination itself happens in
// `setup`, once the store is open and can be asked which one to use — the
// plugin only has to exist before then.
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
// Attachment bytes are served to the webview from the local blob store
// (M10.7f). Registered on the BUILDER because a scheme has to exist before
// the webview is created; the directory it reads from arrives later, in
@@ -81,6 +123,9 @@ pub fn run() {
// in this directory saying which one the user picked (issue 2183).
update::adopt_installer_channel(&db, &dir);
sweep_local_trash(&db);
// Before the store is handed to the app: `restore` needs to read the
// stored shortcut out of it, and after `manage` the Db has moved.
capture::restore(app.handle(), &db);
app.manage(db);
// Attachment bytes live beside the database, filed by content hash, so a
// synced image is readable with no network (M10.7d).
@@ -139,6 +184,9 @@ pub fn run() {
update::update_channel_set,
update::update_check,
update::update_install,
capture::capture_shortcut_get,
capture::capture_shortcut_set,
capture::capture_done,
])
.run(tauri::generate_context!())
.expect("error while running the ThoughtSync desktop app");
@@ -219,8 +267,8 @@ fn log_event(level: String, message: String) {
fn log_environment(app: &tauri::App) {
use tauri::Manager;
log::info!(
"ThoughtSync desktop v{} starting ({} {})",
env!("CARGO_PKG_VERSION"),
"ThoughtSync desktop {} starting ({} {})",
display_version(),
std::env::consts::OS,
std::env::consts::ARCH,
);
+11 -5
View File
@@ -1,10 +1,16 @@
//! In-app updates (M10.9).
//!
//! Two channels, because two audiences: `stable` follows tagged `v*` releases,
//! `dev` follows every green push. Each reads a `latest.json` published as an asset
//! on a release whose TAG NEVER CHANGES — verified necessary, because Forgejo has no
//! `/releases/latest/download/<asset>` route (it 404s), so "newest" cannot be named
//! in a URL. A fixed tag can.
//! Two channels, because two audiences: `stable` follows every merge to `main`,
//! `dev` follows every green push to `dev`. Each reads a `latest.json` published as
//! an asset on a release whose TAG NEVER CHANGES — verified necessary, because
//! Forgejo has no `/releases/latest/download/<asset>` route (it 404s), so "newest"
//! cannot be named in a URL. A fixed tag can.
//!
//! `stable` followed tagged `v*` releases until M314 step 3, and its manifest pointed
//! at bundles living on a different release. It holds its own bundles now, exactly as
//! `dev` always has — so a build reaches stable users with no tag cut anywhere, which
//! is the whole point of the change. NOTHING HERE MOVED: this code only ever read
//! `<channel>/latest.json`, and that is still where the manifest lands.
//!
//! The feed lives on Fabled-Git rather than on a ThoughtSync server, deliberately:
//! this app is usable having never linked a server, and an install that can't reach
+16 -6
View File
@@ -15,13 +15,19 @@ cannot talk to.
**Normally: nowhere. It is already in the image.**
CI fetches the newest published Android build into every server image it builds,
so `:dev`, `:latest` and `:<version>` all ship a client. `docker compose pull &&
docker compose up -d` delivers a new server and a new client together, and there
is nothing to copy.
CI fetches the published Android build into every server image it builds, so
`:dev` and `:latest` both ship a client. `docker compose pull && docker compose
up -d` delivers a new server and a new client together, and there is nothing to
copy.
A versioned image therefore carries the *newest* client rather than one pinned to
that version. That is deliberate: the two negotiate a sync protocol version
**The channel is a property of the image you run.** A `:dev` image bakes in the
dev-channel APK, `:latest` the stable one — so pointing a phone at a stable
server gets it a stable client, with no second place holding that decision. (Until
M314 step 3 the fetch was hard-wired to the dev release on every branch, so a
stable server served a dev client.)
An image therefore carries the *newest* client on its channel rather than one
pinned to a version. That is deliberate: the two negotiate a sync protocol version
before they link, so a mismatch is caught by the handshake rather than by
pinning.
@@ -30,6 +36,10 @@ pinning.
If you want a specific build — testing something, or holding back — drop it in
`/var/thoughtsync/client/` and it wins over the image's copy.
That directory is shared with the desktop clients the server hands out, and
**precedence is decided per platform**: dropping in an APK overrides the baked APK
and leaves every other client alone. It is one directory, not one choice.
Two files, both required:
| File | What it is |
+20 -5
View File
@@ -52,6 +52,15 @@ syncs everything else.
### The policy
- **Any wire change** → bump `SYNC_PROTOCOL_VERSION`.
- v2 (M13): `kind` and `title` left the wire; **floor raised**, because a v1
client kept pushing both and read back notes carrying neither — and `title` was
the note's NAME, so an old client showed nameless notes.
- v3: attachments/tombstones/revisions.
- v4 (M315): `color` left the note; **floor NOT raised**. Both directions degrade
in silence and neither loses anything visible — an old client reading a v4 note
falls back to the colour it derives locally, and one pushing `color` has the key
ignored. The test is not "did a field leave" but "does either side end up
showing something wrong".
- **Additive change** (a new field, a new capability) → add a `sync_features`
name. Do **not** raise a minimum. Old clients keep working.
- **Breaking change only** → raise `MIN_CLIENT_PROTOCOL_VERSION` (or the client's
@@ -190,18 +199,24 @@ Body: `{ "changes": [ ... ] }` (max 1000 per batch). Each change:
```json
{ "entity": "note", "id": "<uuid>", "op": "upsert", "edited_at": "<iso8601>",
"title": "...", "body": "...", "color": "blue", "kind": "text",
"body": "...",
"pinned": false, "archived": false, "trashed": false, "remind_at": null,
"position": 0, "items": [ {"text": "...", "checked": false} ],
"recurrence": null, "position": 0,
"label_ids": ["<uuid>", ...], "created_at": "<iso8601, on create>" }
```
- **Client-generated ids.** Notes/labels are UUIDs; the client mints the id when
it creates the row offline and sends it here. Create-if-absent, else update.
- **Whole-note semantics.** A note upsert carries the client's *full* current
state (not a partial patch) — the server overwrites all scalar fields, replaces
items, and sets manual label memberships from `label_ids` (tag-sourced labels
are re-derived from the body). `#tags` are recomputed server-side.
state (not a partial patch) — the server overwrites all scalar fields and sets
manual label memberships from `label_ids` (tag-sourced labels are re-derived
from the body). `#tags` are recomputed server-side. A checklist is `- [ ] ` lines
inside `body` (M304), so there is no separate `items` array.
- **Fields a change may still carry, and the server reads past.** `title` and
`kind` (removed in v2), `items` (M304) and `color` (v4, M315). The server reads
its payload key by key and never validates the shape, which is exactly what lets
an older client keep pushing a field this one has stopped storing — see the
version policy above for why none of those needed a floor raise on their own.
- **`op: "delete"`** purges (tombstones) the row. Trashing is just an upsert with
`trashed: true`.
- **Labels:** `{entity: "label", op: "upsert"|"delete", id, edited_at, name,
+1 -3
View File
@@ -9,7 +9,6 @@
// consume. Client-side logic (list reconciliation, optimistic updates, toasts)
// stays in the stores — the repo is data access only.
import type { NoteColor } from "../notes/colors";
import type { Note, NoteFacets, NoteView, NoteRevision } from "../stores/notes";
import type { Label } from "../stores/labels";
import type { SavedFilter } from "../stores/savedFilters";
@@ -33,13 +32,12 @@ export interface NoteListQuery {
export interface NoteCreateInput {
body: string;
color: NoteColor;
items?: string[];
}
// The mutable subset of a note (PATCH /api/notes/:id).
export type NoteChanges = Partial<
Pick<Note, "body" | "color" | "pinned" | "archived" | "remind_at" | "recurrence">
Pick<Note, "body" | "pinned" | "archived" | "remind_at" | "recurrence">
>;
export interface ChecklistItemChanges {
-1
View File
@@ -31,7 +31,6 @@ function notesQuery(q: NoteListQuery): string {
if (q.labelId) params.append("label", q.labelId);
for (const id of q.facets?.label ?? []) if (id) params.append("label", id);
if (q.facets?.q) params.set("q", q.facets.q);
if (q.facets?.color) params.set("color", q.facets.color);
if (q.facets?.has_reminder) params.set("has_reminder", "true");
if (q.facets?.has_attachment) params.set("has_attachment", "true");
if (q.facets?.created_after) params.set("created_after", q.facets.created_after);
+39 -9
View File
@@ -14,7 +14,7 @@ import ImportNotes from "./ImportNotes.vue";
import LabelsModal from "./LabelsModal.vue";
import { isDesktop } from "../desktop/bridge";
import { facetsToQuery } from "../notes/facets";
import { NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
import { NOTE_SWATCH_CLASSES, resolveLabelColor } from "../notes/colors";
const route = useRoute();
const router = useRouter();
@@ -27,6 +27,23 @@ const ui = useUiStore();
// Sync is a desktop-app concern: the web build already IS the server's UI.
const desktopApp = isDesktop();
// The build, for the dim line at the foot of the rail (#3181).
//
// NEVER BLANK. "unknown" is the honest answer when the value is missing, and an
// empty space is a bug that reads as a design choice. Note 3127 §5: with version
// tags gone this is the only answer to "which build is this?", so it has to be
// either right or visibly absent.
//
// One slot, two artifacts, and that is deliberate rather than sloppy. In the
// browser `repo` is `rest`, so this is the SERVER's version; in the desktop shell
// `repo` is `local` and `config_get` returns the desktop build's own. Each surface
// names the thing the person is actually looking at. A linked server's version is
// a different question and Sync answers it separately.
const buildVersion = computed(() => config.version || "unknown");
const buildLabel = computed(
() => `ThoughtSync ${desktopApp ? "desktop" : "server"} build ${buildVersion.value}`,
);
async function removeView(f: SavedFilter) {
if (!window.confirm(`Delete the "${f.name}" view?`)) return;
try {
@@ -173,8 +190,10 @@ onBeforeUnmount(() => {
const currentLabelId = computed(() => (route.name === "label" ? String(route.params.id) : null));
function labelDot(color: string): string {
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default;
// The drawer's tag list. Same resolution as every other chip and dot — a tag that is
// green on a card must be green here, or the sidebar stops being a way to find it.
function labelDot(label: { name: string; color: string }): string {
return NOTE_SWATCH_CLASSES[resolveLabelColor(label)] ?? NOTE_SWATCH_CLASSES.default;
}
// The board lenses — the routes a search can happen *within*. Searching while looking
@@ -413,7 +432,7 @@ async function signOut() {
@click="drawer = false"
></div>
<aside
class="fixed inset-y-0 left-0 z-40 w-64 -translate-x-full overflow-y-auto border-r border-neutral-200 bg-neutral-50 p-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))] pt-[calc(0.75rem+env(safe-area-inset-top,0px))] transition-transform duration-200 sm:static sm:z-auto sm:w-56 sm:translate-x-0 sm:pb-3 sm:pt-3 dark:border-neutral-800 dark:bg-neutral-950"
class="fixed inset-y-0 left-0 z-40 flex w-64 -translate-x-full flex-col overflow-y-auto border-r border-neutral-200 bg-neutral-50 p-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))] pt-[calc(0.75rem+env(safe-area-inset-top,0px))] transition-transform duration-200 sm:static sm:z-auto sm:w-56 sm:translate-x-0 sm:pb-3 sm:pt-3 dark:border-neutral-800 dark:bg-neutral-950"
:class="drawer ? 'translate-x-0' : ''"
>
<nav class="flex flex-col gap-0.5 text-sm" @click="drawer = false">
@@ -422,18 +441,18 @@ async function signOut() {
</RouterLink>
<div class="mt-3 flex items-center justify-between px-3 pb-1">
<span class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Labels</span>
<span class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Tags</span>
<button
type="button"
class="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200"
title="Edit labels"
aria-label="Edit labels"
title="Manage tags"
aria-label="Manage tags"
@click="managing = true"
>
<Icon name="pencil" />
</button>
</div>
<p v-if="!labels.items.length" class="px-3 py-1 text-xs text-neutral-400">No labels yet</p>
<p v-if="!labels.items.length" class="px-3 py-1 text-xs text-neutral-400">No tags yet</p>
<RouterLink
v-for="lb in labels.items"
:key="lb.id"
@@ -443,7 +462,7 @@ async function signOut() {
>
<span
class="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10 dark:border-white/15"
:class="labelDot(lb.color)"
:class="labelDot(lb)"
></span>
<span class="truncate">{{ lb.name }}</span>
</RouterLink>
@@ -525,6 +544,17 @@ async function signOut() {
</button>
</div>
</nav>
<!-- The build. `mt-auto` puts it at the foot of the rail when the nav is
short and lets it simply follow when the nav has scrolled.
`select-all` because the one thing anybody does with this is copy it
into a bug report. -->
<p
class="mt-auto select-all px-3 pt-6 text-[11px] text-neutral-400 dark:text-neutral-500"
:title="buildLabel"
>
{{ buildVersion }}
</p>
</aside>
<!-- tabindex="-1" so the skip link above actually moves FOCUS here, not just
+7 -9
View File
@@ -11,18 +11,16 @@ withDefaults(
</script>
<template>
<!-- The look comes from `.btn` + a variant in style.css, NOT from here. A
download has to be an <a> (only an anchor can carry an href and hand the
transfer to the browser), so the shape has to live somewhere both elements
can wear it. The disabled: variants stay local an anchor has no
:disabled, so they are not shared and never were. -->
<button
:type="type"
:disabled="disabled || loading"
class="inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold transition
focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2
focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-offset-neutral-950
disabled:cursor-not-allowed disabled:opacity-60"
:class="
variant === 'primary'
? 'bg-brand text-neutral-900 shadow-sm hover:bg-brand-600 active:bg-brand-700'
: 'text-neutral-700 hover:bg-neutral-200/70 dark:text-neutral-200 dark:hover:bg-neutral-800'
"
class="btn disabled:cursor-not-allowed disabled:opacity-60"
:class="variant === 'primary' ? 'btn-primary' : 'btn-ghost'"
>
<span
v-if="loading"
+167
View File
@@ -0,0 +1,167 @@
<script setup lang="ts">
// Every client this server holds, with the one that fits the visitor on top.
//
// Five artifacts is where a downloads page turns into a table of filenames and
// stops being a product. So this LEADS with the download that fits the machine
// asking and keeps the rest quiet but visible — nothing is behind a disclosure,
// because a wrong guess must cost a person nothing.
import { computed } from "vue";
import { useConfigStore, type ClientRelease } from "../stores/config";
const config = useConfigStore();
type Family = "android" | "windows" | "linux" | "mac" | "ios" | "other";
/**
* Which OS is asking, from the user agent.
*
* ORDER IS THE WHOLE ALGORITHM. Android's UA contains "Linux", an iPad's contains
* "Mac OS X", and a Chromebook's contains "X11" — so each narrow test has to run
* before the broad one that would otherwise swallow it.
*
* `navigator.userAgent` rather than `userAgentData`: the reduced UA Chrome now
* sends still carries the platform token, which is the only thing being asked
* for, and one code path beats two for a guess that is allowed to be wrong.
*/
function detectFamily(ua: string): Family {
if (/Android/i.test(ua)) return "android";
if (/Windows/i.test(ua)) return "windows";
if (/iPhone|iPad|iPod/i.test(ua)) return "ios";
if (/Mac OS X|Macintosh/i.test(ua)) return "mac";
// CrOS lands here on purpose: a Chromebook's Linux container is a Debian one,
// which is the first thing the Linux group offers.
if (/Linux|X11|CrOS/i.test(ua)) return "linux";
return "other";
}
// What to lead with per family, in the order someone on it should see them.
//
// Linux gets all three because the UA says "Linux" and nothing about dpkg or
// pacman — there is no more specific answer to be had, so the three are named for
// the DISTRO a person knows rather than the package format they may not.
//
// macOS and iOS lead with nothing. There is no build for either, and an empty
// lead is the honest way to say so — see `missingPlatform` below.
const LEAD: Record<Family, string[]> = {
android: ["android"],
windows: ["windows"],
linux: ["linux-deb", "linux-pacman", "linux-appimage"],
mac: [],
ios: [],
other: [],
};
const FAMILY_TITLE: Record<Family, string> = {
android: "Android",
windows: "Windows",
linux: "Linux",
mac: "macOS",
ios: "iOS",
other: "This machine",
};
// Read once. The UA does not change while the page is open, and making it
// reactive would only invite someone to think it could.
const family = detectFamily(navigator.userAgent);
// The lead offers this server actually holds. A platform in LEAD that the server
// has no build for simply is not here — the guess never conjures a download.
const lead = computed(() =>
LEAD[family]
.map((id) => config.clients[id])
.filter((c): c is ClientRelease => Boolean(c)),
);
const others = computed(() => {
const leading = new Set(lead.value.map((c) => c.platform));
// Object.values keeps the server's own PLATFORMS order, which is a deliberate
// one (phone first, then the desktop bundles) and not worth re-deciding here.
return Object.values(config.clients).filter((c) => !leading.has(c.platform));
});
const groups = computed(() => {
const out: { title: string; releases: ClientRelease[]; prominent: boolean }[] = [];
if (lead.value.length) {
out.push({ title: FAMILY_TITLE[family], releases: lead.value, prominent: true });
}
if (others.value.length) {
out.push({
// Without a lead there is no "other" — the whole list is the choice.
title: lead.value.length ? "Other platforms" : "Choose a platform",
releases: others.value,
prominent: false,
});
}
return out;
});
// Said plainly, so a Mac reads as "not yet" rather than as a page that failed to
// find its own downloads.
const missingPlatform = computed(() =>
!lead.value.length && (family === "mac" || family === "ios") ? FAMILY_TITLE[family] : "",
);
// One decimal below 10 MB, none above: these sit in one list where a 2.7 MB
// package and a 95 MB AppImage are compared, and "3 MB" next to "95 MB" loses the
// only distinction that matters at the small end.
function readableSize(bytes: number): string {
const mb = bytes / 1024 / 1024;
return `${mb < 10 ? mb.toFixed(1) : mb.toFixed(0)} MB`;
}
</script>
<template>
<!-- Nothing at all on a server with no clients a brand-new instance before its
first image carrying them. An empty section would be a promise it can't keep. -->
<section v-if="groups.length" class="mb-6 rounded-xl border border-neutral-200 p-4 dark:border-neutral-800">
<h2 class="text-sm font-medium text-neutral-800 dark:text-neutral-100">Get the apps</h2>
<p class="mt-0.5 text-xs text-neutral-400">
Served by this server, so they always speak the same sync protocol.
</p>
<p v-if="missingPlatform" class="mt-1 text-xs text-neutral-400">
There's no {{ missingPlatform }} build yet.
</p>
<div v-for="group in groups" :key="group.title" class="mt-4">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-400">{{ group.title }}</p>
<ul class="mt-2 flex flex-col gap-2">
<li
v-for="client in group.releases"
:key="client.platform"
class="flex items-center justify-between gap-4"
>
<div class="min-w-0">
<p class="text-sm text-neutral-800 dark:text-neutral-100">{{ client.label }}</p>
<p class="mt-0.5 text-xs text-neutral-400">
<!-- `unknown` rather than a blank or a plausible default: with no
second source to contradict it, a wrong version here is a wrong
answer nothing can catch. Not knowing which build it is, is also
not a reason to withhold the download. -->
Version {{ client.version || "unknown" }} · {{ readableSize(client.size) }}<span
v-if="client.platform === 'linux-appimage'"
>, and the only one that updates itself in place</span
>
</p>
</div>
<!-- An anchor, never BaseButton and never a fetch: these are 395 MB and
the browser's own download manager handles the transfer better than
anything this app would do with a blob. It wears `.btn` — the same
definition BaseButton wears, so the two cannot drift.
`download` carries no filename because the server already names the
file in its Content-Disposition, which browsers prefer over this
attribute anyway — a value here would be inert and read as if it
weren't. -->
<a
:href="client.url"
download
class="btn shrink-0"
:class="group.prominent ? 'btn-primary' : 'btn-ghost'"
>
Download
</a>
</li>
</ul>
</div>
</section>
</template>
-24
View File
@@ -1,24 +0,0 @@
<script setup lang="ts">
import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
defineProps<{ modelValue: NoteColor }>();
defineEmits<{ (e: "update:modelValue", value: NoteColor): void }>();
</script>
<template>
<div class="flex flex-wrap items-center gap-1.5">
<button
v-for="key in NOTE_COLOR_KEYS"
:key="key"
type="button"
:title="NOTE_COLOR_LABELS[key]"
:aria-label="NOTE_COLOR_LABELS[key]"
:aria-pressed="modelValue === key"
class="h-6 w-6 rounded-full border border-black/10 transition hover:scale-110
focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-1
focus-visible:ring-offset-white dark:focus-visible:ring-offset-neutral-900"
:class="[NOTE_SWATCH_CLASSES[key], modelValue === key ? 'ring-2 ring-brand ring-offset-1' : '']"
@click="$emit('update:modelValue', key)"
/>
</div>
</template>
+1 -19
View File
@@ -7,7 +7,6 @@ import { useUiStore } from "../stores/ui";
import type { NoteFacets } from "../stores/notes";
import { facetCount, facetsFromQuery, facetsToQuery } from "../notes/facets";
import { addLocalDays, formatLocalDay, parseLocalDate } from "../notes/datetime";
import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
import Icon from "./Icon.vue";
// A dead-simple facet bar over the board: color + labels + has-reminder
@@ -32,9 +31,6 @@ function patch(p: Partial<NoteFacets>) {
function clearAll() {
void router.replace({ path: "/", query: {} });
}
function setColor(c: NoteColor) {
patch({ color: facets.value.color === c ? undefined : c });
}
function toggleLabel(id: string) {
const cur = facets.value.label ?? [];
const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
@@ -111,22 +107,8 @@ const chipOff = "border-neutral-300 text-neutral-600 hover:bg-neutral-100 dark:b
v-if="open"
class="mt-2 flex flex-col gap-3 rounded-xl border border-neutral-200 p-3 dark:border-neutral-800"
>
<div class="flex flex-wrap items-center gap-1.5">
<span class="w-16 shrink-0 text-xs text-neutral-400">Color</span>
<button
v-for="c in NOTE_COLOR_KEYS"
:key="c"
type="button"
:title="NOTE_COLOR_LABELS[c]"
:aria-label="NOTE_COLOR_LABELS[c]"
class="h-6 w-6 rounded-full border border-black/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-white/10"
:class="[NOTE_SWATCH_CLASSES[c], facets.color === c ? 'ring-2 ring-brand ring-offset-1' : '']"
@click="setColor(c)"
/>
</div>
<div v-if="labels.items.length" class="flex flex-wrap items-center gap-1.5">
<span class="w-16 shrink-0 text-xs text-neutral-400">Labels</span>
<span class="w-16 shrink-0 text-xs text-neutral-400">Tags</span>
<button
v-for="lb in labels.items"
:key="lb.id"
+2 -2
View File
@@ -46,7 +46,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
<template>
<div ref="root" class="relative">
<button type="button" class="icon-btn" title="Labels" aria-label="Labels" @click="open = !open">
<button type="button" class="icon-btn" title="Tags" aria-label="Tags" @click="open = !open">
<Icon name="tag" />
</button>
<div
@@ -56,7 +56,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
<input
v-model="filter"
type="text"
placeholder="Label note…"
placeholder="Tag note…"
class="mb-1 w-full rounded-md border border-neutral-300 bg-white px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-600 dark:bg-neutral-900"
/>
<ul class="max-h-48 overflow-y-auto">
+25 -15
View File
@@ -1,7 +1,13 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useLabelsStore, type Label } from "../stores/labels";
import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
import {
NOTE_COLOR_KEYS,
NOTE_COLOR_LABELS,
NOTE_SWATCH_CLASSES,
resolveLabelColor,
type NoteColor,
} from "../notes/colors";
import BaseModal from "./BaseModal.vue";
import Icon from "./Icon.vue";
@@ -25,8 +31,12 @@ async function rename(id: string, value: string) {
if (name) await labels.rename(id, name);
}
function labelDot(color: string): string {
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default;
// Shows the colour the tag ACTUALLY wears, derived from its name when nobody has
// picked one — so this screen agrees with the chips everywhere else. The ring in the
// swatch grid below follows the same resolution, so opening the picker highlights
// what you can already see rather than nothing at all.
function labelDot(label: { name: string; color: string }): string {
return NOTE_SWATCH_CLASSES[resolveLabelColor(label)] ?? NOTE_SWATCH_CLASSES.default;
}
function openColor(id: string) {
@@ -57,7 +67,7 @@ async function doMerge(sourceId: string, targetId: string) {
<template>
<BaseModal panel-class="w-full max-w-sm shadow-xl" @close="emit('close')">
<div class="flex items-center justify-between border-b border-neutral-100 px-4 py-3 dark:border-neutral-800">
<h2 class="text-sm font-semibold">Manage labels</h2>
<h2 class="text-sm font-semibold">Manage tags</h2>
<button type="button" class="icon-btn" aria-label="Close" @click="emit('close')"><Icon name="close" /></button>
</div>
<div class="flex flex-col gap-2 p-4">
@@ -66,7 +76,7 @@ async function doMerge(sourceId: string, targetId: string) {
<input
v-model="newName"
type="text"
placeholder="Create label"
placeholder="Create tag"
class="flex-1 rounded-md border border-neutral-300 bg-white px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800"
/>
</form>
@@ -75,9 +85,9 @@ async function doMerge(sourceId: string, targetId: string) {
<button
type="button"
class="h-4 w-4 shrink-0 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-white/15"
:class="labelDot(lb.color)"
:title="`Color: ${NOTE_COLOR_LABELS[(lb.color as NoteColor)] ?? lb.color}`"
aria-label="Change label color"
:class="labelDot(lb)"
:title="`Color: ${NOTE_COLOR_LABELS[resolveLabelColor(lb)]}`"
aria-label="Change tag color"
@click="openColor(lb.id)"
/>
<input
@@ -94,8 +104,8 @@ async function doMerge(sourceId: string, targetId: string) {
v-if="canMerge"
type="button"
class="icon-btn"
title="Merge into another label"
aria-label="Merge into another label"
title="Merge into another tag"
aria-label="Merge into another tag"
@click="openMerge(lb.id)"
>
<Icon name="merge" />
@@ -103,8 +113,8 @@ async function doMerge(sourceId: string, targetId: string) {
<button
type="button"
class="icon-btn"
title="Delete label"
aria-label="Delete label"
title="Delete tag"
aria-label="Delete tag"
@click="labels.remove(lb.id)"
>
<Icon name="trash" />
@@ -120,7 +130,7 @@ async function doMerge(sourceId: string, targetId: string) {
:title="NOTE_COLOR_LABELS[key]"
:aria-label="NOTE_COLOR_LABELS[key]"
class="h-6 w-6 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
:class="[NOTE_SWATCH_CLASSES[key], lb.color === key ? 'ring-2 ring-brand' : '']"
:class="[NOTE_SWATCH_CLASSES[key], resolveLabelColor(lb) === key ? 'ring-2 ring-brand' : '']"
@click="pickColor(lb.id, key)"
/>
</div>
@@ -140,7 +150,7 @@ async function doMerge(sourceId: string, targetId: string) {
>
<span
class="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10 dark:border-white/15"
:class="labelDot(t.color)"
:class="labelDot(t)"
></span>
<span class="truncate">{{ t.name }}</span>
</button>
@@ -150,7 +160,7 @@ async function doMerge(sourceId: string, targetId: string) {
</li>
</ul>
<p v-if="!labels.items.length" class="py-2 text-center text-xs text-neutral-400">
No labels yet create one above.
No tags yet create one above, or write a #tag in a note and it becomes one.
</p>
</div>
</BaseModal>
+10 -2
View File
@@ -1,10 +1,16 @@
<script setup lang="ts">
import type { InlineToken } from "../notes/markdown";
import { tagTextClasses } from "../notes/colors";
// Emphasis and code only. `[[wiki-links]]` were the one token type that needed a
// Emphasis, code, and `#tags`. `[[wiki-links]]` were the one token type that needed a
// router, a store and a resolver behind it; they are gone (note 2897), and so is all
// of that.
defineProps<{ tokens: InlineToken[] }>();
//
// `tagColors` maps a lowercased tag name to the colour stored on that label. Threaded
// down from the card rather than looked up here, because this component renders text
// and has no idea which note the text belongs to — and a tag the operator recoloured
// must read the same here as it does on a chip.
defineProps<{ tokens: InlineToken[]; tagColors?: Record<string, string> }>();
</script>
<!-- Rendered tightly (no whitespace between tokens) so a token's own leading/trailing
@@ -17,6 +23,8 @@ defineProps<{ tokens: InlineToken[] }>();
v-else-if="t.type === 'code'"
class="rounded bg-black/5 px-1 py-0.5 font-mono text-[0.85em] dark:bg-white/10"
>{{ t.value }}</code
><span v-else-if="t.type === 'tag'" class="font-medium" :class="tagTextClasses(t.value, tagColors)"
>#{{ t.value }}</span
><template v-else>{{ t.value }}</template></template
></template
>
+47 -8
View File
@@ -3,34 +3,73 @@ import { computed } from "vue";
import { parseMarkdown } from "../notes/markdown";
import MarkdownInline from "./MarkdownInline.vue";
const props = defineProps<{ text: string }>();
// `tagColors` is passed straight through to MarkdownInline — see there for why the
// card owns the lookup rather than the renderer.
const props = defineProps<{
text: string;
toggleable?: boolean;
tagColors?: Record<string, string>;
}>();
// Ticking a box rewrites a line of the note's body, which is a thing only the owner
// of that note can do — so this renders the checkbox and hands the intent up rather
// than reaching for the store itself. The card wires it; a read-only render does not
// pass `toggleable` and the boxes are inert.
const emit = defineEmits<{ toggle: [index: number, checked: boolean] }>();
const blocks = computed(() => parseMarkdown(props.text));
</script>
<template>
<div class="space-y-1.5 break-words">
<template v-for="(b, i) in blocks" :key="i">
<h3 v-if="b.type === 'h1'" class="text-base font-bold"><MarkdownInline :tokens="b.inline ?? []" /></h3>
<h4 v-else-if="b.type === 'h2'" class="text-sm font-bold"><MarkdownInline :tokens="b.inline ?? []" /></h4>
<h5 v-else-if="b.type === 'h3'" class="text-sm font-semibold"><MarkdownInline :tokens="b.inline ?? []" /></h5>
<h3 v-if="b.type === 'h1'" class="text-base font-bold">
<MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
</h3>
<h4 v-else-if="b.type === 'h2'" class="text-sm font-bold">
<MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
</h4>
<h5 v-else-if="b.type === 'h3'" class="text-sm font-semibold">
<MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
</h5>
<blockquote
v-else-if="b.type === 'quote'"
class="whitespace-pre-wrap border-l-2 border-neutral-300 pl-2 text-neutral-600 dark:border-neutral-600 dark:text-neutral-400"
>
<MarkdownInline :tokens="b.inline ?? []" />
<MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
</blockquote>
<div v-else-if="b.type === 'task'" class="flex flex-col gap-1">
<div v-for="(it, j) in b.items ?? []" :key="j" class="flex items-start gap-2">
<!-- Not wrapped in a <label>: on a card the text is the note's own words and
clicking it opens the note, so only the box itself toggles. `.stop` for
the same reason — the card is a click target underneath. -->
<input
type="checkbox"
class="mt-0.5 h-4 w-4 shrink-0 accent-brand"
:checked="b.tasks?.[j]?.checked ?? false"
:disabled="!toggleable"
:aria-label="toggleable ? 'Toggle item' : undefined"
@click.stop
@change="emit('toggle', b.tasks?.[j]?.index ?? 0, ($event.target as HTMLInputElement).checked)"
/>
<span
class="min-w-0 flex-1"
:class="b.tasks?.[j]?.checked ? 'text-neutral-400 line-through' : ''"
>
<MarkdownInline :tokens="it" :tag-colors="tagColors" />
</span>
</div>
</div>
<ul v-else-if="b.type === 'ul'" class="list-disc space-y-0.5 pl-5">
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li>
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" :tag-colors="tagColors" /></li>
</ul>
<ol v-else-if="b.type === 'ol'" class="list-decimal space-y-0.5 pl-5">
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li>
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" :tag-colors="tagColors" /></li>
</ol>
<pre
v-else-if="b.type === 'pre'"
class="overflow-x-auto whitespace-pre-wrap rounded-md bg-black/5 p-2 font-mono text-xs dark:bg-white/10"
>{{ b.value ?? "" }}</pre
>
<p v-else class="whitespace-pre-wrap"><MarkdownInline :tokens="b.inline ?? []" /></p>
<p v-else class="whitespace-pre-wrap"><MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" /></p>
</template>
</div>
</template>
+88 -88
View File
@@ -1,19 +1,11 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { computed, ref, watch } from "vue";
import { useNotesStore } from "../stores/notes";
import {
LABEL_CHIP_CLASSES,
NOTE_CARD_CLASSES,
NOTE_COLOR_KEYS,
NOTE_COLOR_LABELS,
NOTE_SWATCH_CLASSES,
type NoteColor,
} from "../notes/colors";
import { NOTE_CARD_SURFACE, labelChipClasses } from "../notes/colors";
import type { Note } from "../stores/notes";
import Icon from "./Icon.vue";
import LinkPreview from "./LinkPreview.vue";
import MarkdownText from "./MarkdownText.vue";
import NoteChecklist from "./NoteChecklist.vue";
import {
cardIdAt,
draggingId,
@@ -94,6 +86,15 @@ const bodyPreview = computed(() => {
return lines.slice(0, PREVIEW_LINES).join("\n") + "\n…";
});
/** Tick a box without opening the note — the common gesture on a board.
*
* The index is the item's ordinal in the WHOLE body, which survives the preview
* clamp above because that only ever drops lines from the end. `updateItem` takes
* it as the item id, which is exactly what an id is now (M304). */
function toggleTask(index: number, checked: boolean) {
void notes.updateItem(props.note.id, String(index), { checked });
}
const root = ref<HTMLElement | null>(null);
// --- Drag-to-reorder. Pointer Events, gated behind an explicit grip handle so a
@@ -181,43 +182,31 @@ async function snoozeReminder(minutes: number): Promise<void> {
emit("reminder-changed");
}
function cardClass(color: NoteColor): string {
return NOTE_CARD_CLASSES[color] ?? NOTE_CARD_CLASSES.default;
}
// Only the tags the BODY is not already showing. `via_tag` means exactly "backed by
// text still in the note" since M311, so a chip for one printed the same tag twice —
// once where it was typed, once in this row — and the loud copy was the duplicate. A
// tag left in prose is tinted where it sits instead (MarkdownInline). What survives
// here is what the body cannot say: a tag lifted off its own line, and a label added
// through the picker.
const chipLabels = computed(() => props.note.labels.filter((lb) => !lb.via_tag));
function labelChip(color: string): string {
return LABEL_CHIP_CLASSES[color as NoteColor] ?? LABEL_CHIP_CLASSES.default;
}
// Per-card color popover (recolor without opening the editor).
const colorOpen = ref(false);
function swatch(color: string): string {
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default;
}
function pickColor(color: NoteColor) {
colorOpen.value = false;
void notes.setColor(props.note.id, color);
}
function onDocMousedown(e: MouseEvent) {
if (colorOpen.value && root.value && !root.value.contains(e.target as Node)) colorOpen.value = false;
}
// Only listen for outside clicks while the popover is actually open.
watch(colorOpen, (open) => {
if (open) document.addEventListener("mousedown", onDocMousedown);
else document.removeEventListener("mousedown", onDocMousedown);
// The colour the operator stored for each of this note's tags, keyed by lowercased
// name — what MarkdownInline needs to tint a `#tag` the same as its chip would be.
// Lowercased because tags dedupe case-insensitively, so `#Todo` and `#todo` are one.
const tagColors = computed<Record<string, string>>(() => {
const map: Record<string, string> = {};
for (const lb of props.note.labels) map[lb.name.toLowerCase()] = lb.color;
return map;
});
onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown));
</script>
<template>
<div
ref="root"
class="group relative mb-4 break-inside-avoid rounded-xl border p-3 shadow-sm transition hover:shadow-md"
class="group relative mb-4 break-inside-avoid rounded-xl border border-[#b8b8b8] p-3 shadow-sm transition hover:shadow-md dark:border-[#404040]"
:class="[
cardClass(note.color),
NOTE_CARD_SURFACE,
dragging ? 'opacity-40' : '',
dragOver
? 'scale-[1.02] shadow-lg ring-2 ring-brand ring-offset-2 ring-offset-white dark:ring-offset-neutral-950'
@@ -226,6 +215,61 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
]"
:data-note-id="note.id"
>
<!-- THE EDGE IS THE CARD'S BOUNDARY, and since M315 it is the ONLY thing that
varies from the board: the fill is one neutral (NOTE_CARD_SURFACE) and no
longer says anything about the note. That makes this line load-bearing rather
than decorative — it is what a card IS.
It was already neutral before the fill was. The version that came from the
palette was a `{hue}-900` border and failed twice over: the line was the
loudest element on the card (1.56-2.09 against its own fill, where the fill
managed 1.03-1.05 against the board) AND carried the same information the fill
did, so a board of them read as a grid of outlines. A neutral line carries no
information at all, which is exactly what lets it be structure. The fill is
the same argument one size up, made two milestones later.
MEASURED AGAINST ONE FILL NOW, and deliberately left where it was. #b8b8b8 on
white is 1.98 and #404040 on #171717 is 1.73 — both inside the ranges these
values already shipped at across twenty fills (light 1.57-1.98, dark
1.58-1.73), but at the top of them rather than the ~1.6-1.7 the pair was
originally matched on. Softening the light edge to re-match would weaken the
only boundary a white card on a #fafafa board has, and the complaint that
started M315 was about fill, never about edge weight. If an operator pass
disagrees it is one constant, in two files.
NOT a translucent black/white edge, which is the tidier-looking way to do this
and was measured and rejected: a border composites over what is under it, so
`border-white/20` came out #56396d on a purple card and #a3c9c1 on a teal one.
With one fill that argument no longer bites — but an opaque grey is what the
Android side must also write, and two surfaces stating the same hex is how
they stay the same card.
`shadow-sm`, back down from `shadow`: the border is the boundary again, so the
shadow is only depth. -->
<!-- TAGS FIRST. They used to sit under everything else, which on a tall note put
the one thing that says what a note IS below the fold of a glance. A board is
scanned, not read, and the answer to "which of these is about the thing I am
looking for" should be the first thing the eye lands on rather than the last.
Above the image and the body rather than beside them, because the body's first
line is the note's NAME (M13 steps 3 and 4) and a chip floated next to it would
compete with the thing that identifies the note. -->
<div v-if="chipLabels.length" class="mb-2 flex flex-wrap gap-1">
<!-- Every chip carries the `#`, not just the ones derived from body text. That
branch used to distinguish a `#tag` from a picker label; it cannot any more,
because a tag whose text is still in the body no longer reaches this row at
all. What is left is all the same thing to the eye and to the vocabulary —
and the hash is what keeps a lifted chip reading as the `#todo` somebody
typed. Android's row says the same, which it did not before. -->
<span
v-for="lb in chipLabels"
:key="lb.id"
class="rounded-full px-2 py-0.5 text-xs"
:class="labelChipClasses(lb)"
>#{{ lb.name }}</span
>
</div>
<img
v-if="firstImage"
:src="firstImage.url"
@@ -263,7 +307,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
blank and the link is never unreachable. -->
<LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" />
<div v-else-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
<MarkdownText :text="bodyPreview" />
<MarkdownText :text="bodyPreview" :tag-colors="tagColors" toggleable @toggle="toggleTask" />
</div>
<p
v-if="!note.body && !note.items.length && !note.attachments.length"
@@ -279,23 +323,10 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
<LinkPreview v-for="p in note.previews" :key="p.id" :preview="p" compact />
</div>
<NoteChecklist
v-if="note.items.length"
:class="note.body ? 'mt-2' : ''"
:note-id="note.id"
:items="note.items"
@click="emit('open', note)"
/>
<div v-if="note.labels.length" class="mt-2 flex flex-wrap gap-1">
<span
v-for="lb in note.labels"
:key="lb.id"
class="rounded-full px-2 py-0.5 text-xs"
:class="labelChip(lb.color)"
>{{ lb.via_tag ? "#" + lb.name : lb.name }}</span
>
</div>
<!-- No separate checklist block any more. A checklist is lines of the body (M304),
so MarkdownText above draws it in place — which is what lets a list sit between
two paragraphs instead of always after them. Rendering both would have shown
every list twice. -->
<div v-if="note.remind_at" class="mt-2 flex flex-wrap items-center gap-1.5">
<span
@@ -410,19 +441,6 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
</button>
</template>
<template v-else>
<button
type="button"
class="icon-btn"
title="Change color"
aria-label="Change color"
:aria-expanded="colorOpen"
@click.stop="colorOpen = !colorOpen"
>
<span
class="h-4 w-4 rounded-full border border-black/10 dark:border-white/20"
:class="swatch(note.color)"
></span>
</button>
<button
type="button"
class="icon-btn"
@@ -453,24 +471,6 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
<Icon name="trash" />
</button>
</template>
<!-- Inside the action set rather than beside it, so it follows the set to
whichever corner or footer the device put it in. -->
<div
v-if="colorOpen"
class="note-swatches flex w-40 flex-wrap gap-1.5 rounded-lg border border-neutral-200 bg-white p-2 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
<button
v-for="key in NOTE_COLOR_KEYS"
:key="key"
type="button"
:title="NOTE_COLOR_LABELS[key]"
:aria-label="NOTE_COLOR_LABELS[key]"
class="h-6 w-6 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
:class="[NOTE_SWATCH_CLASSES[key], note.color === key ? 'ring-2 ring-brand' : '']"
@click.stop="pickColor(key)"
/>
</div>
</div>
</div>
</div>
-75
View File
@@ -1,75 +0,0 @@
<script setup lang="ts">
import { ref } from "vue";
import { useNotesStore, type ChecklistItem } from "../stores/notes";
const props = defineProps<{ noteId: string; items: ChecklistItem[]; editable?: boolean }>();
const notes = useNotesStore();
const newItem = ref("");
async function addItem() {
const text = newItem.value.trim();
if (!text) return;
await notes.addItem(props.noteId, text);
newItem.value = "";
}
function toggle(item: ChecklistItem) {
void notes.updateItem(props.noteId, item.id, { checked: !item.checked });
}
function editText(item: ChecklistItem, value: string) {
if (value !== item.text) void notes.updateItem(props.noteId, item.id, { text: value });
}
function remove(item: ChecklistItem) {
void notes.deleteItem(props.noteId, item.id);
}
</script>
<template>
<div class="flex flex-col gap-1">
<div v-for="item in items" :key="item.id" class="group/item flex items-center gap-2">
<input
type="checkbox"
class="h-4 w-4 shrink-0 accent-brand"
:checked="item.checked"
@change="toggle(item)"
@click.stop
/>
<input
v-if="editable"
:value="item.text"
class="min-w-0 flex-1 bg-transparent text-sm outline-none"
:class="item.checked ? 'text-neutral-400 line-through' : ''"
@change="editText(item, ($event.target as HTMLInputElement).value)"
/>
<span
v-else
class="min-w-0 flex-1 truncate text-sm"
:class="item.checked ? 'text-neutral-400 line-through' : 'text-neutral-700 dark:text-neutral-300'"
>{{ item.text }}</span
>
<button
v-if="editable"
type="button"
class="hover-reveal text-neutral-300 opacity-0 hover:text-neutral-600 group-hover/item:opacity-100 dark:hover:text-neutral-200"
aria-label="Delete item"
@click="remove(item)"
>
×
</button>
</div>
<form v-if="editable" class="mt-1 flex items-center gap-2" @submit.prevent="addItem">
<span class="h-4 w-4 shrink-0" />
<input
v-model="newItem"
type="text"
placeholder="+ List item"
class="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-neutral-400"
/>
</form>
<p v-if="!editable && items.length === 0" class="text-sm italic text-neutral-400">Empty checklist</p>
</div>
</template>
+263 -78
View File
@@ -1,16 +1,23 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useNotesStore } from "../stores/notes";
import ColorPicker from "./ColorPicker.vue";
import Icon from "./Icon.vue";
import LabelPicker from "./LabelPicker.vue";
import LinkPreview from "./LinkPreview.vue";
import NoteChecklist from "./NoteChecklist.vue";
import { fromLocalInput, toLocalInput } from "../notes/datetime";
import { takeMorphOrigin } from "../composables/useEditorMorph";
import { prefersReducedMotion } from "../composables/useReducedMotion";
import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
import { LABEL_CHIP_CLASSES, type NoteColor } from "../notes/colors";
import { labelChipClasses } from "../notes/colors";
import {
afterEnter,
type EditorBlock,
joinBlocks,
plusTask,
promoteTasks,
splitBlocks,
withoutIndex,
} from "../notes/blocks";
// One modal editor for BOTH composing and editing — a single surface (the board's
// "Take a note…" bar just opens this in compose mode). `note` = the note being edited,
@@ -25,24 +32,53 @@ const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void
const notes = useNotesStore();
const noteId = ref<string | null>(props.note?.id ?? null);
const body = ref(props.note?.body ?? props.initialBody);
const color = ref<NoteColor>(props.note?.color ?? "default");
// BLOCKS rather than one string, because a checklist item is drawn as a real checkbox
// and a widget cannot live inside a <textarea>. The note is still one markdown body —
// see notes/blocks.ts — and `body` is what every save, baseline and draft still reads.
const blocks = ref<EditorBlock[]>(splitBlocks(props.note?.body ?? props.initialBody));
const body = computed(() => joinBlocks(blocks.value));
function setBody(text: string): void {
blocks.value = splitBlocks(text);
}
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
// Whether this editor is showing the checklist. A note HAS a checklist (M13 step 2)
// rather than BEING one, so this is a view flag, not a property of the note: it turns
// on when the note already carries items, and when someone asks for one.
const checklistOpen = ref(false);
const saving = ref(false);
const root = ref<HTMLElement | null>(null);
const bodyInput = ref<HTMLTextAreaElement | null>(null);
// One element per block, keyed by the block's id — the only thing about a block that
// survives one being inserted above it. Focus is asked for by id and honoured after
// the render that created the field, since an element that does not exist yet cannot
// take it.
const blockEls = new Map<number, HTMLTextAreaElement | HTMLInputElement>();
function setBlockEl(id: number, el: unknown): void {
if (el) blockEls.set(id, el as HTMLTextAreaElement);
else blockEls.delete(id);
}
async function focusBlock(id: number | null): Promise<void> {
if (id === null) return;
await nextTick();
const el = blockEls.get(id);
el?.focus();
if (el) el.selectionStart = el.selectionEnd = el.value.length;
}
/** A prose field sized to its text. `rows="1"` plus this beats guessing a row count,
* which is wrong the moment a line wraps. */
function grow(el: HTMLTextAreaElement): void {
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}
function growAll(): void {
for (const el of blockEls.values()) {
if (el instanceof HTMLTextAreaElement) grow(el);
}
}
const fileInput = ref<HTMLInputElement | null>(null);
const uploadError = ref("");
// Baseline for edit-mode change detection (save only when text actually changed).
const baseline = ref<{ body: string; color: NoteColor }>({
body: props.note?.body ?? "",
color: (props.note?.color ?? "default") as NoteColor,
});
const baseline = ref<{ body: string }>({ body: props.note?.body ?? "" });
const isCreate = computed(() => noteId.value === null);
const hasContent = computed(() => body.value.trim() !== "");
@@ -55,7 +91,6 @@ const draftNote = computed<Note>(() => ({
id: "",
display_title: "",
body: body.value,
color: color.value,
position: 0,
pinned: false,
archived: false,
@@ -75,15 +110,6 @@ const liveNote = computed<Note>(() =>
? (notes.items.find((n) => n.id === noteId.value) ?? props.note ?? draftNote.value)
: draftNote.value,
);
// The checklist renders once the note has items, or once someone has asked for one.
// It sits BELOW the body rather than instead of it — a note can carry both, which is
// the whole point of the merge.
//
// Items need a persisted note to hang off, so this is a rich action like attaching a
// file: in compose it waits for the draft to be saved.
const showChecklist = computed(
() => !isCreate.value && (liveNote.value.items.length > 0 || checklistOpen.value),
);
const bodyPlaceholder = "Take a note…";
// Keep local state in sync when the edited note changes (modal reused for another note).
@@ -91,18 +117,17 @@ watch(
() => props.note,
(n) => {
noteId.value = n?.id ?? null;
body.value = n?.body ?? "";
color.value = (n?.color ?? "default") as NoteColor;
setBody(n?.body ?? "");
labelList.value = n ? [...n.labels] : [];
baseline.value = { body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor };
baseline.value = { body: n?.body ?? "" };
},
);
// ---- persistence ----
async function createFromFields(): Promise<void> {
const created = await notes.create({ body: body.value, color: color.value });
const created = await notes.create({ body: body.value });
noteId.value = created.id;
baseline.value = { body: created.body, color: created.color as NoteColor };
baseline.value = { body: created.body };
}
// Ensure a persisted note exists (for rich actions mid-compose). Returns its id, or
@@ -129,24 +154,79 @@ async function flush(): Promise<void> {
}
const b = baseline.value;
const nextBody = body.value;
const changed = nextBody !== b.body || color.value !== b.color;
const changed = nextBody !== b.body;
if (!changed) return;
saving.value = true;
try {
await notes.saveEdit(noteId.value as string, { body: nextBody, color: color.value });
baseline.value = { body: nextBody, color: color.value };
await notes.saveEdit(noteId.value as string, { body: nextBody });
baseline.value = { body: nextBody };
} finally {
saving.value = false;
}
}
// ---- idle autosave ----
//
// This editor used to write ONLY on close, and the reason was cost: a body write
// snapshotted a revision, so saving often meant a version history of thirty
// snapshots of one paragraph being typed. The price was durability — a tab closed
// mid-paragraph lost the paragraph, which is the one thing a notes app must not do.
//
// That trade is gone. Both engines now coalesce snapshots to one per editing
// session (`revisions.py` and `store.rs`'s `should_snapshot`, Scribe #2971), so a
// write costs a write. Writing on an idle pause is what collects the refund; the
// Android editor already does the same.
const AUTOSAVE_MS = 1000;
let autosaveTimer: ReturnType<typeof setTimeout> | null = null;
function cancelAutosave(): void {
if (autosaveTimer !== null) {
clearTimeout(autosaveTimer);
autosaveTimer = null;
}
}
async function autosave(): Promise<void> {
// `flush` returns without writing while a save is in flight, which would silently
// drop everything typed since that save began. Re-arming rather than skipping is
// what keeps that from being a lost paragraph.
if (saving.value) {
scheduleAutosave();
return;
}
try {
await flush();
} catch {
// Swallowed on purpose. An autosave that interrupts typing with an error is
// worse than one that waits for the next pause, and `close` still surfaces a
// real failure at the moment the person is looking at the editor.
}
}
function scheduleAutosave(): void {
cancelAutosave();
autosaveTimer = setTimeout(() => {
autosaveTimer = null;
void autosave();
}, AUTOSAVE_MS);
}
// EDIT mode only, deliberately. In compose, `dismiss` discards a note that was
// never persisted, so that an accidental keystroke or a type-to-compose never
// litters the board — and an autosave that created the row would take that away
// without anyone asking for it. Materialising a compose on first keystroke is a
// separate decision (Scribe #2967), not a side effect of this one.
watch(body, () => {
if (!isCreate.value) scheduleAutosave();
});
onBeforeUnmount(cancelAutosave);
function resetCompose(): void {
noteId.value = null;
body.value = "";
color.value = "default";
setBody("");
labelList.value = [];
checklistOpen.value = false;
baseline.value = { body: "", color: "default" };
baseline.value = { body: "" };
uploadError.value = "";
}
@@ -157,7 +237,9 @@ async function commitAndContinue(): Promise<void> {
await flush();
resetCompose();
await nextTick();
bodyInput.value?.focus();
growAll();
const first = blocks.value[0];
await focusBlock(first ? first.id : null);
}
// ---- open/close animation (M7) ----
//
@@ -200,6 +282,10 @@ async function finish(): Promise<void> {
// Persist (create in compose, save in edit) and close the editor.
async function close(): Promise<void> {
// Cancelled first: a timer that fires during the leave animation would write
// through a component on its way out, after `flush` has already saved the same
// text.
cancelAutosave();
await flush();
await finish();
}
@@ -208,6 +294,7 @@ async function close(): Promise<void> {
// commit it explicitly (Done, Ctrl/Cmd+Enter, or Shift+Enter). An existing note, or a
// compose already persisted by a rich action, closes normally (saving its text).
async function dismiss(): Promise<void> {
cancelAutosave();
if (isCreate.value) {
await finish();
return;
@@ -227,21 +314,85 @@ function onBackdropMousedown(): void {
onMounted(async () => {
await nextTick();
const el = bodyInput.value;
el?.focus();
// Put the caret after any seeded text (type-to-compose) so typing continues cleanly.
if (el) el.selectionStart = el.selectionEnd = el.value.length;
growAll();
// The LAST block, with the caret after its text: opening a note means continuing it,
// and type-to-compose seeds text that should be typed straight on from.
const last = blocks.value[blocks.value.length - 1];
await focusBlock(last ? last.id : null);
});
function onBodyKeydown(e: KeyboardEvent) {
// Compose: Shift+Enter saves the note and starts a fresh one (rapid capture).
/** Editing one block: replace its text, leave every other block alone. */
function setText(index: number, text: string): void {
const out = [...blocks.value];
out[index] = { ...out[index], text };
blocks.value = out;
}
function setChecked(index: number, checked: boolean): void {
const out = [...blocks.value];
out[index] = { ...out[index], checked };
blocks.value = out;
}
function onProseInput(index: number, e: Event): void {
const el = e.target as HTMLTextAreaElement;
setText(index, el.value);
grow(el);
}
/**
* Leaving a prose block is when a `- [ ] ` typed by hand becomes a real item.
*
* See notes/blocks.ts for why blur is the only safe moment. The identity check is the
* contract `promoteTasks` offers: an untouched array back means nothing to promote, and
* reassigning the ref anyway would re-key every field below this one for no reason.
*
* `growAll` after the DOM settles, because the textarea being left is now shorter by
* however many lines became checkboxes and would otherwise keep its old height.
*/
function onProseBlur(index: number): void {
const promoted = promoteTasks(blocks.value, index);
if (promoted === blocks.value) return;
blocks.value = promoted;
void nextTick().then(growAll);
}
/** Compose: Shift+Enter saves the note and starts a fresh one (rapid capture). */
function onProseKeydown(e: KeyboardEvent): void {
if (isCreate.value && e.key === "Enter" && e.shiftKey) {
e.preventDefault();
void commitAndContinue();
return;
}
}
/** Enter on an item makes the next one; on an EMPTY item it ends the list. */
function onTaskEnter(index: number): void {
const next = afterEnter(blocks.value, index);
blocks.value = next.blocks;
void focusBlock(next.focus);
}
/**
* Backspace at the very start of an EMPTY item removes it.
*
* Worth having on the web where Android's is not: a browser sends a real keydown for
* Backspace, while an Android soft keyboard sends an IME delete that never surfaces as
* one. Enter-on-empty ends a list on both surfaces; this is the extra way out that
* only one of them can offer.
*/
function onTaskBackspace(index: number, e: KeyboardEvent): void {
const el = e.target as HTMLInputElement;
if (el.value !== "" || el.selectionStart !== 0) return;
e.preventDefault();
removeBlock(index);
}
function removeBlock(index: number): void {
const next = withoutIndex(blocks.value, index);
blocks.value = next.blocks;
void focusBlock(next.focus);
}
// ---- reminder ----
const reminderLocal = computed(() => toLocalInput(liveNote.value.remind_at));
async function onReminderChange(e: Event) {
@@ -279,20 +430,19 @@ async function onLabelsChange(next: NoteLabel[]) {
async function removeLabel(id: string) {
await onLabelsChange(labelList.value.filter((lb) => lb.id !== id));
}
function labelChip(c: string): string {
return LABEL_CHIP_CLASSES[c as NoteColor] ?? LABEL_CHIP_CLASSES.default;
}
// ---- add a checklist ----
//
// Not a conversion any more. Nothing is moved, nothing is swapped: the note keeps its
// body and gains a place to put items. Persists the draft first for the same reason
// attaching a file does — an item needs a note to belong to.
async function addChecklist() {
if (checklistOpen.value) return;
const id = await ensureDraft();
if (!id) return;
checklistOpen.value = true;
// Appends an empty item and puts the caret in it. Unlike every other toolbar button
// this one needs NO persisted note to hang anything off — a checklist is part of the
// body (M304), so it works on an empty compose box the moment it opens.
//
// Appends rather than inserting at the caret because a block editor has no single
// caret to insert at: the field that had focus may not be the one being looked at by
// the time this runs.
function addChecklist(): void {
const next = plusTask(blocks.value);
blocks.value = next.blocks;
void focusBlock(next.focus);
}
// ---- attachments ----
@@ -371,9 +521,8 @@ async function restoreRevisionAt(revId: string) {
const id = noteId.value;
if (!id) return;
const updated = await notes.restoreRevision(id, revId);
body.value = updated.body;
color.value = updated.color;
baseline.value = { body: updated.body, color: updated.color };
setBody(updated.body);
baseline.value = { body: updated.body };
void loadRevisions(); // the pre-restore state became a new revision
}
function revLabel(iso: string | null): string {
@@ -474,31 +623,65 @@ function revPreview(rev: NoteRevision): string {
/>
</div>
<textarea
ref="bodyInput"
v-model="body"
rows="8"
:placeholder="bodyPlaceholder"
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
@keydown="onBodyKeydown"
/>
<!-- Below the body, not instead of it. -->
<NoteChecklist
v-if="showChecklist"
class="py-1"
:note-id="liveNote.id"
:items="liveNote.items"
editable
/>
<!-- The body, as fields and checkboxes rather than as markup. A checklist
item is a real input; a run of prose is one textarea, so typing a
paragraph still feels like typing a paragraph. -->
<div class="flex flex-col gap-1">
<template v-for="(block, i) in blocks" :key="block.id">
<div v-if="block.checked !== null" class="group/item flex items-center gap-2">
<input
type="checkbox"
class="h-4 w-4 shrink-0 accent-brand"
:checked="block.checked"
:aria-label="block.text || 'Checklist item'"
@change="setChecked(i, ($event.target as HTMLInputElement).checked)"
/>
<input
:ref="(el) => setBlockEl(block.id, el)"
:value="block.text"
type="text"
class="min-w-0 flex-1 bg-transparent text-sm leading-relaxed outline-none"
:class="block.checked ? 'text-neutral-400 line-through' : ''"
@input="setText(i, ($event.target as HTMLInputElement).value)"
@keydown.enter.prevent="onTaskEnter(i)"
@keydown.backspace="onTaskBackspace(i, $event)"
/>
<button
type="button"
class="hover-reveal shrink-0 text-neutral-300 opacity-0 hover:text-neutral-600 focus:opacity-100 group-hover/item:opacity-100 dark:hover:text-neutral-200"
aria-label="Delete item"
@click="removeBlock(i)"
>
×
</button>
</div>
<textarea
v-else
:ref="(el) => setBlockEl(block.id, el)"
:value="block.text"
rows="1"
:placeholder="i === 0 ? bodyPlaceholder : ''"
class="w-full resize-none overflow-hidden bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
@input="onProseInput(i, $event)"
@keydown="onProseKeydown"
@blur="onProseBlur(i)"
/>
</template>
</div>
<!-- No checklist component. The items are lines of the textarea above, which
is what lets a list sit between two paragraphs (M304). -->
<div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1">
<span
v-for="lb in labelList"
:key="lb.id"
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
:class="labelChip(lb.color)"
:class="labelChipClasses(lb)"
>
{{ lb.via_tag ? "#" + lb.name : lb.name }}
<!-- `#` on every chip, matching the card. This row still lists the tags
the BODY owns too — it is the control surface, and `via_tag` is what
decides whether there is a cross to remove one with. -->
#{{ lb.name }}
<button
v-if="!lb.via_tag"
type="button"
@@ -583,8 +766,10 @@ function revPreview(rev: NoteRevision): string {
</div>
</div>
<div class="flex items-center justify-between gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
<ColorPicker v-model="color" />
<!-- `justify-end`, not `justify-between`: the colour picker sat on the left of
this row until M315 and the row was balanced around it. With one child left,
`between` would push the actions to the far left of a full-width bar. -->
<div class="flex items-center justify-end gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
<div class="flex items-center gap-0.5">
<button
v-if="richEnabled && !liveNote.trashed"
@@ -598,7 +783,7 @@ function revPreview(rev: NoteRevision): string {
</button>
<input ref="fileInput" type="file" class="hidden" @change="onFileChange" />
<button
v-if="richEnabled && !liveNote.trashed && !showChecklist"
v-if="!liveNote.trashed"
type="button"
class="icon-btn"
title="Add a checklist"
+53
View File
@@ -5,6 +5,13 @@
interface TauriGlobal {
core: { invoke: <T>(cmd: string, args?: Record<string, unknown>) => Promise<T> };
// Also from `withGlobalTauri`. Needed because quick capture puts the app in TWO
// windows, each with its own Pinia stores — a note saved in one is invisible to
// the other until something says so, and an event is the only channel between
// them that does not involve polling SQLite.
event?: {
listen: <T>(event: string, handler: (e: { payload: T }) => void) => Promise<() => void>;
};
}
declare global {
@@ -192,3 +199,49 @@ export const updates = {
*/
install: () => invoke<void>("update_install"),
};
// --- Quick capture (#1899) ---------------------------------------------------
/**
* The stored hotkey and whether the OS actually accepted it.
*
* They disagree more often than you would like: a combination can be saved and
* refuse to register because a window manager or another app already holds it,
* and on Wayland a compositor may refuse global grabs entirely. `registered:
* false` alongside a non-empty `shortcut` is precisely that case, and the UI has
* to say so — a hotkey that silently does nothing is worse than none, because
* there is nothing to look at and nothing to fix.
*/
export interface CaptureShortcut {
/** The stored combination, or "" when quick capture is off. */
shortcut: string;
registered: boolean;
}
/** Offered as a starting point, never applied on the user's behalf. */
export const SUGGESTED_CAPTURE_SHORTCUT = "CommandOrControl+Shift+N";
/** Fired at the main window after a capture is saved. */
const CAPTURED_EVENT = "thoughtsync://captured";
export const capture = {
shortcut: () => invoke<CaptureShortcut>("capture_shortcut_get"),
/** Pass "" to turn quick capture off. Rejects if the system refuses it. */
setShortcut: (shortcut: string) => invoke<CaptureShortcut>("capture_shortcut_set", { shortcut }),
/** Hide the capture window; `saved` decides whether the board is told to reload. */
done: (saved: boolean) => invoke<void>("capture_done", { saved }),
};
/**
* Run `handler` whenever a note is captured in the other window.
*
* Returns an unlisten function, or a no-op on the web build and on any desktop
* runtime that does not expose the event API — the board simply keeps showing
* what it has until its next load, which is a stale list rather than a broken one.
*/
export async function onCaptured(handler: () => void): Promise<() => void> {
const events = window.__TAURI__?.event;
if (!events) return () => {};
return events.listen(CAPTURED_EVENT, () => handler());
}
+144
View File
@@ -0,0 +1,144 @@
// The editor's shape for a note body: a list of blocks rather than one string.
//
// The note is still one markdown body underneath (M304) — this is a rendering and
// input shape, and nothing below the editor can tell it exists. `joinBlocks` puts the
// string back together on every edit, and for a body already in canonical form it
// returns exactly what `splitBlocks` was handed.
//
// Why blocks at all: a checklist item has to be a real `<input type="checkbox">`, and
// a widget cannot live inside a `<textarea>`. Only a `contenteditable` could hold one,
// and that is a different editor with a different set of problems.
//
// A run of prose lines is ONE block, not one per line. Typing a paragraph has to feel
// like typing a paragraph, and a separate field under every sentence would break the
// caret mid-sentence. Only a checklist item earns a block, because only a checklist
// item needs a widget.
//
// The mirror of android/.../ui/EditorBlock.kt, deliberately: the two editors should
// behave the same, and the cheapest way to keep them that way is for the shapes to
// read alike.
import { parseTaskLine, renderTaskLine } from "./markdown";
export interface EditorBlock {
/** Stable across edits, so Vue keeps a field's caret when a block is inserted above
* it. Content cannot serve as the key — two empty items are identical and neither
* is the other. */
id: number;
text: string;
/** null for prose; ticked-or-not for a checklist item. */
checked: boolean | null;
}
/** Split a body into blocks, numbering them from `firstId`. */
export function splitBlocks(body: string, firstId = 0): EditorBlock[] {
const out: EditorBlock[] = [];
const prose: string[] = [];
let id = firstId;
const flushProse = () => {
if (prose.length) {
out.push({ id: id++, text: prose.join("\n"), checked: null });
prose.length = 0;
}
};
for (const line of (body ?? "").split("\n")) {
const task = parseTaskLine(line);
if (task) {
flushProse();
out.push({ id: id++, text: task.text, checked: task.checked });
} else {
prose.push(line);
}
}
flushProse();
// Never empty: an empty note still needs one field to type into.
return out.length ? out : [{ id, text: "", checked: null }];
}
/** The body those blocks stand for. */
export function joinBlocks(blocks: EditorBlock[]): string {
return blocks.map((b) => (b.checked === null ? b.text : renderTaskLine(b.text, b.checked))).join("\n");
}
/** An id nothing else is using. Monotonic within a session, which is all it has to be. */
export function nextId(blocks: EditorBlock[]): number {
return blocks.reduce((max, b) => Math.max(max, b.id), -1) + 1;
}
/**
* What Enter does on a checklist item, and which block should hold the caret after.
*
* On an item with words in it, a new empty item below. On an EMPTY one, the item
* becomes prose — which is how a list ENDS, and the only way to get a paragraph after
* one. Without that half a list is impossible to get out of.
*
* Appends rather than splitting at the caret: splitting an item in two is a rarity,
* and the caret is at the end for every ordinary use of that key.
*/
export function afterEnter(blocks: EditorBlock[], index: number): { blocks: EditorBlock[]; focus: number } {
const block = blocks[index];
const out = [...blocks];
if (!block.text.trim()) {
out[index] = { ...block, text: "", checked: null };
return { blocks: out, focus: block.id };
}
const id = nextId(blocks);
out.splice(index + 1, 0, { id, text: "", checked: false });
return { blocks: out, focus: id };
}
/**
* Drop a block, leaving at least one field to type into.
*
* Focus goes to the block above — or, when the first one was removed, to whichever
* takes its place. `index - 1` alone is -1 there, which would leave nothing focused.
*/
export function withoutIndex(blocks: EditorBlock[], index: number): { blocks: EditorBlock[]; focus: number | null } {
const kept = blocks.filter((_, i) => i !== index);
const fallback: EditorBlock[] = [{ id: nextId(blocks), text: "", checked: null }];
const remaining = kept.length ? kept : fallback;
return { blocks: remaining, focus: remaining[Math.max(0, index - 1)]?.id ?? null };
}
/** One more empty checklist item at the end, and the id to put the caret in. */
export function plusTask(blocks: EditorBlock[]): { blocks: EditorBlock[]; focus: number } {
const id = nextId(blocks);
return { blocks: [...blocks, { id, text: "", checked: false }], focus: id };
}
/**
* Re-read ONE prose block for `- [ ] ` lines somebody typed by hand.
*
* `splitBlocks` runs once, when the editor opens. After that the blocks are the state
* and nothing reads the body again — every edit travels the other way, through
* `joinBlocks`. So a marker typed by hand stayed literal text on screen until the note
* was closed and reopened, even though it was already a real item in storage and the
* card was already drawing a checkbox for it. The editor was the only place that
* disagreed.
*
* ON BLUR, and only the block being left. There is no good moment to convert while
* someone is typing: re-splitting on a keystroke moves the caret out of the word being
* written, and converting the instant `- [ ]` is complete does it before the item has
* any text. Blur is the one moment the person has demonstrably finished with the block,
* so a re-split costs no caret and cannot catch a half-typed line.
*
* Returns THE SAME ARRAY, not an equal copy, when there was nothing to promote — the
* caller leans on that to leave the ref alone, and a blur that changed nothing must not
* re-key every field below it.
*
* Non-canonical markers (`- [X]`, an odd bullet) come back canonical, exactly as they
* would have on reopen. That is the only case where this changes the body rather than
* only the way it is drawn.
*/
export function promoteTasks(blocks: EditorBlock[], index: number): EditorBlock[] {
const block = blocks[index];
if (!block || block.checked !== null) return blocks;
const split = splitBlocks(block.text, nextId(blocks));
// A single prose block back means there was nothing to promote. `splitBlocks` never
// returns an empty array, so `split[0]` is safe.
const changed = split.length > 1 || split[0].checked !== null;
return changed ? [...blocks.slice(0, index), ...split, ...blocks.slice(index + 1)] : blocks;
}
+252 -37
View File
@@ -17,18 +17,10 @@ export const NOTE_COLOR_KEYS = [
export type NoteColor = (typeof NOTE_COLOR_KEYS)[number];
export const NOTE_CARD_CLASSES: Record<NoteColor, string> = {
default: "bg-white border-neutral-200 dark:bg-neutral-900 dark:border-neutral-700",
red: "bg-red-50 border-red-200 dark:bg-red-950/40 dark:border-red-900",
orange: "bg-orange-50 border-orange-200 dark:bg-orange-950/40 dark:border-orange-900",
yellow: "bg-amber-50 border-amber-200 dark:bg-amber-950/40 dark:border-amber-900",
green: "bg-green-50 border-green-200 dark:bg-green-950/40 dark:border-green-900",
teal: "bg-teal-50 border-teal-200 dark:bg-teal-950/40 dark:border-teal-900",
blue: "bg-blue-50 border-blue-200 dark:bg-blue-950/40 dark:border-blue-900",
purple: "bg-purple-50 border-purple-200 dark:bg-purple-950/40 dark:border-purple-900",
pink: "bg-pink-50 border-pink-200 dark:bg-pink-950/40 dark:border-pink-900",
gray: "bg-neutral-100 border-neutral-300 dark:bg-neutral-800 dark:border-neutral-700",
};
/** Membership test for a colour key arriving from the server, which may be newer
* than this client. Once a lookup in a card-fill table; since M315 there is no such
* table, and this guards a LABEL's stored colour on its way into the palette. */
const KNOWN_COLORS = new Set<string>(NOTE_COLOR_KEYS);
export const NOTE_SWATCH_CLASSES: Record<NoteColor, string> = {
default: "bg-white dark:bg-neutral-600",
@@ -43,35 +35,108 @@ export const NOTE_SWATCH_CLASSES: Record<NoteColor, string> = {
gray: "bg-neutral-400 dark:bg-neutral-500",
};
// Label chip tints (bg + readable text), keyed by the same color vocabulary.
export const LABEL_CHIP_CLASSES: Record<NoteColor, string> = {
default: "bg-black/5 text-neutral-600 dark:bg-white/10 dark:text-neutral-300",
red: "bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300",
orange: "bg-orange-100 text-orange-700 dark:bg-orange-950/50 dark:text-orange-300",
yellow: "bg-amber-100 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300",
green: "bg-green-100 text-green-700 dark:bg-green-950/50 dark:text-green-300",
teal: "bg-teal-100 text-teal-700 dark:bg-teal-950/50 dark:text-teal-300",
blue: "bg-blue-100 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300",
purple: "bg-purple-100 text-purple-700 dark:bg-purple-950/50 dark:text-purple-300",
pink: "bg-pink-100 text-pink-700 dark:bg-pink-950/50 dark:text-pink-300",
gray: "bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-200",
// A chip's SHELL: its fill and its hairline edge, keyed by the colour vocabulary. The
// INK is not here — see TAG_TEXT_CLASSES, which one table now serves both a chip's text
// and a `#tag` left in the prose. Compose the two with `labelChipClasses`.
//
// THE RING IS NOT DECORATION. A chip's fill measures 1.02-1.26 against the card in
// light and 1.02-1.73 in dark — that is to say, very nearly nothing. The pill's shape
// is the edge; the fill only tints it. (Dark red is the extreme at 1.02, which is
// invisible: without the ring that chip would be loose text.) An edge holds the shape
// against any background, where shifting the fill only moves which card it collides
// with.
//
// AT 65%, NOT 60%, AND SOLVED FOR RATHER THAN GUESSED. 0.60 was chosen against a worst
// case that no longer exists — a chip sitting on a card of its own colour, back when a
// note took its first tag's fill. Against the one card surface (M315) the ring is the
// ink at alpha over a known fill, so the alpha that clears the 3:1 of WCAG 1.4.11 for
// all ten hues can simply be solved: 0.60 gives 2.75-3.82 in light and misses for six
// of them, 0.65 gives 3.03-4.36 and misses for none. Dark is 4.52-5.76. 0.80 was the
// number the old comment named as the fallback and it is more than is needed — it
// draws a hard outline where a hairline does the job.
//
// `default`'s ring was `black/10 dark:white/15` here and the ink at alpha on the phone —
// 1.36 against its own fill in light against Compose's 3.21, so the two surfaces were
// drawing visibly different pills for the same chip. It is the ink at 65% on both now,
// like every other key: one rule for ten hues, not nine and an exception.
//
// Mirrored in NoteTint.kt as `chipBackground` and `chipBorder` / CHIP_EDGE_ALPHA.
export const LABEL_CHIP_SHELL: Record<NoteColor, string> = {
default: "bg-black/5 dark:bg-white/10 ring-1 ring-inset ring-neutral-700/65 dark:ring-neutral-300/65",
red: "bg-red-100 dark:bg-red-950/50 ring-1 ring-inset ring-red-800/65 dark:ring-red-300/65",
orange: "bg-orange-100 dark:bg-orange-950/50 ring-1 ring-inset ring-orange-800/65 dark:ring-orange-300/65",
yellow: "bg-amber-100 dark:bg-amber-950/50 ring-1 ring-inset ring-amber-800/65 dark:ring-amber-300/65",
green: "bg-green-100 dark:bg-green-950/50 ring-1 ring-inset ring-green-800/65 dark:ring-green-300/65",
teal: "bg-teal-100 dark:bg-teal-950/50 ring-1 ring-inset ring-teal-800/65 dark:ring-teal-300/65",
blue: "bg-blue-100 dark:bg-blue-950/50 ring-1 ring-inset ring-blue-800/65 dark:ring-blue-300/65",
purple: "bg-purple-100 dark:bg-purple-950/50 ring-1 ring-inset ring-purple-800/65 dark:ring-purple-300/65",
pink: "bg-pink-100 dark:bg-pink-950/50 ring-1 ring-inset ring-pink-800/65 dark:ring-pink-300/65",
gray: "bg-neutral-200 dark:bg-neutral-700 ring-1 ring-inset ring-neutral-800/65 dark:ring-neutral-200/65",
};
// Solid fills for graph nodes (SVG needs concrete colors, not Tailwind bg classes).
// Mid-tone hues read on both the light and dark graph background.
export const NOTE_NODE_FILL: Record<NoteColor, string> = {
default: "#9ca3af",
red: "#ef4444",
orange: "#f97316",
yellow: "#f59e0b",
green: "#22c55e",
teal: "#14b8a6",
blue: "#3b82f6",
purple: "#a855f7",
pink: "#ec4899",
gray: "#6b7280",
// THE INK A TAG IS DRAWN IN — one table, for a `#tag` left in the prose AND for a
// chip's text. It was two, and the split was real while it lasted: a chip carried its
// own `-100` fill and could afford `-700`, while inline text sat on whatever the card
// was, which included a gray-tagged card at `neutral-200` where `-700` measured 3.98
// (green), 4.11 (orange) and 4.34 (teal) — all under the 4.5 body text needs. One step
// deeper cleared every fill at once, so inline got `-800` and the chip kept `-700`.
//
// M315 removed the twenty card fills the split was solving for, and this is the payoff:
// against ONE card surface both jobs can take the same value. `-800`/`-300` is the one
// they take, and the direction is deliberate — the inline token is the common case
// (since M311 a tag whose text is in the body is drawn where it was typed and NOT
// repeated as a chip), so collapsing onto the inline column leaves what is seen most
// exactly as it was, and moves only the chip. The chip is strictly better for it:
//
// inline, on the card as a chip, on its own fill
// light `-800` 7.09 - 15.13 6.37 - 12.01 (was 4.52 - 8.23)
// dark `-300` 9.45 - 14.23 8.23 - 11.88 (unchanged)
//
// Dark needed no decision at all: the two tables were already the same value for all
// ten hues, which is on its own most of the argument that one table was always enough.
//
// Mirrored in NoteTint.kt as `lightTagInk` / `darkTagInk`.
export const TAG_TEXT_CLASSES: Record<NoteColor, string> = {
default: "text-neutral-700 dark:text-neutral-300",
red: "text-red-800 dark:text-red-300",
orange: "text-orange-800 dark:text-orange-300",
yellow: "text-amber-800 dark:text-amber-300",
green: "text-green-800 dark:text-green-300",
teal: "text-teal-800 dark:text-teal-300",
blue: "text-blue-800 dark:text-blue-300",
purple: "text-purple-800 dark:text-purple-300",
pink: "text-pink-800 dark:text-pink-300",
gray: "text-neutral-800 dark:text-neutral-200",
};
/**
* The classes for one `#tag` in a note's own words.
*
* `picked` maps a lowercased tag name to the colour stored on that label, so a tag the
* operator has recoloured reads the same inline as it does on a chip. A tag the note
* does not carry as a label yet — just typed, not yet derived — is not in the map, and
* `resolveLabelColor` derives one from the name exactly as the chip would have.
*/
export function tagTextClasses(name: string, picked?: Record<string, string>): string {
return TAG_TEXT_CLASSES[resolveLabelColor({ name, color: picked?.[name.toLowerCase()] })];
}
/**
* The whole class list for a label CHIP: the shell and the ink, composed.
*
* One function rather than the composition written out at each call site, because the
* board's chip and the editor's chip have to be the same pill — the same tag changing
* colour on opening a note would be worse than both being grey. That was a comment
* asking two files to stay in step; it is one call now.
*
* Takes the LABEL, not its colour: a tag nobody has coloured derives one from its name,
* so chips carry the tag's identity rather than all being the same grey.
*/
export function labelChipClasses(label: { name: string; color?: string | null }): string {
const color = resolveLabelColor(label);
return `${LABEL_CHIP_SHELL[color]} ${TAG_TEXT_CLASSES[color]}`;
}
export const NOTE_COLOR_LABELS: Record<NoteColor, string> = {
default: "Default",
red: "Red",
@@ -84,3 +149,153 @@ export const NOTE_COLOR_LABELS: Record<NoteColor, string> = {
pink: "Pink",
gray: "Gray",
};
// ---------------------------------------------------------------------------
// Derived colour — the hue a LABEL wears when nobody picked one for it.
//
// Every `#tag` is born colourless, so without this a board of tags is a board of
// identical grey chips. Hashing the tag's NAME is deterministic, identical on every
// surface, costs no column and no migration, and a tag keeps its colour for life.
//
// THIS WAS THE CARD'S COLOUR TOO, ONCE. It is not any more (M315): a note's fill is
// one neutral and only its tags carry hue. The hash survived that removal because the
// job it still does — give a name a stable colour — was never the job that failed.
// What failed was asking a colour that means "which tag" to also mean nothing at all
// on an untagged note, at which point the board had two vocabularies and neither read.
//
// THIS IS HALF A MIRRORED PAIR. `android/.../ui/DerivedTint.kt` computes the same
// hash over the same key order, and the two must agree exactly or a tag is one colour
// on the phone and another in the browser. Same discipline as the checklist grammar's
// three implementations, and the same reason: a value that disagrees across surfaces
// is a bug you cannot unsee and cannot explain.
//
// The Kotlin side has a unit test pinning the fixture below. THIS SIDE HAS NO
// MECHANICAL GUARD — the frontend has no test runner, only `vue-tsc --noEmit`.
// If you change anything here, check it against the fixture by hand.
export const DERIVED_TINT_KEYS: readonly NoteColor[] = NOTE_COLOR_KEYS.filter(
(key) => key !== "default",
);
/**
* FNV-1a over the id's bytes, 32-bit.
*
* Chosen because both languages compute it identically in ten lines with no
* library. Explicitly NOT `String.hashCode()`: Kotlin's is specified but JS has
* no equivalent, and reimplementing Java's from memory in TypeScript is exactly
* how a mirror drifts.
*
* `& 0xff` is a no-op for the ASCII of a UUID, and is kept because it states the
* intent — this hashes BYTES, so the Kotlin side reading `id[i].code and 0xFF`
* is the same function rather than a coincidence.
*/
export function tintHash(id: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < id.length; i++) {
hash ^= id.charCodeAt(i) & 0xff;
// Math.imul, not `*`: JS numbers are doubles and a 32-bit overflow would be
// silently kept as precision instead of wrapping the way Kotlin's Int does.
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash >>> 0;
}
/** The colour a name maps to, stable for as long as the name is. Called with a
* label's lowercased name; `id` is the parameter's history, not its meaning. */
export function derivedTint(id: string): NoteColor {
return DERIVED_TINT_KEYS[tintHash(id) % DERIVED_TINT_KEYS.length];
}
/**
* The colour to paint a LABEL — its chip, and its `#tag` where it sits in the prose.
*
* Derived from the tag's NAME, not stored, when nobody has picked one. Every `#tag`
* ever typed is `default`: `notes/tags.py` mints one as `Label(owner_id=…, name=name)`
* with no colour, so it takes the column default. Without deriving, a board of tags
* would be a board of identical grey chips.
*
* DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing the original plan in #2965.
* That plan wanted a hashed colour written at each of the four places a label can be
* born — and named the risk itself: `find_or_create_label` is "easy to miss, and it
* is the common one", because most tags are born from typing `#grocery`, not from a
* management screen. Deriving has no mint points to miss, needs no backfill for the
* tags that already exist, and reuses a hash that is already written twice. The cost is
* that renaming a tag recolours it, which is defensible: the name IS the tag.
*
* An explicitly-picked colour is still stored and still wins, so tag colours stay
* editable exactly as asked.
*
* Lowercased because tags dedupe case-insensitively — `#Todo` renamed to `#todo` is
* the same tag and should not change colour. Both `toLowerCase` here and Kotlin's
* `lowercase()` are locale-independent, so the mirror holds.
*/
export function resolveLabelColor(label: { name: string; color?: string | null }): NoteColor {
const picked = label.color as NoteColor | undefined | null;
if (picked && picked !== "default" && KNOWN_COLORS.has(picked)) return picked;
if (!label.name) return "default";
return derivedTint(label.name.toLowerCase());
}
// Fixture — the same names and expected keys the Kotlin test asserts. Kept here as
// prose because there is nowhere on this side to assert it. If you change the hash or
// the key order, these must still hold on BOTH surfaces.
//
// The raw hash, over four UUIDs — ids no longer pick a colour, but they are what the
// hash itself is pinned by and the Kotlin test still asserts them:
//
// 00000000-0000-0000-0000-000000000000 0xbe478ed1 purple
// 11111111-1111-1111-1111-111111111111 0x3d75cc01 blue
// 6ba7b810-9dad-11d1-80b4-00c04fd430c8 0xf108e530 orange
// f47ac10b-58cc-4372-a567-0e02b2c3d479 0x5b651540 orange
//
// And the live path — a label, hashing its lowercased NAME:
//
// todo -> pink grocery -> blue work -> green home -> gray
// ideas -> green reading -> gray urgent -> red
//
// Note `work`/`ideas` and `home`/`reading` collide. Nine keys makes that unavoidable
// and it is not a bug: colour hints that two tags are distinct, it never claims two
// chips of one colour are the same tag. The chip's text is what says which tag it is.
// ---------------------------------------------------------------------------
// THE CARD SURFACE — one neutral, no hue, no note involved (M315).
//
// This used to be a function of the note: a palette class for a tagged one, a fill
// generated from the id for the rest. Both are gone. The operator's verdict after
// four passes at it — "my coloring attempt has failed and nothing looks right… we've
// tried a lot to make the color work and somehow it never seems to land" — and the
// diagnosis underneath it is that a card's fill was being asked to carry meaning it
// could not carry. Nine keys is too few to identify anything on a board of any size,
// and a generated fill identifies nothing by construction, so a coloured board taught
// the eye to read hue as significant and then handed it noise.
//
// COLOUR NOW LIVES ONLY ON THE TAG — the chip and the inline `#tag`, both of which sit
// on this one known background from here on. That is a smaller job done properly
// instead of a larger one done four times.
//
// The codebase had already made this argument about the card's EDGE, one level down:
// the hue-coded border came out as "a neutral line carries no information at all,
// which is exactly what lets it be structure instead of content". The fill is the same
// argument at the next size up.
//
// THE VALUES. `bg-white dark:bg-neutral-900` — which is exactly what `default` always
// was, and exactly what the note EDITOR panel has always been (NoteEditor.vue), so
// this is a collapse onto a surface both other surfaces already used rather than a
// new colour anybody has to like.
//
// Measured against the operator's constraint, "not the same color as their background
// but close to it":
//
// card vs board light #ffffff on #fafafa 1.04
// dark #171717 on #0a0a0a 1.10
// edge vs card light #b8b8b8 on #ffffff 1.98
// dark #404040 on #171717 1.73
// body vs card light #171717 on #ffffff 17.93 (needs 4.5)
// dark #fafafa on #171717 17.17
// muted vs card light #404040 on #ffffff 10.37
// dark #e5e5e5 on #171717 14.23
//
// The fill is deliberately the WEAKEST of those numbers. A card is not separated from
// the board by its fill and never was — the edge and the shadow do that, which is why
// 1.04 is enough and why it has to stay near 1: a fill that separated on its own would
// be a panel, and a board of panels is the wall this whole line of work started from.
export const NOTE_CARD_SURFACE = "bg-white dark:bg-neutral-900";
-4
View File
@@ -15,8 +15,6 @@ export function facetsFromQuery(q: LocationQuery): NoteFacets {
const f: NoteFacets = {};
const text = one(q.q);
if (text) f.q = text;
const color = one(q.color);
if (color) f.color = color;
if (labels.length) f.label = labels;
if (one(q.has_reminder) === "true") f.has_reminder = true;
if (one(q.has_attachment) === "true") f.has_attachment = true;
@@ -30,7 +28,6 @@ export function facetsFromQuery(q: LocationQuery): NoteFacets {
export function facetsToQuery(f: NoteFacets): LocationQueryRaw {
const q: LocationQueryRaw = {};
if (f.q) q.q = f.q;
if (f.color) q.color = f.color;
if (f.label?.length) q.label = f.label;
if (f.has_reminder) q.has_reminder = "true";
if (f.has_attachment) q.has_attachment = "true";
@@ -43,7 +40,6 @@ export function facetsToQuery(f: NoteFacets): LocationQueryRaw {
export function facetCount(f: NoteFacets): number {
let n = 0;
if (f.q) n++;
if (f.color) n++;
n += f.label?.length ?? 0;
if (f.has_reminder) n++;
if (f.has_attachment) n++;
+86 -3
View File
@@ -7,16 +7,30 @@
// a heading.
export interface InlineToken {
type: "text" | "bold" | "italic" | "code";
/** `tag` carries the NAME, without the leading `#` — it is both what gets looked up
* for a colour and what is rendered, so the renderer puts the `#` back. */
type: "text" | "bold" | "italic" | "code" | "tag";
value: string;
}
// Flat (non-discriminated) shape on purpose — keeps template type-checking simple.
export interface Block {
type: "p" | "h1" | "h2" | "h3" | "quote" | "ul" | "ol" | "pre";
type: "p" | "h1" | "h2" | "h3" | "quote" | "ul" | "ol" | "pre" | "task";
inline?: InlineToken[];
items?: InlineToken[][];
value?: string;
/** `task` only: one entry per `items` entry, parallel by position. */
tasks?: TaskMeta[];
}
export interface TaskMeta {
/** This item's ordinal among ALL task lines in the body, in document order.
* That is the id the server and the native clients address an item by, so a
* checkbox can be toggled straight from it. Counted across blocks, not within
* one, and unaffected by the card truncating the body — the card only ever
* drops lines from the END. */
index: number;
checked: boolean;
}
// Order matters: code is matched before emphasis so its contents aren't re-parsed;
@@ -25,7 +39,22 @@ export interface Block {
// `[[wiki-links]]` used to lead this alternation. They are gone (note 2897) — this is
// a capture-and-recall surface, and a linking system is organization. `[[text]]` now
// renders as the literal characters someone typed, which is what it always was.
const INLINE_RE = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)/g;
// `#tag` is LAST in the alternation and that is load-bearing twice over. JS tries
// alternatives left to right, so a `#tag` inside backticks is claimed by `code` first
// and stays literal — matching the core, where a fenced block's contents are code.
// And a tag is the one token here that is not delimiter-based, so it must not get a
// chance to start inside `**bold #x**`.
//
// The grammar MIRRORS `line_tags` in core/src/local/derive.rs, which is the definition:
// a `#` at a word boundary (the preceding character is neither a tag character nor
// another `#`, so `a#b` and `##x` are not tags), a letter immediately after it, then
// alphanumerics, `_` and `-`. Rust's `is_alphanumeric` is `Alphabetic | N`, hence the
// property escapes rather than `\w` — and hence the `u` flag.
//
// A heading cannot collide with this: `parseMarkdown` requires a space after the `#`s,
// which `#tag` by definition does not have.
const INLINE_RE =
/(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)|((?<![\p{Alphabetic}\p{N}_#-])#\p{Alphabetic}[\p{Alphabetic}\p{N}_-]*)/gu;
export function parseInline(text: string): InlineToken[] {
const tokens: InlineToken[] = [];
@@ -37,6 +66,7 @@ export function parseInline(text: string): InlineToken[] {
const raw = m[0];
if (m[1]) tokens.push({ type: "code", value: raw.slice(1, -1) });
else if (m[2]) tokens.push({ type: "bold", value: raw.slice(2, -2) });
else if (m[5]) tokens.push({ type: "tag", value: raw.slice(1) });
else tokens.push({ type: "italic", value: raw.slice(1, -1) });
last = m.index + raw.length;
}
@@ -44,11 +74,45 @@ export function parseInline(text: string): InlineToken[] {
return tokens;
}
// A checklist item: the third implementation of one grammar, alongside
// core/src/local/derive.rs and src/thoughtsync/notes/checklist.py. A difference
// between any two of them is a checklist that changes shape when it syncs (M304).
//
// `-` and `*` only, deliberately, even though the `ul` matcher below also takes `+`.
// The other two implementations do not take `+`, and one grammar in three places has
// to be one grammar; a `+ [ ] x` line renders as an ordinary bullet everywhere,
// which is at least consistent.
const TASK_RE = /^\s*[-*] +\[([ xX])\](?: +(.*))?$/;
export interface TaskLine {
checked: boolean;
text: string;
}
/** One task line's parts, or null when the line is prose.
*
* Exported so the EDITOR's block split (notes/blocks.ts) and this read-view parser
* agree by construction rather than by comment. One grammar, one matcher. */
export function parseTaskLine(line: string): TaskLine | null {
const m = TASK_RE.exec(line);
return m ? { checked: m[1] !== " ", text: m[2] ?? "" } : null;
}
/** One item as the body line that stores it, in canonical form — `- [x] `, lowercase,
* and no trailing space when the item is empty so a round trip does not grow it. */
export function renderTaskLine(text: string, checked: boolean): string {
const mark = checked ? "x" : " ";
return text ? `- [${mark}] ${text}` : `- [${mark}]`;
}
export function parseMarkdown(text: string): Block[] {
const lines = (text ?? "").split("\n");
const blocks: Block[] = [];
let paragraph: string[] = [];
let i = 0;
// Runs across the whole document, not per block, because that is what the item's
// id means everywhere else.
let taskIndex = 0;
const flushPara = () => {
if (paragraph.length) {
@@ -96,6 +160,25 @@ export function parseMarkdown(text: string): Block[] {
continue;
}
// Task list, BEFORE the plain bullet below — which would otherwise swallow
// `- [ ] x` as an ordinary list item and leave the brackets showing. Same
// ordering reason as `code` being matched before emphasis in INLINE_RE.
if (parseTaskLine(line)) {
flushPara();
const items: InlineToken[][] = [];
const tasks: TaskMeta[] = [];
while (i < lines.length) {
const task = parseTaskLine(lines[i]);
if (!task) break;
items.push(parseInline(task.text));
tasks.push({ index: taskIndex, checked: task.checked });
taskIndex++;
i++;
}
blocks.push({ type: "task", items, tasks });
continue;
}
// Unordered list: -, *, or + then a space.
if (/^\s*[-*+]\s+/.test(line)) {
flushPara();
+16
View File
@@ -24,6 +24,15 @@ const router = createRouter({
{ path: "timeline", name: "timeline", component: () => import("../views/TimelineView.vue") },
],
},
{
// The quick-capture window (#1899). Its own route because it is its own
// WINDOW — no shell, no nav, one field. Desktop only: there is no global
// hotkey in a browser tab and nothing to summon it.
path: "/capture",
name: "capture",
component: () => import("../views/CaptureView.vue"),
meta: { requiresAuth: true, requiresDesktop: true },
},
{
path: "/settings",
name: "settings",
@@ -89,6 +98,13 @@ router.beforeEach(async (to) => {
if (to.meta.requiresDesktop && !isDesktop()) {
return { name: "board" };
}
// The capture window is opened at `index.html?capture=1` rather than at
// `/capture`, because the bundled assets are served as files and a path with no
// file behind it 404s in the production build — it only routes under the dev
// server. A query string survives that, and this is where it becomes a route.
if (to.query.capture === "1" && to.name !== "capture") {
return { name: "capture" };
}
// Deliberately NOT applied to /login and /register: bouncing those on desktop
// would loop against the requiresAuth guard above the moment a session is
// missing. Nothing on the desktop navigates to them any more (AppShell's sign-out
+40 -10
View File
@@ -2,15 +2,38 @@ import { defineStore } from "pinia";
import { ref } from "vue";
import { repo } from "../adapters";
// The Android build this server can hand out. Absent — not null — when it has
// none, so `v-if` on it is the whole test; see client_dist.py.
export interface AndroidClient {
// One client build this server can hand out. Platforms it holds nothing for are
// ABSENT from the map rather than present-and-null, so a key test is the whole
// question; see client_dist.py.
//
// Named for its twin in `core/src/sync/client.rs`, which deserializes the same
// payload. That one is deliberately NARROWER — it only ever reads
// `/api/client/android`, so its `version_code` is an `i64` and it declares none of
// the fields below that Android does not use. Widening it to match this is not a
// tidy-up: every phone in the field runs the current shape.
export interface ClientRelease {
// The table row's id — "android", "linux-deb", "linux-appimage", "windows".
platform: string;
// What a person calls it, named for the DISTRO rather than the package format
// ("Debian / Ubuntu", not ".deb"). The server owns this wording so the five
// labels cannot drift apart across the surfaces that show them.
label: string;
version: string;
// What decides "is this newer". The name is for people and sorts like a string.
version_code: number;
//
// Not one type across platforms, deliberately: Android's is an integer because
// Android's own install gate compares one, and the desktop's is Tauri's semver
// key `1.0.<minutes>`. Nothing in this app compares them — the union is here so
// the shape is honest rather than to be read.
version_code: number | string;
size: number;
sha256: string;
// A PATH, never an absolute URL — the client joins it to the server it is
// already talking to.
url: string;
// Present only for the AppImage: the minisign signature the desktop updater
// checks before replacing the running binary.
signature?: string;
}
export interface PublicConfig {
@@ -20,7 +43,14 @@ export interface PublicConfig {
enable_url_unfurl: boolean;
// How many days a note survives in Trash before the server purges it. 0 = forever.
trash_retention_days: number;
android_client?: AndroidClient;
// Every client this server holds, keyed by platform id. Absent on a server that
// holds none, and absent on the desktop's own offline config — the Tauri build
// answers `config_get` locally and has no clients to hand out.
//
// `/api/config` also carries `android_client`, which is NOT declared here: it
// exists for phones in the field polling for their own update, not for this app,
// and reading it here would be a second path to the same fact.
clients?: Record<string, ClientRelease>;
}
// Public, unauthenticated app config (site name, whether signups are open).
@@ -33,9 +63,9 @@ export const useConfigStore = defineStore("config", () => {
// unreachable — and 30 is a safer stand-in than 0, since claiming "kept forever"
// when the server is actually purging is the wrong way to be wrong.
const trashRetentionDays = ref(30);
// Null until proven otherwise: a server with no APK, and an older server that
// never had the field, both correctly show no download.
const androidClient = ref<AndroidClient | null>(null);
// Empty until proven otherwise: a server with no clients, and an older server
// that never had the field, both correctly offer no downloads.
const clients = ref<Record<string, ClientRelease>>({});
const loaded = ref(false);
async function load(): Promise<void> {
@@ -47,7 +77,7 @@ export const useConfigStore = defineStore("config", () => {
version.value = cfg.version;
enableUrlUnfurl.value = cfg.enable_url_unfurl ?? true;
trashRetentionDays.value = cfg.trash_retention_days ?? 30;
androidClient.value = cfg.android_client ?? null;
clients.value = cfg.clients ?? {};
} catch {
// Keep defaults if the config endpoint is unreachable.
} finally {
@@ -66,7 +96,7 @@ export const useConfigStore = defineStore("config", () => {
version,
enableUrlUnfurl,
trashRetentionDays,
androidClient,
clients,
loaded,
load,
reload,
+29 -1
View File
@@ -33,7 +33,35 @@ export const useLabelsStore = defineStore("labels", () => {
}
async function rename(id: string, name: string): Promise<void> {
// Renaming onto a name another tag already holds MERGES the two — server-side
// and in the local store, identically. Detected from the LIST rather than from
// the response: the survivor is whichever row is older, so it may well be the
// one we asked to rename, and an id that still matches proves nothing happened.
const absorbing = items.value.find(
(lb) => lb.id !== id && lb.name.toLowerCase() === name.toLowerCase(),
);
if (absorbing) {
// A merge cannot be undone by repeating it, and here it is reachable by a
// typo in a text field — so it asks, the way deleting one does. The counts
// are named because "40 notes" is the part that makes the consequence real.
const mine = items.value.find((lb) => lb.id === id);
const confirmed = window.confirm(
`A tag called "${absorbing.name}" already exists.\n\n` +
`Renaming will MERGE these two into one tag named "${name}", carrying ` +
`every note from both (${mine?.count ?? 0} + ${absorbing.count ?? 0}). ` +
"The notes are kept; one of the two tags stops existing, and that cannot " +
"be undone.",
);
if (!confirmed) return;
}
const updated = await repo.labels.rename(id, name);
if (absorbing) {
// One row is gone and the survivor's count grew, and this response carries no
// count — reload rather than guess which of the two we are now holding.
await load();
return;
}
const idx = items.value.findIndex((lb) => lb.id === id);
// The single-label PATCH doesn't recompute the count — keep the one we have.
if (idx >= 0) items.value[idx] = { ...updated, count: items.value[idx].count };
@@ -53,7 +81,7 @@ export const useLabelsStore = defineStore("labels", () => {
// notes themselves survive; only the membership goes, which is the part people
// most need reassuring about.
const label = items.value.find((lb) => lb.id === id);
const subject = label ? `the label "${label.name}"` : "this label";
const subject = label ? `the tag "${label.name}"` : "this tag";
const confirmed = window.confirm(
`Delete ${subject}?\n\n` +
"It will be removed from every note that uses it, on every device you sync " +
+10 -16
View File
@@ -2,14 +2,12 @@ import { defineStore } from "pinia";
import { ref } from "vue";
import { repo } from "../adapters";
import { useUiStore } from "./ui";
import type { NoteColor } from "../notes/colors";
export type NoteView = "active" | "archived" | "trash";
// Combinable facet filters for the board (mirrors the GET /api/notes query + a saved
// view's stored params). All optional; empty = the plain, unfiltered board.
export interface NoteFacets {
q?: string;
color?: string;
label?: string[];
has_reminder?: boolean;
has_attachment?: boolean;
@@ -21,8 +19,13 @@ export interface NoteLabel {
id: string;
name: string;
color: string;
// True when this label is attached because of a #tag in the note body (kept in
// sync with the text); false = added manually via the picker.
// True when the label is backed by text STILL IN THE BODY — a `#tag` written
// mid-sentence, kept in sync with those words. False covers both a label added
// through the picker and a tag lifted off a line of its own (M311), which is why
// it is also what decides whether a chip can be removed with a cross.
//
// The card reads it the other way round: a true here means the body is already
// showing this tag, so the chip would be the second copy and is not drawn.
via_tag: boolean;
}
@@ -65,7 +68,6 @@ export interface Note {
// (server-derived). Every note has one, so every note has something to be called.
display_title: string;
body: string;
color: NoteColor;
position: number;
pinned: boolean;
archived: boolean;
@@ -131,11 +133,7 @@ export const useNotesStore = defineStore("notes", () => {
}
}
async function create(input: {
body: string;
color: NoteColor;
items?: string[];
}): Promise<Note> {
async function create(input: { body: string; items?: string[] }): Promise<Note> {
const note = await repo.notes.create(input);
reconcile(note);
return note;
@@ -143,9 +141,7 @@ export const useNotesStore = defineStore("notes", () => {
async function mutate(
id: string,
changes: Partial<
Pick<Note, "body" | "color" | "pinned" | "archived" | "remind_at" | "recurrence">
>,
changes: Partial<Pick<Note, "body" | "pinned" | "archived" | "remind_at" | "recurrence">>,
): Promise<void> {
reconcile(await repo.notes.update(id, changes));
}
@@ -156,10 +152,9 @@ export const useNotesStore = defineStore("notes", () => {
if (archived)
useUiStore().showToast("Note archived", { label: "Undo", run: () => void setArchived(id, false) });
};
const setColor = (id: string, color: NoteColor) => mutate(id, { color });
const setReminder = (id: string, remindAt: string | null) => mutate(id, { remind_at: remindAt });
const setRecurrence = (id: string, recurrence: string | null) => mutate(id, { recurrence });
const saveEdit = (id: string, changes: { body: string; color: NoteColor }) => mutate(id, changes);
const saveEdit = (id: string, changes: { body: string }) => mutate(id, changes);
async function completeReminder(id: string): Promise<void> {
reconcile(await repo.notes.completeReminder(id));
@@ -274,7 +269,6 @@ export const useNotesStore = defineStore("notes", () => {
create,
setPinned,
setArchived,
setColor,
setReminder,
setRecurrence,
completeReminder,
+29 -19
View File
@@ -143,25 +143,6 @@ body {
}
}
/* The per-card colour popover, anchored to whichever end of the card the action set
* currently occupies: it opens DOWNWARD from a floating top-corner pill, and UPWARD
* from a footer row, so in both cases it grows into the card rather than off it. */
.note-swatches {
position: absolute;
right: 0;
bottom: 100%;
margin-bottom: 0.375rem;
z-index: 20;
}
@media (hover: hover) {
.note-swatches {
top: 100%;
bottom: auto;
margin-top: 0.375rem;
margin-bottom: 0;
}
}
/* Board motion (M7). Defined once here rather than three times in BoardView's
* markup, because "how the board moves" is one idea even though the pinned, other
* and non-board grids are three TransitionGroups.
@@ -272,6 +253,35 @@ body {
@apply inline-flex min-h-[2.25rem] items-center justify-center px-3;
}
}
/* THE button shape — the ONE definition of it in the app.
*
* It lives here, in the components layer, rather than inside BaseButton.vue,
* because not every button in this app is a <button>. A DOWNLOAD has to be an
* anchor: these are 3-95 MB installers, only an <a> can carry an href, and the
* browser's own download manager handles that transfer better than anything the
* app would do by fetching to a blob. BaseButton cannot serve that case, and a
* second copy of its class list for anchors is how a page ends up with two
* kinds of primary button that drift apart.
*
* So: BaseButton.vue wears these, and so does any anchor that must read as a
* button. Neither owns the look.
*
* The `disabled:` variants are NOT here on purpose — an anchor has no
* :disabled. BaseButton adds them itself, which is exactly the split: shared
* where it is shared, local where the element differs.
*/
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm
font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand
focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50
dark:focus-visible:ring-offset-neutral-950;
}
.btn-primary {
@apply bg-brand text-neutral-900 shadow-sm hover:bg-brand-600 active:bg-brand-700;
}
.btn-ghost {
@apply text-neutral-700 hover:bg-neutral-200/70 dark:text-neutral-200 dark:hover:bg-neutral-800;
}
.nav-link {
@apply flex items-center gap-2 rounded-lg px-3 py-2 font-medium text-neutral-600 transition
hover:bg-neutral-200/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand
+6 -35
View File
@@ -5,6 +5,7 @@ import { useDevicesStore } from "../stores/devices";
import { useUiStore } from "../stores/ui";
import BaseButton from "../components/BaseButton.vue";
import BaseInput from "../components/BaseInput.vue";
import ClientDownloads from "../components/ClientDownloads.vue";
import Icon from "../components/Icon.vue";
import { isDesktop, desktop as desktopBridge, type IntegrationStatus } from "../desktop/bridge";
@@ -12,14 +13,10 @@ import { isDesktop, desktop as desktopBridge, type IntegrationStatus } from "../
// Android apps authenticate sync with a device bearer token issued here.
const devices = useDevicesStore();
const ui = useUiStore();
// The Android build this server holds, if it holds one. Null on a server with no
// APK — the card below is hidden rather than offering a download that 404s.
// Loaded here rather than in ClientDownloads: this view already awaits it, and a
// component that fetches its own config would race the one that does.
const config = useConfigStore();
function readableSize(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(0)} MB`;
}
const error = ref("");
const newName = ref("");
const creating = ref(false);
@@ -160,35 +157,9 @@ onMounted(() => {
</BaseButton>
</section>
<!-- The Android client this server hands out (hidden when it has none) -->
<section
v-if="config.androidClient"
class="mb-6 flex items-center justify-between gap-4 rounded-xl border border-neutral-200 p-4 dark:border-neutral-800"
>
<div class="min-w-0">
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100">Android app</p>
<p class="mt-0.5 text-xs text-neutral-400">
Version {{ config.androidClient.version }} ·
{{ readableSize(config.androidClient.size) }} · served by this server, so it always
speaks the same sync protocol.
</p>
</div>
<!-- A plain anchor, not BaseButton and not a fetch: this is 55 MB, and the
browser's own download manager handles it better than anything this app
would do with a blob. Styled to match BaseButton's primary variant,
which is a <button> and cannot carry an href. -->
<a
:href="config.androidClient.url"
:download="`thoughtsync-${config.androidClient.version}.apk`"
class="inline-flex shrink-0 items-center justify-center gap-2 rounded-lg bg-brand px-4 py-2.5
text-sm font-semibold text-neutral-900 shadow-sm transition hover:bg-brand-600
active:bg-brand-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand
focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50
dark:focus-visible:ring-offset-neutral-950"
>
Download
</a>
</section>
<!-- Every client this server holds, the one that fits this machine on top.
Hides itself when the server holds none. -->
<ClientDownloads />
<!-- One-time token reveal -->
<div
+15 -2
View File
@@ -11,7 +11,7 @@ import EmptyState from "../components/EmptyState.vue";
import FilterBar from "../components/FilterBar.vue";
import NoteGrid from "../components/NoteGrid.vue";
import NoteEditor from "../components/NoteEditor.vue";
import { isDesktop, sync as syncBridge } from "../desktop/bridge";
import { isDesktop, onCaptured, sync as syncBridge } from "../desktop/bridge";
const notes = useNotesStore();
const config = useConfigStore();
@@ -191,7 +191,7 @@ const emptyState = computed(() => {
if (currentView.value === "trash") return { title: "Trash is empty", subtitle: "Notes you delete land here first." };
if (currentView.value === "archived")
return { title: "Nothing archived", subtitle: "Archived notes are tucked away here." };
if (currentLabel.value) return { title: "No notes with this label", subtitle: "Tag a note to see it here." };
if (currentLabel.value) return { title: "No notes with this tag", subtitle: "Tag a note to see it here." };
// Says what "no account" actually means for the notes about to be written here.
// A new user otherwise has no way to tell whether this thing is storing their
// thoughts locally, silently waiting for a login, or quietly sending them off.
@@ -227,8 +227,21 @@ onMounted(() => {
.catch(() => {});
}
});
// A note written in the quick-capture window lands in the same SQLite file but a
// different Pinia store — this window has no way to know unless it is told.
// Registered as a promise because the listener is set up asynchronously, and
// unregistered on the way out so a board that has been navigated away from does
// not keep reloading itself.
let stopCaptureListener: (() => void) | null = null;
onMounted(() => {
void onCaptured(() => void reload()).then((stop) => {
stopCaptureListener = stop;
});
});
onBeforeUnmount(() => {
window.removeEventListener("keydown", onBoardKey);
stopCaptureListener?.();
ui.boardCardFocused = false;
});
watch([currentView, currentLabel, facetKey], reload);
+87
View File
@@ -0,0 +1,87 @@
<script setup lang="ts">
// The quick-capture window: one field, and two ways out.
//
// This runs in a SECOND Tauri window, summoned by a global hotkey over whatever
// the person was doing. Everything here is shaped by that: no shell, no nav, no
// board — a window that arrives uninvited has to be finishable in one gesture and
// leave nothing behind if it isn't.
import { nextTick, onMounted, ref } from "vue";
import { repo } from "../adapters";
import { capture } from "../desktop/bridge";
const body = ref("");
const field = ref<HTMLTextAreaElement | null>(null);
const saving = ref(false);
const error = ref("");
onMounted(async () => {
// Focused on arrival, and after a save. The whole feature is "press the keys and
// start typing" — a window that needs a click first has not saved anyone a step.
await nextTick();
field.value?.focus();
});
async function save() {
const content = body.value.trim();
// Nothing typed is not an error, it is a change of mind — the same reading the
// board takes of tapping + and walking away.
if (!content) {
void capture.done(false);
return;
}
saving.value = true;
error.value = "";
try {
await repo.notes.create({ body: content });
body.value = "";
await capture.done(true);
} catch {
// The window STAYS OPEN on failure, holding the text. Hiding it would throw
// away the only copy of something the person just wrote, to report a problem
// they could otherwise retry their way out of.
error.value = "Couldn't save that. Your text is still here — try again.";
} finally {
saving.value = false;
}
}
function dismiss() {
// The text is deliberately KEPT. The window is hidden rather than destroyed, so
// a capture interrupted by something more urgent is still there on the next
// press — which is the behaviour that makes it safe to press Escape.
void capture.done(false);
}
</script>
<template>
<div
class="flex h-screen w-screen flex-col gap-2 bg-neutral-50 p-3 text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100"
>
<textarea
ref="field"
v-model="body"
class="min-h-0 flex-1 resize-none rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900"
placeholder="Write it down…"
aria-label="New note"
@keydown.esc.prevent="dismiss"
@keydown.enter.ctrl.prevent="save"
@keydown.enter.meta.prevent="save"
/>
<p v-if="error" class="text-xs text-red-600 dark:text-red-400">{{ error }}</p>
<div class="flex items-center justify-between gap-3">
<!-- The shortcuts are written down rather than assumed: this window is seen
rarely and briefly, and it is the only place they are discoverable. -->
<p class="text-xs text-neutral-400">
<kbd>Ctrl</kbd>/<kbd></kbd> + <kbd>Enter</kbd> to save · <kbd>Esc</kbd> to dismiss
</p>
<div class="flex shrink-0 items-center gap-2">
<button type="button" class="btn btn-ghost" @click="dismiss">Cancel</button>
<button type="button" class="btn btn-primary" :disabled="saving" @click="save">
{{ saving ? "Saving" : "Save" }}
</button>
</div>
</div>
</div>
</template>
+12
View File
@@ -88,6 +88,18 @@ async function submit() {
>Create one</RouterLink
>
</p>
<!-- The build, on the one screen a person can reach WITHOUT an account.
"I can't sign in" is a bug report like any other and it needs a build
number; requiring a login to read one would withhold it from exactly the
people who cannot get past this page. `/api/config` is public, so this
costs nothing that was not already public (#3181). -->
<p
class="mt-8 select-all text-center text-[11px] text-neutral-400 dark:text-neutral-500"
:title="`ThoughtSync server build ${config.version || 'unknown'}`"
>
{{ config.version || "unknown" }}
</p>
</div>
</main>
</template>
+94
View File
@@ -5,8 +5,11 @@ import BaseButton from "../components/BaseButton.vue";
import BaseInput from "../components/BaseInput.vue";
import Icon from "../components/Icon.vue";
import {
SUGGESTED_CAPTURE_SHORTCUT,
capture as captureBridge,
sync as syncBridge,
updates as updateBridge,
type CaptureShortcut,
type Compatibility,
type ProbeResult,
type RevokeOutcome,
@@ -77,6 +80,31 @@ const checkedOnce = ref(false);
const updateAvailable = computed(() => !!update.value?.available);
// --- Quick capture -----------------------------------------------------------
// A desktop-local preference, so it lives here beside the update channel rather
// than in admin Settings: that screen is the SERVER's, and this is a property of
// this installation on this machine.
const shortcut = ref<CaptureShortcut>({ shortcut: "", registered: false });
const shortcutDraft = ref("");
const savingShortcut = ref(false);
const shortcutError = ref("");
async function saveShortcut(value: string) {
savingShortcut.value = true;
shortcutError.value = "";
try {
shortcut.value = await captureBridge.setShortcut(value);
shortcutDraft.value = shortcut.value.shortcut;
} catch (e) {
// The message comes from the core and names the actual reason — "something
// else is already using it" reads very differently from "that is not a
// shortcut this system understands", and both are things you can act on.
shortcutError.value = String((e as { message?: string }).message ?? e);
} finally {
savingShortcut.value = false;
}
}
async function checkUpdates() {
checking.value = true;
updateError.value = "";
@@ -126,6 +154,13 @@ async function refresh() {
// An older build without the update commands — leave the default showing
// rather than blocking the whole Sync screen on it.
}
try {
shortcut.value = await captureBridge.shortcut();
shortcutDraft.value = shortcut.value.shortcut;
} catch {
// Older build without the capture commands. Same reading as the channel
// above — show the default rather than block the screen.
}
try {
status.value = await syncBridge.status();
pending.value = await syncBridge.hasPending();
@@ -452,6 +487,65 @@ onMounted(refresh);
</form>
</template>
<!-- Quick capture. Outside the linked/unlinked split for the same reason as
updates: a hotkey that writes to the local store needs no server. -->
<section class="mt-10 border-t border-neutral-200 pt-8 dark:border-neutral-800">
<h2 class="text-sm font-semibold">Quick capture</h2>
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
A system-wide shortcut that opens a small window to write a note in, without
bringing this one forward.
</p>
<div class="mt-4 flex items-end gap-3">
<BaseInput
id="capture-shortcut"
v-model="shortcutDraft"
label="Shortcut"
:placeholder="SUGGESTED_CAPTURE_SHORTCUT"
class="flex-1"
/>
<BaseButton :loading="savingShortcut" @click="saveShortcut(shortcutDraft)">Save</BaseButton>
<BaseButton
v-if="shortcut.shortcut"
variant="ghost"
:loading="savingShortcut"
@click="saveShortcut('')"
>
Turn off
</BaseButton>
</div>
<p v-if="shortcutError" class="mt-2 text-sm text-red-600 dark:text-red-400">
{{ shortcutError }}
</p>
<!-- Stored and LIVE are reported separately because they can disagree: a
combination another app grabbed first is saved here and does nothing when
pressed, and saying only "your shortcut is X" would be a lie with a
keystroke attached. -->
<p
v-else-if="shortcut.shortcut && !shortcut.registered"
class="mt-2 text-sm text-amber-700 dark:text-amber-400"
>
{{ shortcut.shortcut }} is saved but isn't active something else on this
system is holding it. Try a different combination.
</p>
<p v-else-if="shortcut.registered" class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
Press {{ shortcut.shortcut }} anywhere to capture a note.
</p>
<p v-else class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
Off. There's no default on purpose — any combination picked for you is one
taken away from something else on your machine.
<button
type="button"
class="underline hover:text-neutral-700 dark:hover:text-neutral-300"
@click="saveShortcut(SUGGESTED_CAPTURE_SHORTCUT)"
>
Use {{ SUGGESTED_CAPTURE_SHORTCUT }}
</button>
</p>
</section>
<!-- Updates sit outside the linked/unlinked split on purpose: an install that
has never touched a server still updates itself. -->
<section class="mt-10 border-t border-neutral-200 pt-8 dark:border-neutral-800">
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env sh
#
# Collect every client this image should hand out, into one directory.
#
# fetch-clients.sh <dev|stable> <destdir>
#
# The server serves clients from `DATA_DIR/client/` or from the copy baked into the
# image (`client_dist.py`). This is what fills the second one. It runs in CI, right
# before `docker build`, and writes the FIXED filenames that module looks for.
#
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A `:dev` image serves dev clients;
# `:latest` serves stable ones. Passed in rather than derived here, because the
# caller is the thing that knows which image it is building.
#
# NEVER FAILS. A platform with nothing published means the server advertises
# nothing for it and the UI hides that download — a supported state, and the only
# one available before a platform's first build has ever published. Turning eight
# fetches into eight ways to redden an otherwise fine lane would be strictly worse
# than shipping an image that offers four clients instead of five.
#
# WHY THE VERSION IS FETCHED AND NOT DERIVED. The obvious shortcut is to run
# `version.sh display desktop` here — this job has the checkout, after all. It is
# wrong: 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. The size check in `client_dist.py` would not catch it, because
# the size IS measured from the real file — it would sail through and lie about the
# version only. So the version comes from the channel, beside the bytes it
# describes, and only `size`/`sha256` are measured here.
set -eu
channel="${1:?usage: fetch-clients.sh <dev|stable> <destdir>}"
dest="${2:?usage: fetch-clients.sh <dev|stable> <destdir>}"
case "$channel" in dev|stable) : ;; *)
echo "fetch-clients.sh: unknown channel '$channel'" >&2; exit 2 ;;
esac
SERVER="${GITHUB_SERVER_URL:-https://git.fabledsword.com}"
REPO="${GITHUB_REPOSITORY:-bvandeusen/thoughtsync}"
BASE="$SERVER/$REPO/releases/download/$channel"
mkdir -p "$dest"
# Authenticated when we have a token — these releases are private (issue 2091), so
# on this instance we always do. Anonymous still works against a public fork.
fetch() {
if [ -n "${GITHUB_TOKEN:-}" ]; then
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "$2" "$1"
else
curl -fsSL -o "$2" "$1"
fi
}
# One field out of a small flat JSON object. `grep`/`sed` rather than a parser
# because this runs in the CI image's busybox sh and adding a jq dependency to buy
# one string is not a trade worth making. The sidecars are written by us and are
# one level deep.
field() {
grep -oE "\"$2\"[[:space:]]*:[[:space:]]*\"?[^,\"}]+\"?" "$1" 2>/dev/null \
| head -1 | sed -E 's/.*:[[:space:]]*"?([^"]*)"?[[:space:]]*$/\1/'
}
bytes() { wc -c < "$1" | tr -d ' '; }
digest() { sha256sum "$1" | cut -d' ' -f1; }
# The sidecar shape `client_dist.py` reads. `size` and `sha256` are measured from
# the file that actually landed, so a truncated download cannot be described as a
# whole one.
sidecar() {
_file="$1"; _out="$2"; _name="$3"; _code="$4"
# `version_code` is QUOTED here, and that is not a slip. This function only ever
# writes DESKTOP sidecars, whose ordering key is Tauri's `1.0.<minutes>` — which
# unquoted is not valid JSON at all, so every sidecar this wrote would fail to
# parse and the server would advertise nothing. Android's sidecar is a different
# file, copied verbatim from its lane, and keeps its integer.
printf '{\n "version_name": "%s",\n "version_code": "%s",\n "size": %s,\n "sha256": "%s"\n}\n' \
"$_name" "$_code" "$(bytes "$_file")" "$(digest "$_file")" > "$_out"
}
echo "==> Collecting the $channel clients"
# --- Android -----------------------------------------------------------------
#
# Its sidecar is published whole by the Android lane — an APK keeps its version in
# a binary AXML manifest, so the values are recorded where they were already known.
# Copied verbatim rather than rebuilt here.
if fetch "$BASE/thoughtsync.apk" "$dest/thoughtsync.apk" &&
fetch "$BASE/thoughtsync-android.json" "$dest/thoughtsync-android.json"; then
echo " android $(field "$dest/thoughtsync-android.json" version_name)"
else
# Both or neither. Half a pair is worse than none: the server would read a
# sidecar describing an APK that is not there, or an APK it cannot state a
# version for.
echo "::warning::No Android client on the $channel channel — this image ships without one."
rm -f "$dest/thoughtsync.apk" "$dest/thoughtsync-android.json"
fi
# --- desktop -----------------------------------------------------------------
#
# One sidecar on the channel carries the version PAIR for all four bundles, because
# they are one build: `version_name` is what a person reads, `version_code` is the
# ordering key, and the key is also what the bundle filenames are stamped with.
# Written by `write-manifest.sh`, which is the step that speaks for what the channel
# serves.
bake_desktop() {
desk="$dest/.desktop-release.json"
if ! fetch "$BASE/thoughtsync-desktop.json" "$desk"; then
echo "::warning::No desktop release on the $channel channel — this image ships without desktop clients."
rm -f "$desk"
return 0
fi
name="$(field "$desk" version_name)"
key="$(field "$desk" version_code)"
rm -f "$desk"
if [ -z "$name" ] || [ -z "$key" ]; then
echo "::warning::The $channel desktop sidecar named no version — skipping desktop clients."
return 0
fi
echo " desktop $name (key $key)"
# Bundle filenames are stamped with the ORDERING KEY — what Tauri puts in them,
# and what `write-manifest.sh` already selects on. Constructed rather than
# discovered from the release's asset list: one shape, no JSON walk, and a name
# that does not resolve is caught by the fetch failing rather than by matching
# the wrong file.
#
# `<platform id>|<published name>|<name on disk>`
for row in \
"linux-deb|ThoughtSync_${key}_amd64.deb|thoughtsync.deb" \
"linux-pacman|thoughtsync-${key}-1-x86_64.pkg.tar.zst|thoughtsync.pkg.tar.zst" \
"linux-appimage|ThoughtSync_${key}_amd64.AppImage|thoughtsync.AppImage" \
"windows|ThoughtSync_${key}_x64-setup.exe|thoughtsync-setup.exe"
do
id="${row%%|*}"; rest="${row#*|}"
remote="${rest%%|*}"; local_name="${rest#*|}"
if ! fetch "$BASE/$remote" "$dest/$local_name"; then
echo "::warning::$channel has no $remote — this image ships without the $id client."
rm -f "$dest/$local_name"
continue
fi
# The AppImage is the only bundle that replaces itself in place, so the updater
# verifies a signature before it does. Without one it is not servable as an
# update, and `client_dist.py` treats it as absent rather than offering it
# unverifiable — so drop the bundle too rather than baking 95 MB nothing can use.
if [ "$id" = "linux-appimage" ]; then
if ! fetch "$BASE/$remote.sig" "$dest/$local_name.sig"; then
echo "::warning::$remote has no signature on $channel — dropping the AppImage."
rm -f "$dest/$local_name" "$dest/$local_name.sig"
continue
fi
fi
sidecar "$dest/$local_name" "$dest/thoughtsync-$id.json" "$name" "$key"
echo " $id $(bytes "$dest/$local_name") bytes"
done
}
bake_desktop
echo "==> Baked in:"
ls -l "$dest"
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env sh
#
# Refuse to publish a version lower than the one already on the channel.
#
# guard-forward.sh <desktop|android> <dev|stable>
# guard-forward.sh compare <a> <b> exit 0 iff a sorts below b
# guard-forward.sh published <artifact> <channel> print what the channel serves
#
# Note 3127 §6.3. Everything else in this milestone derives a number and trusts it;
# this is the one thing that checks the answer against reality before a user gets it.
#
# WHAT IT CATCHES that nothing else does:
#
# * A SQUASH OR REBASE MERGE (§6.2). Both rewrite the committer date, so `main`
# could stamp a value unrelated to the dev commit it merged. Rule 153 mandates
# plain merge commits — but that rule governs people, and a forge UI's squash
# button does not read it.
# * A REBUILD OF AN OLDER COMMIT. Commit time can go backwards; this is the entire
# mitigation for the desktop key's clock choice (step 4), and the thing to
# revisit first if this repo ever starts rebuilding old commits routinely.
# * CLOCK SKEW between runners, for a build-time key.
#
# What it does NOT catch, because something better does: a shallow clone. That is
# tested directly in `version.sh` via `--is-shallow-repository`, which needs no
# network and covers artifacts that have no published value to compare against.
#
# TOO-LOW IS THE UNRECOVERABLE DIRECTION. A version below what is published means
# every installed client reports "up to date" forever and there is no build you can
# ship to fix it — you have to get back ABOVE the bad number. That is #2183 and
# #2993's shared symptom, and it is why this fails the lane rather than warning.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SERVER="${GITHUB_SERVER_URL:-https://git.fabledsword.com}"
REPO="${GITHUB_REPOSITORY:-bvandeusen/thoughtsync}"
artifact="${1:?usage: guard-forward.sh <desktop|android> <dev|stable>}"
# True when $1 sorts strictly below $2, comparing NUMERICALLY per dot-segment.
#
# Not a string compare, which is the classic way to get this wrong: `1.0.10` sorts
# below `1.0.9` as text. A missing segment reads as 0, so `1.0` == `1.0.0`.
version_lt() {
_a="$1"; _b="$2"
while [ -n "$_a" ] || [ -n "$_b" ]; do
if [ "${_a%%.*}" = "$_a" ]; then _ah="$_a"; _at=""; else _ah="${_a%%.*}"; _at="${_a#*.}"; fi
if [ "${_b%%.*}" = "$_b" ]; then _bh="$_b"; _bt=""; else _bh="${_b%%.*}"; _bt="${_b#*.}"; fi
[ -n "$_ah" ] || _ah=0
[ -n "$_bh" ] || _bh=0
if [ "$_ah" -lt "$_bh" ]; then return 0; fi
if [ "$_ah" -gt "$_bh" ]; then return 1; fi
_a="$_at"; _b="$_bt"
done
return 1 # equal
}
# Auth if we have it, anonymous if not — the releases are public, but a token costs
# nothing and keeps this working if that ever changes.
#
# MISSING CURL IS FATAL, not empty. Every fetch here ends in `|| true` so a network
# blip reads as "nothing published yet" and passes — which is right for a genuinely
# empty channel and catastrophic for a runner image without curl, where it would
# silently turn the guard into a no-op that reports success on every build.
if ! command -v curl >/dev/null 2>&1; then
echo "guard-forward.sh: curl is not on PATH — refusing to run, because every" >&2
echo " lookup here would read as 'nothing published' and this" >&2
echo " guard would pass without checking anything." >&2
exit 1
fi
fetch() {
if [ -n "${GITHUB_TOKEN:-}" ]; then
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" "$1" 2>/dev/null || true
else
curl -fsSL "$1" 2>/dev/null || true
fi
}
# What the channel is serving, per artifact. ONE definition of where to look, shared
# with `should-build.sh` — the skip decision and the guard must agree about what is
# published, and two readers of one fact is how this repo keeps producing #2181-2183.
#
# EVERY LOOKUP HERE MUST SUCCEED EVEN WHEN IT FINDS NOTHING. That is what the `|| true`
# on each pipeline is for, and it is load-bearing rather than defensive noise.
#
# An empty channel is a REAL state this guard is written to pass — `[ -z "$published" ]`
# further down says so in as many words. But the value is captured as
# `published="$(published_for ...)"`, and under `set -e` a command substitution that
# exits non-zero kills the script before that branch is ever reached. Silently, too:
# everything the pipeline would have said went into the capture rather than the log.
#
# WHICH COMMAND THE PIPELINE HAPPENS TO END ON decides whether that fires, which is the
# part worth remembering. `sed` on empty input exits 0; `grep` exits 1. Three of these
# four lookups end in `sed` and were fine. The one that ends in `grep -oE '[0-9]+$'` —
# Android's version_code — was not, and it failed the whole Android lane on the first
# merge to `main` (run 4857): exit 1, no output, 0.16 seconds, on the one channel that
# had no APK published yet. Its three neighbours hid it until then.
published_for() {
case "$1" in
desktop)
# What the UPDATER reads. The manifest is the thing that decides whether a
# client is offered a build, so it is the authority on what is published.
{ fetch "$SERVER/$REPO/releases/download/$2/latest.json" \
| grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 \
| sed -E 's/.*"([^"]+)"$/\1/'; } || true
;;
android)
{ fetch "$SERVER/$REPO/releases/download/$2/thoughtsync-android.json" \
| grep -oE '"version_code"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 \
| grep -oE '[0-9]+$'; } || true
;;
esac
}
# The NAME the channel serves, which is the commit-derived value. Separate from
# `published_for` because the guard compares ordering KEYS and the skip decision
# compares identity — for Android those are different fields, and conflating them
# would make every build look like a change (the code is build-time; it always moves).
published_name() {
case "$1" in
desktop) published_for desktop "$2" ;;
android)
{ fetch "$SERVER/$REPO/releases/download/$2/thoughtsync-android.json" \
| grep -oE '"version_name"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 \
| sed -E 's/.*"([^"]+)"$/\1/'; } || true
;;
esac
}
# An explicit comparison mode, so the ordering logic is testable without a network
# and inspectable without a push. Read-only and bypasses nothing — it is the same
# function the guard itself uses, which is the point: a test of a reimplementation
# would prove nothing about the code that runs.
if [ "$artifact" = "compare" ]; then
a="${2:?usage: guard-forward.sh compare <a> <b>}"
b="${3:?usage: guard-forward.sh compare <a> <b>}"
if version_lt "$a" "$b"; then exit 0; else exit 1; fi
fi
if [ "$artifact" = "published" ]; then
a2="${2:?usage: guard-forward.sh published <artifact> <channel>}"
c2="${3:?usage: guard-forward.sh published <artifact> <channel>}"
published_name "$a2" "$c2"
exit 0
fi
channel="${2:?usage: guard-forward.sh <desktop|android> <dev|stable>}"
case "$artifact" in desktop|android) : ;; *)
echo "guard-forward.sh: unknown artifact '$artifact'" >&2; exit 2 ;;
esac
case "$channel" in dev|stable) : ;; *)
echo "guard-forward.sh: unknown channel '$channel'" >&2; exit 2 ;;
esac
case "$artifact" in
desktop)
derived="$(sh "$ROOT/packaging/version.sh" key desktop)"
# What the UPDATER reads, not what the release happens to hold — the manifest is
# the thing that decides whether a client is offered this build.
published="$(published_for desktop "$channel")"
# COMMIT time, so EQUALITY IS THE ORDINARY CASE: an unchanged source derives
# exactly what it derived last time, and `<=` would fail every no-change build.
# §6.3 says *strictly* less for exactly this reason.
strict=""
;;
android)
derived="$(sh "$ROOT/packaging/version.sh" key android)"
published="$(published_for android "$channel")"
# BUILD time, so equality is NOT ordinary — it means two builds landed in the
# same minute, and Android refuses to install an APK whose versionCode does not
# RISE. So this one requires strictly greater.
#
# If it ever fires, the cheap fix is seconds rather than minutes in version.sh
# (~210M today against Android's 2.1e9 ceiling, so ~60 years of headroom).
# Not done pre-emptively: the concurrency group cancels older runs on a branch,
# so two builds finishing in one minute needs concurrent runs on different
# branches, and the failure is a refused install rather than a stranded channel.
strict="yes"
;;
esac
if [ -z "$published" ]; then
# A channel with nothing on it yet — `stable` before its first merge, or a fresh
# repo. PASS: there is nothing to go backwards from. Failing here would block the
# very first publish to a channel, which is the one case where "lower than what is
# published" is meaningless.
echo "guard: $channel has no published $artifact version yet — nothing to compare."
echo "guard: publishing $derived."
exit 0
fi
echo "guard: $artifact on $channel — derived $derived, published $published"
if version_lt "$derived" "$published"; then
echo "" >&2
echo "GUARD FAILED: $derived is BELOW the published $published on $channel." >&2
echo "" >&2
echo " Publishing it would leave every installed client reporting 'up to date'" >&2
echo " forever, and no later build fixes that until one climbs back above the" >&2
echo " bad number. Do not force past this." >&2
echo "" >&2
echo " Usual causes (note 3127 §6.2, §6.3):" >&2
echo " - a squash or rebase merge rewrote the committer date" >&2
echo " - this build is a rebuild of an older commit" >&2
echo " - clock skew between runners (build-time keys)" >&2
exit 1
fi
if [ -n "$strict" ] && [ "$derived" = "$published" ]; then
echo "" >&2
echo "GUARD FAILED: $derived EQUALS the published $published on $channel." >&2
echo "" >&2
echo " Android requires versionCode to RISE; an equal one cannot be installed" >&2
echo " over what is already out there. Two builds landed in the same minute." >&2
exit 1
fi
echo "guard: ok — $derived may be published."
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env sh
#
# The markdown body for a release: what went live since the previous one.
#
# release-notes.sh <tag>
#
# A release BUILDS NOTHING now (M314 step 7). The merge to `main` already published
# `:latest`, `:<sha>` and both channel feeds, so a tag rebuilding that same source
# would produce identical artifacts and re-push `:<sha>` with different bytes —
# which rule 145 forbids even when the bytes match.
#
# So what is a release FOR? Note 3127 §5 answers it: the changelog. There are two
# halves to "what am I running" and the version answers only the first —
#
# which build is this the footer, /api/config, the APK's versionName
# what is in it that was not ← this
# in the one I ran last month
#
# DERIVED FROM GIT, not hand-maintained. A CHANGELOG.md drifts into being
# aspirational — it records what someone meant to ship. `git log` records what
# shipped, and cannot say otherwise.
set -eu
cd "$(git rev-parse --show-toplevel)"
tag="${1:?usage: release-notes.sh <tag>}"
# The previous release tag, by DATE rather than by name.
#
# `v*` only: this repo also carries `dev` and `stable` tags, which are the fixed-tag
# pointer releases the updater reads. They move constantly and are not releases in
# this sense; sorting them in would make "the previous release" mean whichever
# channel published most recently.
#
# Excludes the tag being described, so re-running on an existing tag still produces
# the range that tag covers rather than an empty one.
prev="$(git tag -l 'v*' --sort=-creatordate | grep -vxF "$tag" | head -1 || true)"
if [ -n "$prev" ]; then
range="$prev..$tag"
header="Changes since \`$prev\`."
else
# The first release. Everything is new, and listing the entire history would be
# noise — say so instead.
range="$tag"
header="First release."
fi
printf 'ThoughtSync %s\n\n%s\n\n' "$tag" "$header"
# `--no-merges`: a merge commit's subject is "Merge branch ..." and says nothing
# about what shipped. The commits it brought in are listed individually, which is
# what somebody reading this wants.
#
# `%s` alone, not `%s (%h)`: the sha is in the forge's own view of the release and
# a reader chasing a specific change clicks through rather than copying a hash out
# of prose.
# CAPPED, because an unbounded list is not a changelog — it is a wall.
#
# The first dated release spans everything since `v0.1.0` — 181 commits at the
# time of writing: nobody reads that, and burying twelve interesting changes in it is worse
# than not writing one. Later releases will be short and the cap will never bite.
#
# The most RECENT are kept, not the oldest, and the count of what was dropped is
# stated — a truncated list that does not say it is truncated is a lie.
CAP=60
total="$(git log --no-merges --format='%s' "$range" | wc -l | tr -d ' ')"
git log --no-merges --reverse --format='- %s' "$range" | tail -"$CAP"
if [ "$total" -gt "$CAP" ]; then
printf '\n_...and %s earlier commits in this range, omitted for length._\n' \
"$((total - CAP))"
fi
printf '\n'
printf '%s\n' "_No artifacts here. Builds reach users from \`main\`: the desktop and Android"
printf '%s\n' "channels and the server image all publish on merge, with no tag required. This"
printf '%s\n' "release is a bookmark — it names a moment and says what was in it._"
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env sh
#
# Does this artifact need building, or is the channel already serving this exact
# source? Prints `true` or `false`.
#
# should-build.sh <desktop|android> <dev|stable>
#
# Note 3127 §4, skip-if-exists — adapted, because §4 assumes a registry keyed by
# VERSION and rule 145 removed exactly that. There is no `:<version>` tag to ask
# about. What there IS, for both clients, is a channel that publishes the version it
# is serving, and that answers the same question: if the channel already serves what
# this source derives, the artifact would be byte-identical and there is nothing to
# build.
#
# WHAT THIS REPLACES, and why that matters more here than the cost saving: the
# `paths:` filters in the workflows were a SECOND, independent statement of each
# artifact's file set, hand-kept beside the one in `version.sh`. They disagreed
# within a day of the sets being written — `packaging/` was added to the sets and
# not to the filters, so the commit that fixed a derivation bug never ran on the two
# lanes it fixed (85ead4d). §3 warns about exactly this duplication; one definition
# with one reader is the fix, and the cost saving is a bonus.
#
# THE SERVER IS NOT LISTED HERE, DELIBERATELY. Its image build is ~15 seconds against
# 6 and 9 minutes for the clients, so there is little to save — and always building
# it is strictly better for a server that can face the internet, because it picks up
# `python:3.12-slim` base updates on every push. That is also why the base-image
# tension in §4 does not bite this project: the artifact most exposed to it never
# skips. The clients' bases are CI runner images, pinned deliberately.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
artifact="${1:?usage: should-build.sh <desktop|android> <dev|stable>}"
channel="${2:?usage: should-build.sh <desktop|android> <dev|stable>}"
case "$artifact" in desktop|android) : ;; *)
echo "should-build.sh: unknown artifact '$artifact'" >&2; exit 2 ;;
esac
case "$channel" in dev|stable) : ;; *)
echo "should-build.sh: unknown channel '$channel'" >&2; exit 2 ;;
esac
# The value that answers "is this the same code?" — which is not the same as the one
# the guard compares.
#
# desktop the ordering key IS the identity; one value, one clock.
# android the NAME. Its versionCode is build-time and moves every run, so
# comparing that would report a change on every push and never skip.
case "$artifact" in
desktop) derived="$(sh "$ROOT/packaging/version.sh" key desktop)" ;;
android) derived="$(sh "$ROOT/packaging/version.sh" display android)" ;;
esac
published="$(sh "$ROOT/packaging/guard-forward.sh" published "$artifact" "$channel")"
if [ -z "$published" ]; then
echo "should-build: $channel serves no $artifact yet — building." >&2
echo true
exit 0
fi
if [ "$derived" = "$published" ]; then
# UNCHANGED. The channel is already serving this exact source, so a build would
# produce the same artifact under the same name and republish it for nothing.
#
# Skipping is safe here in a way it would not be if anything pinned: there is no
# immutable tag to re-push with different bytes (rule 145 removed version tags),
# so the immutability argument in §4.2 does not apply and this stands on cost
# alone — which is the smaller, honest claim.
echo "should-build: $channel already serves $artifact $derived — skipping." >&2
echo false
exit 0
fi
echo "should-build: $artifact moved $published -> $derived — building." >&2
echo true
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env sh
#
# What version an artifact carries, derived from its OWN shipped files.
#
# Replaces desktop/packaging/build-version.sh, which was one generator feeding the
# desktop bundles AND the Android APK off `GITHUB_RUN_NUMBER`. A Kotlin-only commit
# re-versioned the desktop; a Rust-only commit re-versioned the phone. It read as
# tidy — one definition, no drift — which is exactly why it survived review. One
# definition of HOW to derive is right; one VALUE for unrelated artifacts is not.
# (Note 3127 §3, which cites this repo as its example of the failure.)
#
# Lives at the repo root, not under desktop/, because it now serves three artifacts
# and a shared thing filed under one consumer is how it ends up owned by that one.
#
# version.sh display <artifact> the human-readable version — 2026.08.28.1815
# version.sh key <artifact> the ordering key a comparator reads
# version.sh paths <artifact> the shipped file set (for tests and debugging)
#
# TWO VALUES, NOT ONE, and which you want depends on the question:
#
# "is this the same code?" -> display. A dev build and the main build of one
# commit read identically, because they ARE the
# same bytes (note 3127 §2, reason 4).
# "may this replace that?" -> key. What an updater or an install gate
# compares, and never shown to a person.
#
# The desktop needs both because Tauri's updater parses `latest.json`'s version with
# the semver crate, and `2026.08.28.1815` is not valid semver — four segments where
# the spec allows three, and `08` is a leading zero, which it forbids outright. A
# non-semver string does not sort low: the feed fails to DESERIALIZE and every client
# reports "no update available" forever. So the platform's field takes an opaque key
# and the display version lives beside it. See #3142's spike.
#
# WHY NOT A `-dev.N` PRERELEASE for the dev channel — carried over from the script
# this replaces, because it is a real finding and the reasoning is not obvious:
# a prerelease sorts BELOW the release it qualifies (`0.1.0-dev.5` < `0.1.0`), so a
# dev build could never be offered as an update to a tagged one, and Windows
# installer metadata wants a numeric X.Y.Z anyway. The channel goes in a sibling
# field, never in the version — note 3127 §7, and rule 149.
set -eu
# ANCHOR AT THE REPO ROOT BEFORE ANYTHING ELSE.
#
# `git log -- <paths>` resolves pathspecs relative to the CURRENT DIRECTORY, not to
# the repo root. Callers run from wherever suits them — the desktop build from
# `desktop/src-tauri`, the Android build from `android`, the manifest job from the
# root — so without this the same request answers differently per caller.
#
# It is not a tidy failure. Measured on run 4796, one push produced THREE versions:
# the desktop build (cwd `desktop/src-tauri`) said 1.0.3494522, while the pacman
# packager and the manifest job both said 1.0.3502131. The build's pathspec had
# matched `desktop/src-tauri/Cargo.toml` — a real file — so git returned the newest
# commit touching THAT, six days stale. Non-empty, so the guard below could not fire;
# the manifest then found no bundle matching its own answer and the lane went red for
# a reason two steps removed from the cause.
#
# The Android job failed loudly in the same run only because its pathspec happened to
# match nothing from `android/`. Same bug, louder symptom, pure luck.
cd "$(git rev-parse --show-toplevel)"
# A SHALLOW CLONE IS FATAL, and asked directly rather than inferred.
#
# Landmine §6.1: depth-1 sees one commit, so `git log -- <paths>` answers about
# whatever happens to be in that commit and the result is a too-LOW version — the
# unrecoverable direction, arrived at silently with every lane green.
#
# The empty-result guard below catches only the case where NOTHING matches. It missed
# the worse one: on run 4796 a partial match returned a real, six-days-stale answer.
# `--is-shallow-repository` tests the actual hazard instead of a symptom of it, costs
# no network, and covers every artifact including the ones with no published value to
# compare against.
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
echo "version.sh: this is a SHALLOW clone — any version derived here would be" >&2
echo " too low, silently. Add 'fetch-depth: 0' to the checkout." >&2
echo " (note 3127 §6.1)" >&2
exit 1
fi
# 2020-01-01T00:00:00Z. The counter epoch, and it must NEVER move: shifting it
# renumbers every artifact downwards, which is the one direction you cannot recover
# from (note 3127 §6.4).
EPOCH=1577836800
# --- the shipped file sets ---------------------------------------------------
#
# ONE definition, read by every consumer. The `paths:` filters in the three
# workflows are a second, independent statement of the same fact today; they come
# out in step 6 when skip-if-exists replaces them. Until then, a change here that is
# not mirrored there means a lane that does not fire — check both.
#
# Read off what actually PACKAGES each artifact, not off intuition. Miss a file and
# a stale build keeps its version; include one that does not ship and you re-version
# for nothing.
#
# THE BUILD DEFINITION IS IN THE SET, and it is the part that is easy to leave out.
# A workflow file is not "shipped" — but change a Gradle flag or a `cargo tauri
# build` argument and the bytes change while the source does not. Once step 6 skips
# a build whose version already exists, that combination serves the OLD artifact on
# a green run: exactly the "miss a file and a stale build keeps its version" failure,
# arriving through the build recipe rather than the source. Same reason `packaging/`
# is in every set: this script decides identity, so a change to it is a change to
# what each artifact claims to be.
paths_for() {
case "$1" in
# tauri's generate_context! embeds the BUILT frontend in the binary, so a
# frontend commit is a desktop change even though nothing under desktop/ moved.
desktop) echo "desktop core frontend Cargo.toml Cargo.lock .forgejo/workflows/desktop.yml packaging" ;;
# The .so is cross-compiled from core/ through uniffi.
android) echo "android core Cargo.toml Cargo.lock .forgejo/workflows/android.yml packaging" ;;
# BUNDLED ARTIFACT: the image bakes in the Android client (ci.yml fetches the APK
# from the channel release and copies it into the package). So the image's set
# must contain the APK's set — an APK-only change genuinely changes what this
# image ships. Note 3127 §3 names this trap; FC's web image embeds the extension
# the same way.
#
# The base images are NOT listed and do not need to be: `Dockerfile` is in the
# set, so pinning `FROM` by digest (step 6) puts the base inside the set for
# free. Resolving a digest at derive time would work too and is WRONG — it is an
# external lookup, which §7's corollary forbids because it makes the value depend
# on when it was computed.
server) echo "src frontend alembic alembic.ini Dockerfile pyproject.toml .forgejo/workflows/ci.yml android core Cargo.toml Cargo.lock .forgejo/workflows/android.yml packaging" ;;
*) echo "version.sh: unknown artifact '$1'" >&2; exit 2 ;;
esac
}
# Sets TS to the newest commit timestamp touching this artifact's files, or exits.
#
# EMPTY IS FATAL, deliberately. A shallow clone sees one commit and derives a
# too-low value with every lane green — the failure landmine §6.1 exists for, and
# the unrecoverable direction. Every job that calls this needs `fetch-depth: 0`;
# this is what turns forgetting it into a red lane instead of a stranded channel.
#
# SETS A GLOBAL RATHER THAN ECHOING, and that is not a style preference. Written as
# `$(commit_ts desktop)` the function runs in a SUBSHELL, so its `exit` ends only
# that subshell and the caller continues with an empty string. Measured before this
# was fixed: `key desktop` on a repo with no matching history printed the error to
# stderr and then emitted `1.0.-26297280` and exited ZERO. A guard that reports a
# problem and does not stop is worse than none — it looks like it is working.
resolve_ts() {
# Unquoted on purpose: the path list is several words.
# shellcheck disable=SC2046
TS="$(git log --format=%ct -1 HEAD -- $(paths_for "$1"))"
if [ -z "$TS" ]; then
echo "version.sh: no commit touches $1's file set — is this a shallow clone?" >&2
echo " (needs fetch-depth: 0; see note 3127 §6.1)" >&2
exit 1
fi
}
minutes_since_epoch() { echo $(( ($1 - EPOCH) / 60 )); }
what="${1:?usage: version.sh <display|key|paths> <desktop|android|server>}"
artifact="${2:?usage: version.sh <display|key|paths> <desktop|android|server>}"
# VALIDATED HERE, in the parent shell, and not left to `paths_for`'s default arm.
#
# Third instance of one trap in this script, so it is worth stating plainly: `exit`
# inside a function called as `$(...)` ends the SUBSHELL, not the script. `paths_for`
# is reached through `$(paths_for "$1")`, so its `exit 2` printed the error and
# returned an EMPTY pathspec — and an empty pathspec matches everything, so
# `version.sh display nope` answered `2026.08.28.0900` and exited 0. A confident
# version for an artifact that does not exist.
#
# The other two were the shallow-clone guard on the `key` path (emitted
# `1.0.-26297280`, exit 0) and the same guard on `display` (which failed only because
# `date` then choked on an empty string — luck, not design). Each was found by a
# different mechanism; none by reading the code. If you add a guard to this file,
# make sure it runs where the script does.
case "$artifact" in
desktop|android|server) : ;;
*)
echo "version.sh: unknown artifact '$artifact' (want desktop, android or server)" >&2
exit 2
;;
esac
case "$what" in
paths)
paths_for "$artifact"
;;
display)
# One shape for every human-readable version in this repo, and for the release
# tag: YYYY.MM.DD.HHMM, zero-padded, UTC (note 3127 §1). Padded so it sorts as
# text as well as numerically, and so two lanes cannot emit forms one character
# apart.
resolve_ts "$artifact"
date -u -d "@$TS" +%Y.%m.%d.%H%M
;;
key)
case "$artifact" in
desktop)
# COMMIT time. The desktop is a one-value system to Tauri — its comparator
# reads the version name — so this key is also what lands in bundle
# filenames and .deb metadata. Commit time buys the property in §2 reason
# (4): the last dev build before a PR and the main build from it are the
# same bytes and derive the same key, so the artifact is reused rather than
# rebuilt and re-signed under a new name.
#
# Commit time CAN go backwards (rebuild an older commit). The backwards
# guard in step 5 is the whole mitigation, and the desktop's failure there
# is soft: an update is not offered. Contrast Android below.
#
# `1.0.` and not `0.0.`: the minor must clear the installed `0.2.<run>` line
# or every dev user is stranded on "up to date" permanently. Checked against
# the live feed (0.2.466), not against what we thought we had published.
resolve_ts desktop
echo "1.0.$(minutes_since_epoch "$TS")"
;;
android)
# BUILD time, and the asymmetry with the desktop is deliberate. Android
# HARD-FAILS an install on a downgrade (INSTALL_FAILED_VERSION_DOWNGRADE)
# and leaves a channel you cannot get out of, so its key must be monotonic
# BY CONSTRUCTION rather than by a guard that runs in CI. Build time cannot
# go backwards; commit time can.
#
# An Int, which is what Android compares. ~3.5M today against a 2.1e9
# ceiling — roughly four thousand years of headroom.
minutes_since_epoch "$(date -u +%s)"
;;
server)
# NO ORDERING KEY. Nothing compares the server image: no updater, no install
# gate, and `:latest` is moved by the registry rather than chosen by a
# client. §2 is explicit that an artifact with nothing to compare needs only
# a name — do not add one because the other two have one.
echo "version.sh: the server has no ordering key; use 'display'" >&2
exit 2
;;
*) echo "version.sh: unknown artifact '$artifact'" >&2; exit 2 ;;
esac
;;
*)
echo "version.sh: unknown request '$what' (want display, key or paths)" >&2
exit 2
;;
esac
+13
View File
@@ -1,3 +1,16 @@
"""ThoughtSync — self-hosted personal thought-capture web app (FabledSword family)."""
# PACKAGING METADATA, and nothing else. Not the version any running server reports.
#
# A built image carries APP_VERSION in the environment, derived from the server's
# own shipped file set (packaging/version.sh); `app.py` reads that and reports an
# explicit "unknown" when it is absent, so this string never reaches a user and
# bumping it changes nothing anybody sees.
#
# It exists because a Python package needs a version and "unknown" is not a legal
# one here. It used to double as app.py's fallback, which meant a server run from a
# checkout confidently reported `0.2.0` — a real-looking version naming no build
# that exists. Note 3127 §5 is why that matters more than it reads: with version
# tags gone, a build's self-report is the only answer to "which build is this?",
# and there is nothing left to catch it lying.
__version__ = "0.2.0"
+18 -5
View File
@@ -11,7 +11,6 @@ from datetime import timedelta
from quart import Quart, jsonify, send_from_directory
from quart.sessions import SecureCookieSessionInterface
from . import __version__
from .auth import bp as auth_bp
from .client_dist import advertisement as client_advertisement, bp as client_bp
from .config import Config
@@ -64,7 +63,20 @@ def create_app() -> Quart:
# Ephemeral/env secret so the app (and DB-free unit tests) construct without a
# database. before_serving swaps in the real, DB-persisted key before serving.
app.secret_key = Config.secret_key_env() or secrets.token_urlsafe(48)
app.config["APP_VERSION"] = os.environ.get("APP_VERSION", __version__)
# The RUNNING build, or an explicit "unknown" — never the packaging fallback.
#
# This read `os.environ.get("APP_VERSION", __version__)`, so a server started
# from a checkout reported `0.2.0`: a real-looking version that names no build
# anybody could get. `__init__.py` already claimed the honest answer was
# "APP_VERSION being missing, which app.py already handles" — it did not, and a
# comment asserting a behaviour two files away from the code is how that stayed
# true-sounding for months.
#
# It matters more than it used to. Note 3127 §5 removed version tags, so this
# string is the only answer to "which build is this?" and nothing exists to
# contradict it when it is wrong. `__version__` stays where it belongs, as
# packaging metadata, which is the one place "unknown" is not a legal value.
app.config["APP_VERSION"] = os.environ.get("APP_VERSION") or "unknown"
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
# Auto-mark the session cookie Secure on HTTPS requests (see the interface above).
@@ -192,9 +204,10 @@ def create_app() -> Quart:
# linking — while it still has no token and possibly no account — to decide
# whether it can talk to this server, and which optional features to offer.
data.update(protocol_advertisement())
# Which Android client this server can hand out, if any. Absent rather than
# null when it has none, so the web UI hides the download instead of
# offering a button that 404s.
# Which CLIENTS this server can hand out, if any — the whole set under
# `clients`, plus the older `android_client` key that phones in the field
# still read. Absent rather than null when it has none, so the web UI hides
# a download instead of offering a button that 404s.
data.update(client_advertisement())
return jsonify(data)

Some files were not shown because too many files have changed in this diff Show More