#6228 "The app offers its source: a Source link from an admin setting
(AGPL §13)". The AGPL requires a modified copy run for others over a
network to offer them its source; Forge's #2282 is the shape followed.
- Settings → General gains "Source code" (`source_url`), defaulting to
the public repo so a stock install makes the offer. Empty hides it.
- It is an http(s)-only URL setting, refused otherwise on save, and
`safe_link` refuses anything else again when it is published, because
the web renders it as an href.
- /api/config carries it; the web shows "Source" beside the build number
at the foot of the sidebar. The offline desktop's config has no such
field, so the link stays hidden there.
- ci.yml's client-bake comments no longer call the forge private; the
repo has been public since #2091.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#6227 "License Inkwell under AGPL-3.0-only: LICENSE file and SPDX
identifier". The operator chose AGPL-3.0-only, as for FabledStash (#6212).
- LICENSE is the canonical GNU AGPL v3 text from gnu.org: 661 lines, md5
eb1e647870add0502f8f010b19de32af, the same file as FabledStash's.
- AGPL-3.0-only is declared in pyproject.toml, frontend/package.json (and
its lockfile root entry), the four crates, and tauri.conf.json's bundle.
- The pacman package declared MIT; it now declares AGPL-3.0-only.
- pyproject's build floor moves to setuptools>=77, the first release that
reads `license` as an SPDX expression.
- The README's Licence section names the licence, links LICENSE, and
states the network clause in one sentence.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#6225 "Comment standard sweep: root, config, CI workflows, docs and
packaging", milestone 546 "Bring the repo to the comment standard (rule 32)
and compose defaults (rule 198)".
- README restructured in newcomer order: what it is, quick start,
configuration, setup pitfalls, updating and backups, features,
troubleshooting, development, licence. Its quick-start compose carries
no restart policy.
- Dockerfile and .env.example trimmed to one or two lines per setting.
- Workflows, packaging scripts and docs lose issue, run, note, rule and
milestone references and their history narration. Comments that still
described path filters and "step 6" as future work now state what the
workflows do.
- ci-requirements.md rewritten against the current workflows. It said the
build gated on typecheck and lint only, the integration lane did not
gate, a v* tag published :latest, and ci-tauri lacked bsdtar and zstd;
none of those is true now (the last checked against run 9630's log).
- Echo strings in guard-forward.sh and version.sh drop their note
citations; every string a test asserts on is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Milestone 546 "Bring the repo to the comment standard (rule 32) and
compose defaults (rule 198)", step #6222 "Comment standard sweep: web
client (frontend/)".
Comments, docstrings and template notes across frontend/ now say what
the code does and the non-obvious why as present-tense facts. Issue,
task, note, milestone and family-idea references are gone, along with
narration of what used to happen: the titles index's wiki-link origin
and its once-per-session load (#5165), the card-controls overlap a
phone showed (task 2697), the M7 and M10.9 labels, and the card
unification behind the reminders lens (task 1913). Hazards stay as
facts, among them LEAVE_MS matching the editor's leave duration, the
Android client twin that must not be widened, and keeping leavers in
flow in the multi-column masonry.
62 files, comments only: a comment-stripping diff of every changed file
against HEAD is identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Scribe #6198 "An attached image can't be viewed large or copied back out".
Web and desktop: clicking an image in the editor opens it full size, with
Copy image (re-encoded to PNG, the one type every clipboard accepts),
Download and Close. Copy hides where the browser has no async clipboard,
such as a server reached over plain http; right-clicking the full-size
image still copies there.
Android: long-pressing an image in the editor copies it to the clipboard;
a tap still opens it full size in the phone's viewer. Opening and copying
share one hand-out that copies the file out of the blob store behind a
FileProvider URI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A long tag list pushed the drawer's Archive, Trash and Sync off screen on
Android, where Tags still sat above them (#5915 only reordered the web rail).
Tags now come last on Android too, and both clients list the five tags most
recently used or viewed, by name, with "All tags (N)" opening the rest in
place.
Used: the newest untrashed note carrying the tag. GET /api/labels and the
core's list_labels return it as last_used, and each client also folds in the
notes it holds, so a tag applied a moment ago counts before the next listing.
Viewed: opening the tag's lens, remembered on the device (per user on the web).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#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 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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
@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>
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 (#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 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>
Schema v12 adds notes.state_at: a recipient's own pin, archive and order are
stamped there instead of on updated_at, which stays the text's time. Push sends
them only to a server advertising `shared_state` (push::Accepts), a view share
included; the first pull at that level starts the feed over once so held copies
drop their owner's pins. The client speaks protocol 7 and lists `shares` and
`shared_state` among the features a server may lack.
The web card and editor offer pin, archive and drag on shared notes; share and
trash stay the owner's, and the board's trash key skips notes you don't own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The core pulls with shares from a server offering them (protocol 6): a note
says how it is held (owner, edit, view) and who shared it, and a revoked note
leaves the device. The first such pull starts the feed over once, so notes
shared before this build arrive. The store refuses what a share doesn't allow
(view: everything; edit: anything but the text), push sends only the text of
someone else's note, and their notes stay out of trash, reminders and
reordering. Unlinking drops them.
The Share dialog's calls go to the linked server over the device token, as
Tauri commands and through the FFI. The desktop now offers Share and "Shared
with me"; unlinked, the dialog says sharing needs a server.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The operator asked for self-service reset over SMTP. It reuses #5173's
password_resets table, /reset-password page, one-hour single-use token and
sign-out-everywhere.
- Settings (rule 25, not env): a new Email group (SMTP server, port,
encryption as a choice, username, password, from), General → Public
address, and Security → Reset emails per account. The registry gains
`choices`, `secret` (the value is never sent back, `is_set` says one is
saved, an empty save keeps it) and `url` (http(s), trailing slash
stripped).
- mailer.py: stdlib smtplib on a worker thread, 20 s timeout,
starttls | tls | none. mail_settings() is None until a server, a sender
and the public address are set. Links are built from the public address
because the Host header can be forged.
- POST /api/auth/forgot-password: the same answer at the same speed for
any address. The link is made and mailed off the request (send_later).
It is throttled like a sign-in per visitor address, and capped per typed
email by reset_emails_per_account; past the cap it answers the same and
sends nothing.
- POST /api/settings/test-email: mails the admin with the saved settings
and shows the server's error if it fails.
- Public config `password_reset_by_email`. Sign-in shows "Forgot
password?" only then, linking to a new /forgot-password page.
- docs/public-hosting.md: an "Email and forgotten passwords" section.
Tests: the secret stays server-side; emailed link → reset; the same
answer for unknown addresses; the cap; test email success and failure;
validation units. #5266.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>