useNoteList no longer names a Search view; the desktop adapter's M10.7 plan
gave way to sync under the local core; local.ts's sharing comment sat above
settings; AccountList and password_resets still said there was no mail path;
NoteEditor kept an orphan checklist-flag comment, a textarea comment from
before blocks, and the link-preview comment above the file picker; the
serialize docstring's growth plan is now what it holds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- notes/datetime.ts formatLocalDay had no caller; removed.
- style.css set body in two consecutive blocks; they are one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The installer's environment variables kept the ThoughtSync-era TS_ prefix
after the rename to Inkwell. They are now INKWELL_*, as is the
INKWELL_SERVER_DEFAULT line the server fills in when it serves the script.
The old names are not read any more; the operator approved the clean cut.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
update.rs read_marker(): read the installer's marker file, parse it, and
log one that doesn't parse. adopt_installer_channel and
adopt_installer_server each wrote that out; they already shared adopt().
normalize_server's doc now says why it stays apart from
compat::normalize_base_url: one tidies what a person types, the other
refuses anything odd in what a script wrote. The tauri.conf updater
endpoint stays: read_source already documents that it is never consulted,
and removing it is a config change CI would be the first to try.
rustfmt --check is clean in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- lib.rs MAIN_WINDOW: the "main" window label that the reminder worker,
the capture window and the shell each wrote, 5 sites in all.
- lib.rs now_ms(): the epoch-milliseconds clock that the reminder worker
and autosync's cycle stamp each computed. LastCycle.at_ms becomes i64,
the same number on the wire.
- integration.rs applications_dir(): the XDG launcher directory that the
entry path and the desktop-database refresh each built.
rustfmt --check is clean in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- LogTag.kt: the four tags the app logs under. Eight files each declared
one of them as a string.
- SNOOZE_HOUR/SNOOZE_DAY sit beside EditorAction.SnoozeReminder; the
notification's snooze uses SNOOZE_HOUR instead of its own 60.
- Reminders.at reads through ui.epochMillis, the parse the card and the
overdue check already use.
Checked with ktlint and detekt in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ErrorBanner.kt Banner(tintKey) { … } is the rounded, palette-tinted row
that ErrorBanner (red) and UpdateBanner (blue) each built. TintChip moves
to Chips.kt with CHIP_RADIUS, which keeps NoteCard.kt under detekt's
function count. Checked with ktlint and detekt in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
detekt's SpreadOperator flagged trimEnd(*TRAILING_PUNCTUATION) from
1c4bf56. trimEnd { it in TRAILING_PUNCTUATION } trims the same characters
without copying the array. Checked with detekt in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- BoardScreen TitleAndBody: the title plus quieter body that the empty
board and the store-unavailable screen each wrote.
- NoteCard TintChip: the one-line palette chip that the reminder and
shared-by chips each drew in full.
- TagsScreen Swatch: the colour dot that the tag row (tappable) and the
colour picker each built.
Kept: the label chips. Each uses its tag's ink and a bigger shape, not
the card's chip pair. Checked with ktlint in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TrashedNoteItems, PinItem and ArchiveItem (EditorChrome.kt) are the rows
that the editor's overflow and the board's long-press menu each built: a
trashed note's restore and delete-forever, and the pin and archive
toggles. Each menu keeps its own order and its owner-only rows. Checked
with ktlint in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Note.manualLabelIds (NoteAccess.kt): the tags attached by hand, which
the label picker, the chip's remove button and the board's create-label
each filtered out of note.labels.
- sharerName(note): who shared a note, or "Someone", which the shared-by
line and the card's chip each spelled out.
Checked with ktlint in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EditorPickers.kt Sheet(title, onDismiss, modifier, verticalArrangement):
a ModalBottomSheet holding a full-width column with the screen margin,
clear of the navigation bar, under SheetTitle. The filter, tag picker,
reminder and share sheets each built that. A site's extra (a scroll, ime
padding, bottom space) is now applied after the navigation-bar inset
rather than before it. The total inset is the same either way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Panel.kt BackButton(onClick, label): the IconButton + ArrowBack that the
tags, sync and editor top bars each built. Each keeps its own spoken
label. Checked with ktlint in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Panel.kt Hint(text, modifier): bodySmall in onSurfaceVariant. That is the
secondary line that the sync pairing form, the sync screen, the update card
and the share sheet each wrote as a full Text(...) at 14 sites. Sites that
add more than a modifier (the link preview's two-line clamp) stay as they
are. So does ShareSheet's own Muted, which is bodyMedium.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Panel.kt ConfirmDialog (moved from TagsScreen, where it was private):
the delete-forever dialog, the sync disconnect and the tag dialogs all
ask through it now.
- strings.xml: editor_cancel and tags_cancel were both "Cancel"; they are
one cancel string.
- NoteAccess carries the core's permission string (wire). The access
lookup, the share sheet's choices and the board's draft read it from
there instead of writing "owner"/"edit"/"view" again.
- hashtag(name): the #-prefixed tag name that TagsScreen, the card and the
editor chips each wrote.
Formatted and checked with ktlint in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ui/Failure.kt: Throwable.shownAs(fallback) and FALLBACK_ERROR. The
core's own message, else a fallback, which Share, Tags and Sync each
defined privately and Board and Update wrote inline.
- The five view-model factories use lifecycle's viewModelFactory { initializer }
instead of an unchecked-cast object each.
- BoardViewModel.beginSitting: the editingSession bump the four editor
openings each spelled out.
The fallback line stays an English constant, as it was: view models hold no
Context to read strings.xml. Formatted with ktlint in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- desktop/bridge.ts listen<T>(): the no-op-off-desktop event listener
that onSynced, onCaptured and onReminderDue each wrote.
- size.ts roughSize(): one decimal below 10, none above, for the client
download sizes (MB) and the storage line (GB).
- BaseModal's title prop draws the heading row and close button that
LabelsModal and ShareDialog each built.
- SyncView: switchChannel and switchSource share recheck(); the four
update-card radios render from two option lists.
Kept: the board's and the shell's is-typing guards (they ignore different
elements), the drawer's nav beside the palette's commands (one is a laid-
out list with tags between its entries, the other a command list), the
status pills (each colours differently), and plurals (the copy is the
operator's call, raised in #5371).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Accounts, groups, invites and activity each wrote the same first load:
clear the error, try, put the server's reason or a fallback in error,
and stop loading. useLoad (composables/useAction.ts) is that, and its
load() is also the retry.
Kept: ShareDialog and Settings. Their load sets loading back to true on a
reload, which the lists never did, so moving them would change what a
retry shows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ebbe4a6 converted the wrong try block in InviteList: the edit matched from
load()'s try down to revoke()'s catch, which left load() half-converted.
load() goes back to its own try/catch/finally. revoke() uses
toastOnFailure, as intended.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
composables/useAction.ts: toastOnFailure runs an action and toasts the
server's reason or a fallback; useAction adds the busy flag a button
waits on; useRowAction keeps the id of the row whose action is running.
Import, export, the menu entry, the integration prompt, sign out
elsewhere, the account reset link, the group actions (whose local act()
it replaces), invite revoke and sync disconnect each wrote that
try/catch/finally out.
Kept: AccountView's device revoke. It shows a fixed message rather than
the server's reason, and moving it would change the text. ShareDialog
shows its errors inline, not in a toast.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
16ab4a1's APK lane stopped at ktlint (chain-method-continuation) on the
one-line chain 1c4bf56 wrote. Reformatted with ktlint --format in the CI
image; no code change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign in, register, forgot and reset each wrote the same page: a centred
column, the app icon, a title, a subtitle, the form and a footer link. That
is now components/AuthLayout.vue, with the title as a prop and the
subtitle, the form, the footer and anything after it as slots. The footer
links wear the new .text-link class. Markup and classes are unchanged, so
the pages render as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
style.css gains the classes the views spelled out in full: .section-label
(17 sites), .hint (20), .form-error (13), .alert-error (5), .row-card (5),
.list-empty (5), .field (5), .page-shell (3), and the small row action
.btn-sm (4) / .btn-sm-danger (3). Only exact runs moved, so nothing renders
differently; spacing a site adds beyond a run stays a utility beside it.
Kept: the Reminders and Timeline small buttons. They carry no text colour
and inherit it, so putting them on .btn-sm would recolour them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- common.detach: the hold-a-reference-until-done task start that mailer and
unfurl_queue each wrote.
- common.expired_before: the retention window, now shared by trash and the
audit log (it moves out of retention.py, which imports audit).
- responses.too_many: the 429 with Retry-After from the credential throttle
and the client-download throttle.
- share_sync.revoke_lost: revoke whoever could see a note before and no
longer can, after a share or a group goes.
- serialize.serialize_person: a member as the directory, a share and a
group listing show them.
- groups_api._get_group: the path-id lookup four group routes wrote.
- settings.apply_session_ttl: the session lifetime set at boot and on save.
Kept: the attachment-id claim check (one query; each caller answers an id it
already holds differently), the strict UUID-list parses in reorder and
set_note_labels (distinct error messages), and the checklist-items loops
(one line each).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
notes.helpers._fetch_note is the parse-id, not-purged, gated select that
_get_owned, _get_visible and _get_editable each wrote out, and note_visible
is the viewer's visibility predicate that five note reads spelled in full.
labeling.named is the case-insensitive live-name match that tags, labels
(create and rename) and the sync push each wrote; how two tag names compare
is now said in one place (#5385 will change it there). sync._landed is the
flush, read-back-the-revision and reply that four push paths ended with.
The REST routes' commit-and-serialise tails stay: each is two lines, and
whether a route refreshes the row first differs by route.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
common.normalize_email is the trim-and-lowercase that sign-up, sign-in,
reset and invite each wrote inline. auth._unauthenticated is the 401 the
six signed-out paths returned, and auth._password_refusal is the
minimum-length check that register, reset and change repeated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The purge wrote out its own try/unlink/log next to unlink_media, which says the
same thing. It couldn't import it: unlink_media lived in the notes package, which
imports retention. unlink_media moves down to storage.py, the module about what
attachments occupy, and the purge, the delete route and sync all call it.
DRY pass #2, batch 4, F10 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
common.iso is the server's one way to put a datetime on the wire, yet the JSON
export and the importer's Markdown frontmatter wrote out the
x.isoformat() if x else None idiom it replaces, seven times. Same output.
DRY pass #2, batch 4, F10 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
responses.py is the app's one JSON error shape, yet invites, accounts, the SPA
fallback and the test-email route still built jsonify({"error": ...}) by hand,
and two parsed path ids with their own try/uuid.UUID. They now use json_error,
not_found and parse_uuid. The download limiter's 429 stays for F13, with its
Retry-After twin.
DRY pass #2, batch 4, F10 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server's MIN_PASSWORD_LEN was 8, and the web wrote 8 out five times: two
checks and three placeholders. The constant moves beside the other policy numbers
in settings.py (auth.py imports it), /api/config serves it as
min_password_length, and the config store hands it to Register, Reset and
Account. 8 stays only as the fallback until the config answers.
DRY pass #2, batch 3 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server unfurls what detect_urls finds, trailing .,;:!? trimmed, and files the
preview under that. The web and Android cards looked a lone link's preview up
under body.trim(), punctuation included, so a note reading
"https://example.com/a." never showed its card.
- grammar.json gains a urls section: what the server finds in a body, and the
link a lone-link note is filed under.
- The web's rule moves out of NoteCard into notes/links.ts loneUrl(); Android's
LinkPreviewRow gets the same loneUrl(); both trim like the server.
- The server and web suites run the cases; Android's JVM test pins them by hand,
as it does the tint.
Fixes#5399. DRY pass #2, batch 3 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The web already checked its tint keys against grammar.json; the server's
NOTE_COLORS, which normalize_color accepts, had no such guard. It is now the
fixture's hues plus "default". The core has no palette to check: it stores
whatever the UI (which only offers palette keys) or the server sends.
DRY pass #2, batch 3 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
daily/weekly/monthly/yearly was written out in the server (REMINDER_RECURRENCES),
the core (recur::RECURRENCES), the web editor's <option>s and Android's picker,
with nothing holding them together. grammar.json now has a recurrences list; the
server, core and web suites each check theirs against it, and the web's options
come from notes/recurrence.ts rather than the template. Android's picker pins the
list by hand with its localised labels, as it does the tint: its JVM tests do not
read the fixture.
DRY pass #2, batch 3 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server and the core each derived display_title and disagreed twice: the
server cut it at 200 characters and the core didn't, and the server split lines
with splitlines(), which also breaks on a lone \r or a U+2028, where the core and
every other reading of the grammar split on \n alone.
- grammar.json gains a display_titles section: blank lines, markers, an empty
item, \r\n, a lone \r, U+2028, and a 201-character line of 'é' (the cut is
characters, not bytes).
- derive::display_title and DISPLAY_TITLE_CAP are the core's half, moved next to
strip_marker. The server splits on "\n". Both suites run the cases.
Behaviour: a device now names a note with a first line over 200 characters the
way the web always has, and the server names a note containing a lone \r or a
U+2028 the way devices always have.
Fixes#5398. DRY pass #2, batch 3 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pull's note() and push's server_note each wrote out all nineteen fields of a
wire::Note. wire::sample_note(id, revision) is that note; push's version changes
only the body and attachments, by struct update.
DRY pass #2, batch 2, F9 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
blobs, store and portable each built a BlobStore in a temp directory their own
way: pid+tag twice, a uuid once. blobs::scratch(tag) is that, with a counter, so
two tests can never share a directory even if they pick the same tag. The
desktop's and ffi's temp-dir helpers stay, one per crate: sharing them would
need a test-util feature on the core crate.
DRY pass #2, batch 2, F9 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seven test modules each built a migrated in-memory store by hand: open, migrate,
and (in sharing) wrap it in a Db. local::memory_conn() is that, and
open_in_memory uses it too. Each module's db() is now one line, and the schema,
Connection and Mutex imports it needed are gone.
DRY pass #2, batch 2, F9 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Formatting only. portable::instant (from F5), and the ffi's link and unlink
(F2/F3), were laid out by hand without a toolchain; rustfmt splits each chain.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Db::conn is documented as the one way to take the lock, yet five production and
test sites reached past it with db.0.lock(): the startup summary, the desktop's
trash sweep and config_get, and tests in sharing and update. All five now call
conn().
DRY pass #2, batch 2, F8 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"owner"/"edit"/"view" and the entity names "note"/"label"/"attachment"/"preview"
were literals at about thirty sites across store, pull and push, including match
arms whose spelling had to agree with the rows a different module wrote.
models::access and models::entity now name them, and push names its two ops.
SQL text keeps its literals; Rust-side comparisons and writes read the constants.
DRY pass #2, batch 2, F7 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
schema::migrate was thirteen hand-copied blocks of "if version < N, apply,
stamp N". The versions are now a STEPS list (SQL, or code for v8). migrate walks
the list, applies each step a store hasn't had and stamps it. A new version is a
new entry at the end; there is no block to copy.
DRY pass #2, batch 2, F7 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
reminders and due_reminders each wrote out "owner's, not trashed, has a time".
The predicate is now one constant both queries read, carrying the reason a
shared note's reminder is not ours.
DRY pass #2, batch 2, F7 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rename UPDATE appeared twice: once for a merge's survivor and once for a
plain rename. The branch now picks which row is renamed, and one UPDATE follows.
DRY pass #2, batch 2, F7 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
should_snapshot and snapshot_revision each wrote out the SELECT that note_body
already is.
DRY pass #2, batch 2, F7 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
has_pending listed the same four predicates as pending_fingerprint (dirty notes,
dirty labels, pending deletes, unsent uploads) in a second query. It is now
pending_fingerprint(..)?.is_some(). Counting where LIMIT 1 would do costs nothing
on a local store.
DRY pass #2, batch 2, F7 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"application/octet-stream" was written out in the blob server, its test, the
store's normalize_mime and the wire default. All four now read OPAQUE_MIME; the
schema's SQL column default names the same string and says so.
DRY pass #2, batch 2, F7 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seven calls each spelled out send, describe the transport error, map a 401, and
refuse anything else as unexpected_status; two read the server's {"error"} words
the same way. send_raw (transport + a 401 whose meaning the caller names), send
(and anything but success is unexpected) and server_reason now hold those steps.
Each call keeps only what is its own: device_login's and fetch_identity's 401
wording, the release's 404 = none, sharing's 404 and refusal reason, upload's
retry split.
DRY pass #2, batch 2, F6 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The store's time format (RFC 3339, UTC, milliseconds, Z) is what makes lexical
order chronological. It was spelled out ten times as
to_rfc3339_opts(SecondsFormat::Millis, true), with two private now() copies
(store, pull). local::iso(t) and local::now() now hold it; store, pull, engine
and portable call them.
DRY pass #2, batch 2, F5 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"inkwell.db" and "blobs" were spelled out in the ffi and the desktop (whose copy
of DB_FILE sat in the crossover shim). The layout is the core's, the same on
every client, so the names are now core constants and both clients read them.
DRY pass #2, batch 1, F4 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop's sync_unlink and the ffi's unlink were both written out in full: try
the revoke, clear the link either way, and log the outcome. link::unlink(db, held)
now does that. Each client reads its link with state::credentials (with its seal)
before the await and passes it in.
DRY pass #2, batch 1, F3 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop's sync_link and the ffi's link_with_password/link_with_token were
the same steps written out twice: probe, refuse an incompatible server before
any credential is sent, log in or verify a pasted token, keep the link, and adopt
the server's trash retention. link::authenticate(url, Credential) does the
network half and link::store(conn, ..., seal) keeps it, sealed when the client
has a seal. Each client now only reads its input and picks its seal.
The desktop checks for a missing email/password before probing rather than after.
Same error, sooner.
DRY pass #2, batch 1, F2 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Five places read the server address and token straight from sync_state:
sharing, autosync, sync_unlink, update's download token, and the ffi. Reading it
raw is how Android came to send its sealed token to the share routes (#5381).
state::credentials(conn, seal) now holds that read. With a seal it opens the token
(open_token), and without one (the desktop keeps it plain) it returns it as stored.
Every site calls it.
DRY pass #2, batch 1, F1 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
installUpdate, and a failed channel or source switch, all fell back to "The
update check failed." Each now names what failed. A check that runs after a
successful switch keeps its own message.
Fixes#5387.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server clamped; the core took any i64, so 0 or less set a reminder in the past
and a huge value overflowed Duration::minutes. Every caller passes 60 or 1440
today, so this was latent. Both sides now name the range, SNOOZE_MAX_MINUTES,
and point at each other.
Fixes#5386.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each accepted any integer. A session length of 0 expired every session at once,
the admin's own included; an attachment limit above the 64 MB body ceiling
allowed files no request could carry, and a negative one refused every upload.
- session_ttl_days 1..3650, trash_retention_days 0..3650 (0 = keep), and
max_attachment_mb 1..MAX_BODY_MB-1, leaving room for the multipart envelope.
- MAX_BODY_MB is the one number app.py's MAX_CONTENT_LENGTH and that maximum
both read.
- A value stored before its bounds existed reads as the nearest bound.
Fixes#5384.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
get_note and get_attachment selected with the ACL inline and skipped the purged
filter, so a tombstone came back 200 (#2128 says a purged note reads as absent).
Both now go through _get_visible, which cannot skip it. Reorder's batch lookup
gains the same filter, so a stale id cannot write a place onto a tombstone.
Fixes#5383.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The web deleted a tag's row outright (delete, and merge's source), so the change
feed never carried it and linked devices kept the tag. A device's delete left a
tombstone that the web still listed, matched by name on create and rename, minted
#tags onto, and accepted in a picker.
- labeling.tombstone_label is the one way a tag is deleted: drop its links, set
purged_at. REST delete, merge and sync's op=delete all use it.
- labeling.live(owner) is the one definition of a tag that exists; every catalog
read uses it (list, lookup, create/rename matching, #tag minting, picker ids,
export, and sync's name-clash check).
- 0040: (owner_id, name) is unique among live tags only, so a tombstone gives its
name back and #grocery can be made again, on the web or from a device.
Fixes#5382.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Android has stored its device token sealed ("sealed:…") since 8592b83, and
core's sharing calls read the token from the store themselves. The ffi opened
it in credentials() and then threw the result away, so every Share-sheet
request went out as `Bearer sealed:…` and the server refused it.
The sharing functions now take the server address and token from the caller.
The ffi passes what credentials() opened; the desktop, which stores its token
plain, reads it through sharing::stored_link. A new ffi test serves one request
on a loopback port and checks the bearer token that arrives (#5381).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign-ins and failed sign-ins, accounts created and sign-ups refused,
password changes, resets and reset links, devices linked and unlinked,
invites made and revoked. Each is kept in `audit_events` with the address
it came from, for `audit_retention_days` (Settings → Security, 90 by
default, 0 keeps them forever), and listed newest first for admins under
Settings → Activity. The retention loop deletes older events.
`audit.record` writes in its own session, so a refusal is kept even when
the request's transaction rolls back. A failure to record is logged and
swallowed, never the reason a sign-in fails. A throttled attempt (429) is
not recorded: a row per refused request would make each request in a
flood cost a database write. Throttle trips stay in the app log.
Also: the storage-limit test puts `storage_quota_gb` back afterwards,
since settings outlive the per-test truncate.
#2939 §5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#2939 §4. max_attachment_mb capped one file, so any account could fill the
volume. The new Settings → Attachments → storage_quota_gb (default 5, 0 for
no limit) caps an account's total. That total is every attachment on every
note the account owns, trash included, since trashed files stay on disk until
emptied. Admins are exempt.
storage.upload_refusal is now the one check every upload makes: per file, then
per account. It is used by:
- the web upload route;
- the sync PUT, which judges the declared Content-Length before reading the
bytes;
- the importer, which learns the room left up front and refuses the whole
archive if its attachments don't fit (nothing is committed).
Over the limit is answered 507 Insufficient Storage, not 413. The core treats
a 4xx as a permanent refusal it never retries, and a 5xx as worth another try.
So a file refused for want of room syncs by itself once space is freed. The
cost is that an over-limit device re-sends that file each cycle until then.
GET /api/auth/storage returns used and limit, and the Account page shows it as
a Storage row ("1.2 GB of 5 GB used").
docs/public-hosting.md drops the quota gap and gains a section on the limit.
Its Android paragraph still said a public http:// address was only warned
about; since 1dd6fc1 it is refused, and the paragraph now says so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Scribe #3884. The dev channel's release tag moved from `dev` to `dev-rolling`
on 2026-09-10. Since then, the manifest job has also written latest.json to the
old `dev` release, so that desktop apps installed before the move could update
across. The operator has had two desktop installs and is fine reinstalling,
so the bridge goes. The old release and tag are deleted next, through the forge.
- desktop.yml: the BRIDGE_TAG=dev export is removed.
- write-manifest.sh: the TEMPORARY bridge block is removed.
- ci-requirements.md: the "Transitional" paragraph becomes a note that the tag
is gone, and that an app installed before 2026-09-10 reinstalls with
install.sh.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Family idea #5105, practice 12, as the operator chose on 2026-10-08: the token
is encrypted, and Android backup stays on.
The core:
- Adds a TokenSeal trait in sync/state.rs, with set_sealed_link and
open_token.
- A sealed token is stored as "sealed:<value>".
- A plain token, stored before this change or while sealing failed, is sealed
in place on its next read.
- A sealed token that won't open is dropped, and the server address and cursor
are kept, so the app reads as unlinked and asks to sign in again. That is
what happens after Android restores the app onto another phone.
- The desktop passes no seal and keeps storing the token as before.
The FFI:
- Exports TokenSeal as a uniffi foreign trait (seal_token / open_token, null
rather than an exception).
- Requires it in Inkwell's constructor, so there is no moment a token could be
stored unsealed.
- Routes credentials(), unlink() and store_link() through it.
Kotlin:
- KeystoreTokenSeal is AES-GCM under an Android Keystore key, using the
SealedBox framing from Minstrel's KeystoreSessionVault (Scribe snippet #5025),
with no new dependency.
- SealedBoxTest checks the framing on the JVM.
allowBackup stays true, and the manifest says why. An unlinked phone's notes
exist only on the phone, and the backup is their one other copy. The backup
carries a token nothing can open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Family idea #5105, practice 4. Either action signs the account out of every
other browser and unlinks every device. The browser that made the change stays
signed in.
- POST /api/auth/password needs the current password. A wrong one returns 403,
not 401, so this browser doesn't read as signed out, and it counts against the
sign-in throttle. A short new password returns 400.
- POST /api/auth/sign-out-elsewhere does the same sign-out without a password
change. Called from a device, it keeps that device linked.
- _sign_out_elsewhere moves session_epoch on and deletes device tokens. The
reset route now uses it too, keeping no device.
- The page is renamed from "Linked devices" to "Account", in the router title
and both nav entries. Its sections are Linked devices, Password (one short
line, then the form) and Sessions (a single "Sign out everywhere else" row in
the device rows' style), per preference 188: one line each, no paragraphs.
- docs/public-hosting.md says how sessions end, and why a browser session isn't
listed the way a device is: it is a signed cookie, ended by moving the epoch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Family idea #5105, practice 13, as the operator chose on 2026-10-08.
The check lives in the shared core, so the desktop and Android both get it.
compat::cleartext_allowed decides from the address text alone, with no DNS
lookup. It allows https:// always. It allows http:// to private, loopback,
link-local and CGNAT IPs (CGNAT covers Tailscale), to fc00::/7, fe80::/10 and
::1, to single-label names, and to LAN suffixes (.local, .lan, .home.arpa,
.ts.net and others).
The refusal runs in two places:
- probe, so linking stops before a password or token is sent;
- the top of run_cycle, so a device linked before this change stops syncing
with a message telling it to re-link, instead of sending its token on
every cycle.
The server is unchanged and never forces HTTPS (rule 94). Plain http:// on
a LAN links and syncs as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Family idea #5105, practice 8 (Scribe #5113), the operator's choice of
setup window over a setup code.
Before this, whoever reached /register first on an empty server became
its admin. On a fresh server at a public address, that could be a
stranger, and a new DNS name is found within minutes.
Now the first registration is refused once 30 minutes have passed since
the server started (create_app records STARTED_AT). A restart opens the
window again. It is a constant rather than a Setting, because there is no
admin yet to change one. Once an account exists it no longer matters, so
existing servers are unaffected. public-hosting.md says so, and two
integration tests cover both sides.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Family idea #5105, practice 9 (Scribe #5113). The body cap was already
there (MAX_CONTENT_LENGTH, 64 MiB). For timeouts, read from hypercorn
0.18's source:
- --keep-alive goes from 600 to 120. It is also the header timeout:
hypercorn marks a connection busy only once a whole request has
arrived, so a client dribbling headers was allowed ten minutes per
connection. 120 stays above Traefik's 90s backend idle timeout, so the
proxy never reuses a connection this server just closed.
- No --read-timeout, deliberately. It bounds every socket read, including
the whole of a streaming download while the client sends nothing, so it
would cut off an APK fetched slowly over mobile data.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Quart's send_file marks every file it sends Cache-Control: public. The app
download is behind a login, so a shared cache or proxy could have kept one
account's copy and handed it to anyone. send_artifact now marks it
private, as the attachment route already does. Family idea #5105,
practice 11 (Scribe #5113).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three of the idea's practices this project still owed (Scribe #5118):
Practice 3, CI fails on the wrong signer. The signing step printed the
certificate and went on. It now fails unless the APK has exactly one
signer and that signer is the release certificate (SHA-256 408a5835…,
pinned from run 8753). The steps that publish come after it in the same
job, so a wrongly signed build is never staged or published.
Practice 6, app downloads are throttled and carry a sha256 ETag.
- The download route counts per account and answers 429 with
Retry-After past the limit. The limit is a new Settings → Security
value, "App downloads per account per hour" (default 30), live like the
sign-in limits.
- The ETag is the sidecar's sha256, not Quart's mtime-and-path, so a
phone resuming a download across a redeploy is not told its partial
copy is stale. Quart's own ETag and conditional handling are off, and
the route runs the conditional pass after setting the ETag, so Range
and If-Range are judged against the content.
Practice 9, the update offer and debug builds.
- The install-permission notice re-reads the grant each time the app
comes back, as ReminderNotice does. Read once, it stayed up after
someone granted the permission in Settings and came back.
- A debuggable build says it can't update itself and checks for nothing.
Android would refuse the release-signed APK over a debug signature
anyway.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every APK so far carried a debug-profile libinkwell_ffi.so (opt-level 0),
because the workspace's release profile sets strip = true, which removes
the symbols uniffi's --library mode reads the interface from (run 4077).
The cargoNdk task now builds --release with two environment overrides, for
this build only:
- CARGO_PROFILE_RELEASE_STRIP=debuginfo keeps the symbol table. A release
build has no debug info, so this keeps symbols and nothing else.
- CARGO_PROFILE_RELEASE_PANIC=unwind keeps a core panic reaching Kotlin as
an exception, which the board shows as an error, not an app exit. Phones
have always had unwind, because debug unwinds, so this keeps what they
do.
Overrides rather than a profile of our own because cargo-ndk copies its -o
output from the release directory, and nothing says it handles another.
The desktop's binaries are unchanged. android.yml passes release on the
signed path; the unsigned debug path keeps debug.
Scribe #2810.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The board was one column below 640px, so on a phone it read as a list
while the app showed two. It is now two columns from the smallest width,
with an 8px gap there (16px from sm up). Two columns at 16px on a 390px
screen would leave ~170px cards. NoteCard's bottom margin, the vertical
half of the gap, tightens with it.
NoteGrid is the only place the board's columns are defined, so board,
search, timeline and reminders all change together.
Fill order is untouched. CSS columns fill top to bottom, so note #2 sits
under #1 rather than beside it, unlike Android. That is left until it has
been looked at on a phone, as #2950 recommends.
Scribe #2950.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
As on the web, the search box is now one more facet on the board you are
looking at, rather than a separate unfiltered search. "These words, in
notes tagged grocery" works: on the main board the text is sent to the
core together with the Filters sheet's tags, attachment and shared
switches, and the core ANDs them in list_notes. Archive, Trash and a
tag's view take the text alone. A search typed on Reminders, which is not
a board view, moves to the main board, as the web does.
The Filters chip stays while you search; it was hidden before, on the
mistaken claim that the web hides its filters too. Drag-to-reorder stays
off during a search, since a filtered subset can't be renumbered against
notes it can't see.
store::search and the FFI's search_notes had no other callers, and are
removed. They also searched archived notes and ignored pinning, which the
board's query does not.
Scribe #2942.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run 8731 failed checkAarMetadata: Coil 3.6.x requires compileSdk 37, and
it pulls in Compose 1.12, which requires AGP 9.1. This project is on
compileSdk 36 and AGP 9.0.1. Coil 3.5.0's AARs ask for 36, and its Compose
(JetBrains 1.11.1) is within this project's BOM (Compose 1.11.2).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The card draws the linked page's og:image in a strip down its left edge,
cropped to the card's height: 96dp full, 48dp compact, matching the web's
w-24 and w-12. The image is remote, so the phone fetches it from whatever
host the link points at, exactly as a browser does for the web card. The
operator chose that parity (Scribe #3307).
The image is Coil 3's AsyncImage, with OkHttp as its fetcher. Coil's disk
cache means a card scrolled past twice costs one download. When there is
no image, or it fails to load, nothing is drawn rather than an empty box,
and the card is the text card it was before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Milestone 325 step 6 (Scribe #3254).
The server publishes its AppImage in the updater's own format at
/api/client/linux-appimage/update.json: the ordering key as `version`, the
signature, and an absolute download URL built on the host that was asked,
so the token the updater attaches goes nowhere else. Unsigned platforms and
a server with no AppImage 404.
The desktop's update source is now Fabled-Git (and its channel) or one
server:
- `read_source` is the one reader. The installer's `install-server` marker
feeds the `update_server` pref once per new value, exactly as the channel
marker feeds its pref; tauri.conf.json's endpoint is never consulted.
- From a server, the check and the download carry the sync link's token
when the app is linked to that same server. Without one the update shows
and says to link rather than offering a button that 401s.
- A server with no build says so. A 404 is "up to date" only on the forge,
where it means an unpublished channel.
- Sync → App updates offers the source once there is a server to offer (the
chosen one, or the linked one), and only shows the channel for the forge.
The trust anchor does not move: whatever the source, the updater verifies
the AppImage against the public key built into the app.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Milestone 325 step 5 (Scribe #3253).
curl -fsSL https://notes.example.com/install.sh | sh
The server serves the installer at /install.sh with its own address written
into it (installer.py). Settings → Public address when set, the request's own
address otherwise. The substitution is one variable, given a value that has
passed a strict shape check, and the script checks it again; an address that
cannot pass makes the route refuse rather than serve a script pointed
elsewhere. `public_url` joins the live settings cache so the route needs no
database.
From a server, the script:
- resolves each Linux bundle from the public /api/client/<platform>, and
builds the download URL from the platform id rather than reading it from
the reply;
- asks for a device token (from the terminal, since stdin is the script),
or takes TS_TOKEN, and sends it from a file rather than the command line;
- checks the sha256 the server published before anything installs;
- revokes a prompted token once the download is done;
- records `install-server` for the updater (step 6) instead of the channel.
The forge path is unchanged, and stays the default for the copy the forge
serves. The Account page's downloads card shows the one-line command
whenever the server holds a Linux client.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run 8693's detekt failed both on CyclomaticComplexMethod (17 and 15 against
15) after the filters and drag-to-reorder landed.
- BoardState now carries `filterable` and `reorderable`, so the board's
rules for when the filter row shows and when a card can be carried live
with the state they read instead of as boolean chains in the screen.
- NoteCard's contents (tags, body, links, attachments, reminder, sharing)
move to their own CardContents composable, leaving NoteCard the gestures,
the frame and the menu.
No behaviour change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Filters: a chip under the search bar opens a sheet with Has attachment, Shared
with me and tags (a note must carry all of them), applied as they are tapped,
with a count on the chip and a Clear beside it. Only the main board filters and
search spans everything, as on the web; opening another view starts it
unfiltered, a deleted tag drops out of the filters as it does from the lens, and
an empty filtered board says so.
Reorder: hold a card, then move it. The hold is the long press that opens the
card's menu, which closes as the card starts to move; lifting without moving
leaves the menu as before, and moving before the hold is a scroll. Cards trade
places live and the drop writes the order through the core's reorder, newly
exposed over the ffi as reorder_notes. Only on the plain main board, and only on
the same side of the pinned line, since the store sorts pinned first.
#5313.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vue/test-utils and jsdom as dev dependencies, jsdom chosen per file with the
vitest environment comment so the existing unit tests stay on node. The dialog's
repo.shares is faked; the tests cover offering everyone, sharing with a person
and a group, changing and removing a share (and the board's shared flag that
follows), a refused share keeping the choice, the load retry, and the
single-person instance. #5313.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The integration lookup read the job's id from /etc/hostname, which holds only
while the runner leaves the hostname as the container id. Steward's fix (#5104)
reads it from the /etc/hostname bind mount's path in /proc/self/mountinfo and
falls back to the hostname, so one recipe now serves every repo (#5313).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It proved the APK gate (#5237): run 8667 failed at the core's tests, skipped
the APK job and still dispatched the server image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The step added in 42db4cd ran cargo on the Android image, which has OpenSSL
only for the Android targets; the host build died at openssl-sys (run 8663)
before reaching a test. The core's clippy and tests are now a 'rust' job on
ci-tauri, the image desktop's verify runs them on, and the APK job needs it.
The server-image dispatch moves to its own job: it was a step inside the APK
job, and a skipped job runs no steps, so failing core checks would have
silently stopped the server image too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
android.yml never ran cargo, so a core test that failed in desktop.yml's
verify job stopped the desktop installers and not the APK, which links the
same core through android/ffi (#5237). The Kotlin + Rust job now runs clippy
and the tests for inkwell-core and inkwell-ffi before anything is assembled
or published.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run 8653 failed at 'alembic upgrade head' with a password error: two
integration jobs were on the runner at once, and the name=integration
filter took the other one's database (#5312). The lookup is now scoped to
this job's GITEA-ACTIONS-TASK-<id>- prefix, read from the job container's
own name, and requires exactly one match.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The board was Fixed(2) at every width, so a tablet showed two wide columns
where the web shows three or four (#5311). The column count now follows the
web's NoteGrid breakpoints on the window's width: three from 1024dp, four
from 1280dp. A phone keeps two.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step 18 of the audit follow-through (#5180), on the operator's decisions.
Inkwell is for capture and recall (note 2897), and these three duplicated a
surface that does the job already:
- Saved views. They lived only on the web; the desktop kept its own set that
never synced, and Android had none. Tags in the drawer already give
one-click recall. Gone from the server (routes, model, migration 0038 drops
the table), the core (store functions, schema v13 drops its table), the
desktop commands, the web adapters, the drawer's Views list and the
"Save view" link.
- The "Has reminder" facet. The Reminders page lists them, sorted by due.
- The FilterBar's "Created" range. Timeline is the date lens and keeps the
created_after/created_before query it builds from local days, which also
retires the UTC/local-day disagreement between the two (B3).
An old link that still carries the removed keys opens the plain board; a
web test pins that.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the audit (#5179). Ten comment blocks narrated how the code got here:
milestone numbers, earlier values, the operator's verdict on an old design.
Each now says what the code does and why, and the history stays in git,
Scribe and docs/sync.md. The protocol-version comment in sync.py points at
docs/sync.md's policy section, which already lists every bump.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the audit (#5179, web half).
- NoteActions: share, pin, archive and trash (restore and delete forever
when trashed), as both the card and the editor offer them. The editor's
history toggle goes in its slot, and it closes on `acted`.
- ReminderActions: the Done / 1h / 1d chips on the card and in the editor,
which are now the same chips.
- PageHeader: the back-to-board header that Settings, Sync and Linked
devices each wrote out, now with a `back` icon from the shared set.
- notes/datetime: formatShortDateTime (was formatReminder and the editor's
revLabel) and formatDateTime (the two `fmt` copies).
- notes/colors: labelDotClasses (the sidebar's and the tag manager's
labelDot).
- Drawer links use exact-active-class instead of route.name ternaries.
- BoardView binds its three grids from one gridBinds object.
- Settings goes through repo.settings (rest, plus a local adapter that
answers "needs a server") and shows load failures through AsyncState.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the audit (#5179, core and desktop half).
- Every `db.0.lock().map_err(|e| e.to_string())?` (about 50 sites in core and
the desktop) is now `db.conn()?`. The few sites that deliberately handle
a poisoned lock differently, and the tests, keep their own spelling.
- push::Change derives Default, so its four constructors name only the
fields they set.
- store: list_notes, reminders, titles and search share notes_where (ids
from a query, each loaded through load_note). Labels share
LABEL_SELECT/label_row, and saved filters share
SAVED_FILTER_SELECT/saved_filter_row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the audit (#5179, server half).
- auth: login and device-login share _check_credentials (dummy hash for a
missing account, throttle bookkeeping, the failure log line) and
_bad_credentials.
- settings uses common.coerce_bool. Its private copy differed only in
treating a non-string as its truthiness, which the shared one now does.
- notes: create and import share helpers.top_position.
- auth, settings_api, sync and client_dist return errors through
responses.json_error / not_found, and parse ids with parse_uuid.
- sync: push replies are built by _result(id, entity, status, **extra).
Already merged by earlier steps, so nothing to do here: attachment storage
(store_attachment), the preview upsert (only unfurl_queue writes one now),
and _serialize_note (delegates to _serialize_notes).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the audit (#5178). Each was unreachable from every client:
- Checklist add-item and delete-item: REST POST /items and DELETE /items/<id>,
the Tauri commands, the store, rest and local adapters, the core's
add_item/delete_item, set_item_text and remove_item, and the FFI exports.
Adding, rewording and removing an item are body edits in every editor. The
checked toggle stays, and its rewriter is simpler without the drop branch.
- Manual unfurl: POST /unfurl and its adapters. Previews arrive in the
background after a save (unfurl_queue).
- The /api/config `android_client` key, android_release() and the
APK_NAME/MANIFEST_NAME aliases. Phones poll /api/client/android.
- users.email_verified and users.avatar_path (migration 0037). Nothing set
the first or read the second; the SMTP reset never checked verification.
- derive::extract_tags (only tests used it; the shared fixture now runs
through extract_tag_spans), the unused check and link icons, and the
unused editor_add_item string.
- The blob scheme is renamed tsblob -> inkblob. URLs are built as notes are
read, so nothing stored carries the old one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Share sheet lists groups after people, shows a group share as its name and
how many are in it, and shares through the FFI's ShareTarget.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Share dialog lists people and groups in one picker and shows a group share
as its name and member count. Settings gains a Groups section for the admin:
create, rename, delete, and add or remove people.
The core client reads the directory's groups and group shares (ShareTarget:
a member or a group); the desktop command takes user_id or group_id.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/api/groups (admin) creates, renames and deletes groups and adds or removes
members. The member directory lists every group, and a share may name a
group_id instead of a user_id; a note's shares answer with `member` or `group`.
A note shared with a group reaches whoever is in it now, so membership is what
the feed follows: joining grants each of the group's notes to the new member's
devices, and leaving (or the group being deleted) revokes them unless a direct
share or another group still reaches that person. recipients() never counts the
note's own owner, who may sit in a group it is shared with.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The card's long-press menu and the editor's overflow now open on shared notes
with Pin and Archive; Labels, Share and Move to trash stay the owner's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>