#5927 "Adopt the family idea secret.Box": the SMTP password and the backup
bucket's secret key were kept as plain text in the settings table, so a copy
of the database alone could read them.
- New sealing.py: AES-GCM under an HKDF-derived key, each value bound to its
setting's name. The key is a random file, /var/inkwell/sealing.key (0600),
made on first use. It lives on the data volume, not in the database, which
is where the signing secret sits by default.
- set_settings seals a secret before storing it; get_setting opens it. A value
that no longer opens (key file lost) reads as not set, so Settings asks
for it again.
- At startup, credentials stored before this are sealed in place, so nothing
has to be re-entered. A data folder that can't be written never stops the
server; saving a password then answers with the reason.
- New dependency: cryptography (approved by the operator).
Also #1919 "Deprecate / remove the PWA": kept. Its service worker caches
only the offline page, so an installed copy always runs the live build. One
stale comment in sw.js is corrected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#5918 "Backup file": an uncompressed tar of per-table JSON Lines (primary-key
order, sorted keys) plus every attachment file, with fixed tar headers, a
dated name, and an atomic write through a dot-named .part file. Unchanged
rows are the same bytes every day, so restic off-site stores only the edits.
No credential ever enters a backup.
#5919 "Backup destinations": a folder, or an S3-compatible bucket through a
stdlib SigV4 put/list client (checked against AWS's published signatures),
with an upload limit. Test destination only lists, so an append-only key works.
#5920 "Backup job": daily at a chosen hour, Back up now, a 1/2/4 h retry
ladder, a one-hour deadline, an advisory lock, the last 30 runs, and an
overdue verdict on Settings -> Backups.
#5921 "Restore": python -m inkwell.restore FILE into an empty instance, an
integration test that backs up and restores, and docs/backups.md with
Fabled Archives as the worked example.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each email was a subject and a plain-text f-string at its call site.
mail_templates.py now holds one layout: the site name, a heading, the
message, a button for the link and a footer saying who sent it and why.
Every email (password reset, test, invite) is a function returning its
subject, text and HTML. mailer.send sends them as multipart/alternative,
text first.
The HTML is email-shaped: tables, inline styles, no images or fonts, and
every value from settings or a person escaped.
POST /api/invites takes send_email. It is refused before anything is made
when email is off or the invite names no address. The link is built from
the public address. A failed send leaves the invite standing with
email_error, so the admin still gets the link once. The invite form offers
"Email the link to …" when email is on and an address is filled in.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settings was every registry group, then invites, accounts, groups and
activity, in one scroll. It is now six tabs held in ?tab=:
- General: General, Notes, Attachments, Links.
- People: Access, Invites, Accounts, Groups.
- Email, with a line saying whether email is on and what it still needs.
- Security.
- Activity.
A registry group that no tab names lands on General, so it can't go missing.
Save saves only the current tab. Edits waiting on other tabs keep their
values and mark their tab with a dot. Leaving the page with unsaved edits
asks first.
Review fixes:
- On a phone, a field stacks under its label instead of overflowing beside
it.
- Descriptions are easier to read.
- The SMTP encryption choice shows readable names (choice_labels).
- The test-email button uses .btn-sm and is disabled while email is off.
- The accounts list is headed Accounts, not People, now that People is a
tab.
- The docs point at the new paths.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tags is the one list in the rail that grows without limit, so everything
with a fixed place goes above it and stays put.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tag row under the text was a copy of the note's labels taken when the
editor opened. A #tag in the body becomes a tag on the server's side of a
save, so the copy never learned of it until the note was reopened. Every
save now hands its note back, and the row is refreshed from it.
Typing # in a prose block or a checklist item opens the tags that already
exist: prefix matches first, busiest first. Arrows move, Enter or Tab puts
the tag in, Esc closes only the menu. The grammar and ranking live in
notes/tags.ts with tests, so the menu never offers a tag the server would
not derive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The label picker and the editor's tag row each wrote the same Text: the
label_from_tag string, labelSmall, onSurfaceVariant, 8dp start padding.
Chips.kt's FromTextMark() is now both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The web compared or keyed tag names with name.toLowerCase() at five sites:
the labels store's merge check, LabelPicker's create check, NoteCard's
colour map, and colors.ts twice. Android had three, plus TagsScreen's rename
dialog using equals(ignoreCase = true). Each now calls foldTag(name), which
says what the core's derive::fold and the server's labeling.named say.
Android's rename dialog now predicts a merge the same way the core decides
one. Per-character ignoreCase and full lowercasing disagree on letters like
İ, whose lowercase is two characters. LabelPicker's search filter keeps
its own lowercasing: that is search, not identity.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SQLite's lower() folds ASCII only, so a device could hold "Café" and
"CAFÉ" as two tags while the server held one. derive::fold (to_lowercase)
is now the one comparison. It is registered as the deterministic SQL function
fold() on every connection (schema::migrate), and find_or_create, the rename
clash, the pull clash and the unique index all use it. derive's push_unique
used eq_ignore_ascii_case and now folds the same way.
v14 merges pairs a device already holds before rebuilding the index. The
older row survives, as in rename_label. Memberships move with via_tag kept,
affected notes go dirty, and the merged-away row is a pending delete. It is
written out rather than calling store::merge_labels so the migration doesn't
depend on store code that later versions may change.
rusqlite gains its "functions" feature (operator-approved, 2026-10-08).
grammar.json gains "#café and #CAFÉ" -> ["café"], which all three
implementations run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Web: one counted(n, one, other) helper. The sync screen's three "(s)"
messages (attachments that didn't download, changes refused, files that
didn't upload) now say "1 file" / "2 files". The six hand-written ternaries
(SyncView x2, ImportNotes, GroupList, ShareDialog, LabelsModal) use it too.
Android: tags_count was two strings chosen in code, and is now <plurals>.
The delete confirmation read "It is on 1 notes … removed from all of them"
and now has a one form of its own.
Operator decision on #5371, 2026-10-08.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run 8849 watched the gate reject: the APK job failed, "Build the server
image" was skipped, ci.yml 8850 stood down, nothing was dispatched, and
:dev still resolves to package version 11662 (pushed by 8848). This push
checks the green direction.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Preference 172. ScrollSentinel watches the end of a cursor-paged list and
asks for the next page a screen early. It asks once per cursor, keeps going
when a page lands without pushing the end off screen, and stops on a failure
until Try again (an observer re-firing on a failure is a request loop). A
failed older page now gets its own error beside the retry, instead of
replacing the list's load error.
Adapted from fabledstash's ScrollSentinel (snippet 3760).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reverted by the next commit. Expect: build fails, server-image skipped,
no ci.yml dispatch, :dev unmoved.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
server-image ran under always(), so a failed core check or APK build still
dispatched ci.yml. Run 8839 published :<sha> for 0ecdbfe while its APK lane
(8835) was red. Rule 177: nothing publishes on red. With always() dropped,
the dispatch needs rust and build to succeed. A skipped decide still skips
it, as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fb95c9b moved the foreground latch into ui/ForegroundTransitions.kt, but
MainActivity lives one package up and imports what it uses from ui by name;
run 8835's compileDebugKotlin stopped on the two unresolved calls.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rename and setColor each found the label and replaced it, keeping the count
the single-label PATCH doesn't recompute; keepingCount(updated) is that.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ShareDialog's Share and NoteEditor's Add note / Close each spelled out the
same compact button, smaller than .btn. .btn-compact is its shape and
.btn-compact-primary its brand fill; Close keeps its neutral colours beside it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run 8830's Linux bundle sat on a rust-lld link whose 17 threads were all
parked on futexes until the runner's own 30-minute cap ended it. verify, the
Linux and Windows builds get 20 minutes (warm runs take ~4, and a cold one
compiles the tree in about two), the manifest 5. Kept under the runner's cap
so these are the limits that fire.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Five test modules wrote the same db() over memory_conn, and portable.rs
unwrapped it inline; local::test_db() is that. Four pull/push tests wrote the
same label INSERT; local::seed_label(conn, id, name, dirty) is it. Both are
cfg(test), beside memory_conn, as wire::sample_note is beside wire::Note.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SettingsView drew secret and plain text settings as two inputs differing only
in type, autocomplete and the saved-value placeholder; one input binds those.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
enable and pushSoon each set the CONNECTED constraint and the 30-second
exponential backoff; B.online() on WorkRequest.Builder sets both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_notes.py and derive.rs each kept the same six lines of prose that look
like a checklist item. Four were already task_lines cases in grammar.json;
the other two (empty brackets, no bullet) join them, so the web suite now
tests them too, and both hand lists go.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The source and channel fieldsets were the same markup over different options.
updateChoices holds each set with its legend, radio name and when it is
offered, and one v-for draws them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
revoke_self and revoke_device each loaded the caller's DeviceToken, deleted
it, committed and audited DEVICE_UNLINKED; _unlink_device(which) is that,
owner-scoped as before, with each route keeping only how it names the row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AccountView's device-token reveal was OneTimeLink's markup and copy handler
again. OneTimeLink becomes OneTimeSecret (value, note, noun = link | token),
and the account page shows a new token through it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RegisterView and ResetPasswordView each wrote the min-length check and its
message; config.passwordRefusal(password) is that check, the web's side of
auth._password_refusal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Web: GroupList's Person was adapters/repo's Member field for field, and
AccountList's Account was the session store's User; both now import them.
The bridge's Identity stays, as the Tauri mirror of the core's struct.
Server: auth._serialize_user and accounts' _serialize_account wrote the same
four fields. serialize.serialize_user is serialize_person plus is_admin, and
the account list adds created_at to it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Destination carried its English title as a field, and NoteTint its colour
name; every other word on screen is a string resource. Destination.title()
now reads nav_notes/reminders/archive/trash (a tag's destination keeps its
name), and NoteTint.label is a @StringRes. Error fallbacks raised in view
models and the install receiver stay as constants: they are made outside
composition, with no Context to read a resource from.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sharing.test.ts round-tripped shared=with_me, which facets.test.ts already
does with every facet; its unknown-value case moves there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
notes/__init__ re-exported 17 names so it could stand in for the old single
module. Only sync.py and test_notes.py used that, and each now imports from
the module that defines the name; __all__ and the nine imports __init__ held
only for it go. sync.py also drops sa_delete, func, live and NoteLabel,
unused since 8eff5f6 (#5382). The integration test's patch on
inkwell.notes.schedule_unfurls stays: the routes call it from there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FlushOnStop was ForegroundTransitions with only its ON_STOP half, so it goes
and the editor calls ForegroundTransitions(onBackground = flush); both halves
now default to nothing, dropping three onBackground = {}. AutomaticUpdate and
AutomaticSync each kept a wanted flag set on the way in and consumed in a
LaunchedEffect once ready; that latch is OnEachForeground(ready, onBackground,
act), with each caller's ready and its reason kept where they were.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sync/mod.rs listed 5 of its 9 modules; migrate's doc sat above the v9 SQL;
client.rs had items after its test module; the ffi's sync_now doc had fused
into client_update's; complete_reminder (ffi and EditorAction) still said
recurrence advancement was to come, though the core does it; NoteQuery.view
listed views the core never matched and claimed it validated. Test scratch
dirs drop the old ts-/iw- prefixes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>