66 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 2e2714a50b core: clippy — a one-element slice from a reference in the store test
CI & Build / Python lint (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
CI & Build / Build & push image (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / integration (push) Successful in 1m16s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 3m26s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m37s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m25s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Android / Kotlin + Rust (APK) (push) Successful in 10m54s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:18:53 -04:00
bvandeusenandClaude Opus 5.5 4f5459cb94 android: pin and archive a note someone shared with you
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 1m30s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / integration (push) Successful in 1m24s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m17s
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>
2026-10-07 17:13:58 -04:00
bvandeusenandClaude Opus 5.5 89cd1c76bb core, web: pin, archive and reorder a note someone shared with you
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>
2026-10-07 17:13:58 -04:00
bvandeusenandClaude Opus 5.5 63955bbe97 sync: recipients keep their own pin, archive and place on shared notes
A note_user_state row per (note, recipient) holds what used to be the owner's
columns as far as anyone else could tell. The board filters and orders through
the viewer's own state; PATCH and reorder write it for a note shared at any
level; the feed's revision for a shared note is the later of the note's and the
caller's row, so a recipient's pin reaches their devices and no one else's.

Push takes the three with their own `state_at` stamp (protocol 7,
`shared_state`), so pinning a copy whose text is behind never makes that text
win over the owner's edit. A body is only stamped as an edit when it changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:13:58 -04:00
bvandeusenandClaude Opus 5.5 5e8c6dc7bf android: detekt — check the link before loading shares, and NoteAccess gets its own file
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 13s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / integration (push) Successful in 1m6s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m27s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:52:21 -04:00
bvandeusenandClaude Opus 5.5 2e7db21b21 android: share a note, and read or edit one shared with you
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 18s
CI & Build / integration (push) Successful in 1m25s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m39s
Android / Kotlin + Rust (APK) (push) Failing after 4m53s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m42s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m40s
Desktop (Tauri) / Update manifest (push) Successful in 5s
The editor's menu has Share…, which opens a sheet of who the note is shared
with and lets you add someone at view or edit, change it, or stop sharing;
unlinked, it says sharing needs a server. A note shared to view opens
read-only; at edit only its text can change. Cards say who shared a note
("From Robin") or that yours is shared, and a view-only note's boxes don't
tick.

Also: two core store tests used unwrap_err on a Result<Note>, which needs
Note: Debug; they use err().expect() now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:42:30 -04:00
bvandeusenandClaude Opus 5.5 aa36b43dc3 core: shared notes on the desktop and phone, and Share from the desktop
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 1m14s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 1m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 9m20s
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>
2026-10-07 15:33:16 -04:00
bvandeusenandClaude Opus 5.5 75928c7afd sync: shared notes in the feed, revocations, and text pushes from an edit share
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python tests (push) Successful in 15s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / integration (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 54s
The change feed answers `?shares=1` with every note the caller can see, each
saying how it is held, plus a `revoked` list of notes that left them. Granting
a share moves the note past the recipient's cursor; ending one, or the owner
deleting the note, leaves a revocation on the same cursor. A recipient at edit
may push the note's text, and nothing else. Protocol 6, feature `shares`;
opt-in, so the floor stays at 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:25:13 -04:00
bvandeusenandClaude Opus 5.5 2e2d8667dd password reset by email: Settings → Email, Forgot password?, and a test-email button
CI & Build / Python tests (push) Successful in 15s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / integration (push) Successful in 1m16s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 11s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build & push image (push) Successful in 1m15s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m49s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m26s
Desktop (Tauri) / Update manifest (push) Successful in 4s
The operator asked for self-service reset over SMTP. It reuses #5173's
password_resets table, /reset-password page, one-hour single-use token and
sign-out-everywhere.

- Settings (rule 25, not env): a new Email group (SMTP server, port,
  encryption as a choice, username, password, from), General → Public
  address, and Security → Reset emails per account. The registry gains
  `choices`, `secret` (the value is never sent back, `is_set` says one is
  saved, an empty save keeps it) and `url` (http(s), trailing slash
  stripped).
- mailer.py: stdlib smtplib on a worker thread, 20 s timeout,
  starttls | tls | none. mail_settings() is None until a server, a sender
  and the public address are set. Links are built from the public address
  because the Host header can be forged.
- POST /api/auth/forgot-password: the same answer at the same speed for
  any address. The link is made and mailed off the request (send_later).
  It is throttled like a sign-in per visitor address, and capped per typed
  email by reset_emails_per_account; past the cap it answers the same and
  sends nothing.
- POST /api/settings/test-email: mails the admin with the saved settings
  and shows the server's error if it fails.
- Public config `password_reset_by_email`. Sign-in shows "Forgot
  password?" only then, linking to a new /forgot-password page.
- docs/public-hosting.md: an "Email and forgotten passwords" section.

Tests: the secret stays server-side; emailed link → reset; the same
answer for unknown addresses; the cap; test email success and failure;
validation units. #5266.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:15:43 -04:00
bvandeusenandClaude Opus 5.5 ca242e59a6 tests: adding a checklist item answers 201
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 59s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m48s
CI & Build / Build & push image (push) Successful in 1m2s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m7s
Desktop (Tauri) / Update manifest (push) Successful in 4s
The edit-share test expected 200 from POST …/items, which creates. #5174.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:06:39 -04:00
bvandeusenandClaude Opus 5.5 f53d377766 sharing: share a note from the web, at view or edit, with anyone on the instance
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Python tests (push) Successful in 14s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / integration (push) Failing after 54s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m6s
Desktop (Tauri) / Update manifest (push) Canceled after 0s
Desktop (Tauri) / Tauri desktop (Linux) (push) Canceled after 2m33s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Canceled after 2m25s
The ACL has gated every read since M0, but nothing could write a share.

Server:
- shares_api: GET /api/users/directory (everyone but you, signed-in only),
  GET/POST /api/notes/<id>/shares and DELETE …/shares/<share_id>, owner
  only. Sharing again with the same person changes the permission
  (ON CONFLICT on the new unique index).
- acl.visible_to_user takes permission=; granted_to and shared_ids feed
  the serializer.
- Edit covers body and checklist (_get_editable). Everything else stays
  _get_owned. A view share's write is a 404 like a stranger's (#1984). An
  editor's PATCH naming anything but body is a 403.
- Serialized notes carry permission, shared and shared_by. A recipient
  never gets the owner's labels, and a #tag an editor types files under
  the owner's (it always went to note.owner_id).
- ?shared=with_me, also allowed in saved views. Trash and reminders are the
  owner's. purge_note drops the note's shares.
- Migration 0034: one share per note and person (and per group), permission
  limited to view and edit, an index for "shared with me".

Web:
- ShareDialog (one, mounted by the shell): pick a member, Can view or Can
  edit, change or remove existing shares, with loading, error and empty
  states.
- Card: "Shared by X" or "Shared" chip; owner-only actions and reminder
  buttons hidden for recipients; checkboxes inert at view.
- Editor: read-only at view; text and checklist only at edit; Share button
  for the owner.
- FilterBar: Shared with me. Repo seam gains `shares`; the offline desktop
  shows none of it (#5175 brings sharing there).

Tests: owner, recipient and stranger across reads, every write at view and
edit, tag filing, unshare, trash, delete and validation; web unit tests for
the facet and permission helpers. #5174.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:01:53 -04:00
bvandeusenandClaude Opus 5.5 f100e5ef85 tests: the self-revoke routing check reads the URL map; its 400 moves to Postgres
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 51s
CI & Build / Build & push image (push) Successful in 50s
test_devices signed a fake account into a session and relied on
login_required answering without the database. Since 3dd0b44 the session
path reads the account's epoch, so the fake account hit an unreachable
database and 500ed. The routing property (the static /devices/self rule beats
/devices/<device_id>) is now asserted on the URL map, and the view's 400 for a
web session is an integration test with a real account. #5173.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:41:44 -04:00
bvandeusenandClaude Opus 5.5 3dd0b44cb9 password reset: an admin makes a one-hour link, and using it signs the account out everywhere
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Failing after 12s
CI & Build / integration (push) Successful in 49s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m41s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m5s
Desktop (Tauri) / Update manifest (push) Successful in 5s
There is no mail path, so a forgotten password needed a hand on the database
(#2939 §2). Settings → People lists the accounts; Reset password makes a link
that works once within an hour, shown once for the admin to hand over. Making
another link for the same account closes the earlier one.

Using it (/reset-password) sets the password, deletes the account's device
tokens, and moves users.session_epoch on. Sessions are signed cookies the
server can't delete, so each now carries the epoch it signed in under and
login_required reads the account's epoch by primary key. A cookie from before
this has no epoch and reads as 0, the starting value, so the upgrade signs
nobody out. A deleted account's session now stops working too.

The one-time link reveal moves out of InviteList into OneTimeLink, and the
link-building into router/links.ts, shared by invites and resets.

Migration 0033. #5173.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:35:58 -04:00
bvandeusenandClaude Opus 5.5 28fa8badcb invites: an admin lets one person register while registration stays closed
CI & Build / Python lint (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 54s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m8s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m28s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m14s
Until now adding a second person meant re-opening registration to the
whole internet while they signed up (#2939 §1). An admin now makes an
invite in Settings: a link that works once, expires (7 days by default,
1 to 30), and can be pinned to one email address. Only the token's hash
is stored, so the link is shown once.

POST /api/auth/register takes `invite`. Redemption is one conditional
UPDATE inside the transaction that creates the account, so two people
racing one link can't both get in, and a taken email leaves the invite
unused. Every refusal says "invalid or expired invite". The register
page reads ?invite= and opens even while registration is closed.

Refs #5172

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:13:03 -04:00
bvandeusenandClaude Opus 5.5 df85535ea2 desktop: reminders reach you when the window isn't in front
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 36s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m30s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m38s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m38s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 8m45s
A Rust worker reads due reminders from the local store every 15 s and
announces each occurrence once: always to the main window as a toast, and
as a system notification (tauri-plugin-notification) when that window
isn't focused. The page no longer polls on the desktop; its Notification
went nowhere in WebKitGTK and its timer stopped with the window. The
Reminders page says what each surface actually does.

Core gains store::due_reminders, compared by instant, not by string.

Refs #5171

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:46:02 -04:00
bvandeusenandClaude Opus 5.5 5989ffc1c6 desktop: Import and Export work offline; the menu toggle is reachable; errors say why
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 48s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 4m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m10s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m32s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 12m42s
Export and Import were a link to the server and a reject("needs a server") on the
desktop. Both now run in the core with no server:

- core/src/local/portable.rs builds the same zip the server writes (notes.json,
  a Markdown file per note, each attachment this device holds) and reads either
  export marker or a Google Keep Takeout zip, with the server's decompression
  budget and an all-or-nothing transaction. Export saves to Downloads (no new
  plugin) and the sidebar says where; Import takes the archive as raw IPC bytes.
- core/testdata/portable.json pins the format for both copies: the server runs
  its Keep and native readers against it (test_portable_fixture.py) and checks
  its real export's keys (test_integration.py); the core runs the same cases.
- Found on the way: both importers skipped a Keep note that is only a photo as
  "empty". It now imports, on the server and in the core.
- New dependency, approved: `zip` (deflate only) plus `flate2` on its pure-Rust
  backend, both already in the lockfile.

The AppImage applications-menu toggle moves from Account, which the desktop
never shows, to the Sync page; the first-run prompt now says so.

errorMessage (#5236) replaces the hand-rolled `.error ?? …` / `.message ?? e`
reads at the remaining catch sites, so a desktop failure shows its real reason.

Task #5170.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:36:42 -04:00
bvandeusenandClaude Opus 5.5 ac4427f834 android: shows and attaches files, and the share sheet takes images
CI & Build / Web typecheck and unit tests (push) Successful in 11s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / integration (push) Successful in 45s
CI & Build / Build & push image (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 1s
CI & Build / Python tests (push) Successful in 12s
Android / Kotlin + Rust (APK) (push) Successful in 11m6s
The phone downloaded every attachment and drew none of them, so a photo note
looked empty. Now:

- Cards show a note's first image and name its other files; the editor shows
  every image at full width and every file as a row. Tapping one opens it in
  whatever app handles its type (a cache copy under its real name, through a
  FileProvider that serves only those copies). Each can be removed, and a file
  the server refused says why under it.
- The editor's toolbar has an Attach button (any type, several at once). Files
  are stored on the phone straight away and upload on the next sync that
  reaches a server, through the core's step-6 path. Link previews show in the
  editor too, and can be dismissed.
- Share → Inkwell accepts one or several images, with or without a caption,
  finishing #1899's deferred image/* target.
- The FFI gains add_attachment, delete_attachment, delete_preview and
  blob_path. The sync summary counts uploads and failed uploads.

Images decode at the size they are drawn (BitmapFactory sampling plus EXIF
rotation, small LRU cache), so no image library is added. Files over 50 MB are
refused on the phone before they are read whole into memory.

Task #5169.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:59:36 -04:00
bvandeusenandClaude Opus 5.5 5b11490858 core: the compat forward-compat test builds its feature list from the constants
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
CI & Build / Python tests (push) Successful in 16s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / integration (push) Successful in 41s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m19s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m34s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m20s
Android / Kotlin + Rust (APK) (push) Successful in 8m9s
A literal list went stale the moment attachment_sync was added (run 8513), the
same way pinned version numbers did at v2 — for a reason unrelated to what the
test checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:13:17 -04:00
bvandeusenandClaude Opus 5.5 2b2ceaa82e attachments sync: attach offline, upload when linked, removals stick (#5168)
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 35s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 9s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 2m23s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m17s
Desktop could not create an attachment at all, and a removed attachment or
dismissed preview came back on the next pull. Now:

- core: add_attachment keeps the bytes in the blob store and queues the row
  (schema v10: attachments.uploaded / upload_error). Push uploads it once its
  note has landed. A refusal that retrying won't fix (too large, id clash, hash
  mismatch) is recorded on the file and not re-sent every cycle; the editor
  shows it.
- core: removing a synced attachment or dismissing a preview leaves a tombstone
  in pending_deletes; push sends it as an `attachment`/`preview` delete, and a
  pull while it waits doesn't put the row back. A pull also keeps files still
  waiting to upload instead of replacing them wholesale.
- server: PUT /api/sync/attachments/<id> (raw body, sha256-checked, idempotent,
  size-capped) and child deletes in push, which apply regardless of LWW and
  answer noop for rows the caller can't see. One store_attachment helper for
  the upload route, the importer and sync. Protocol 5, feature attachment_sync;
  the client sends neither to a server without it.
- server: migration 0031 makes a link preview's insert/delete bump its note, so
  background-fetched previews and web dismissals reach linked devices.
- desktop: Attach and paste-image work offline (raw-bytes IPC command).
- SVG is served as a download by the desktop blob scheme too (as #1981 did for
  the web), and drawn as a file chip on both.
- autosync: drop the catch_unwind; release builds abort on panic, so it only
  ever worked in debug builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:07:52 -04:00
bvandeusenandClaude Opus 5.5 efb141e555 desktop: syncs on its own — at launch, soon after an edit, every few minutes and on focus
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m53s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m47s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Android / Kotlin + Rust (APK) (push) Successful in 11m34s
Android / Build, or is the channel already serving this? (push) Successful in 5s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 13s
CI & Build / Python tests (push) Successful in 14s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / integration (push) Successful in 47s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 3m8s
Until now the only caller of the sync engine was the "Sync now" button. A worker
thread now owns every cycle (the button's included, so two never overlap):

- launch: one cycle as the app opens;
- edit: every 10s it reads a fingerprint of the pending set and sends when that
  moved. A fingerprint rather than "anything pending", because a rejected change
  stays pending and would otherwise be resent every tick forever;
- timer: a pull every 5 minutes with nothing to send;
- focus: at most once per 30s.

Failed automatic cycles back off (doubling from 10s to 5 minutes). A panicking
cycle counts as a failed one rather than ending the thread. Every cycle is
emitted as inkwell://synced: the board reloads when the pull changed something,
and the Sync screen shows the last automatic failure. No final push on quit;
the launch cycle sends whatever was left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:40:27 -04:00
bvandeusenandClaude Opus 5.5 09239ee3c7 web: the app's type-check leaves the unit tests out; CI checks them separately
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python lint (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 19s
CI & Build / integration (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 1m13s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 3m19s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 4m11s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m36s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Run 8478 passed every test lane and then failed `Build & push image`. The
Dockerfile's frontend stage copies only frontend/, and `npm run build`
type-checks with tsconfig.json, which covered grammar.test.ts. Its import of
../core/testdata/grammar.json doesn't exist inside that stage. Nothing was
published: the build is the publish and it stopped.

tsconfig.json now excludes `*.test.ts`. The new tsconfig.test.json extends it
with the tests included, and ci.yml's typecheck lane runs that one, so the
tests are still type-checked before anything ships.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:34:14 -04:00
bvandeusenandClaude Opus 5.5 38da5160a0 grammar: a #tag starts after whitespace and with a letter, on every surface
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / integration (push) Successful in 38s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m7s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m38s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 10m35s
The shared fixture went red on the server (run 8468: 5 failed), because the three
tag rules disagreed:
- core and web: any non-tag character counts as a boundary, so `(#todo)`
  and `end.#tag` are tags, and so is the `/#section` of a pasted URL;
- server: only whitespace counts, but `#1st` and `#_x` are tags.

A note's labels could therefore change every time it synced.

All three now share the strict rule: start of line or whitespace, then a
letter, then letters, digits, `_` and `-`. Nothing becomes a tag that wasn't
already one everywhere, and URL anchors stop becoming labels on desktop and
Android. The server's existing `http://x/#nope` test already expected this.

derive.rs's boundary, markdown.ts's lookbehind and tags.py's regex change
together; `_is_tag` goes because the regex now requires the letter. The
fixture flips `(#todo)`, `end.#tag` and its lift case, and adds the URL case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:22:53 -04:00
bvandeusenandClaude Opus 5.5 535331c5b2 tests: one fixture for the note grammar, run by the core, the server and the web
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / integration (push) Failing after 27s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 1s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Failing after 12s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 4m25s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m28s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m19s
Desktop (Tauri) / Update manifest (push) Successful in 9s
Android / Kotlin + Rust (APK) (push) Canceled after 11m21s
The checklist grammar and the #tag rule are implemented three times (derive.rs,
checklist.py/tags.py, markdown.ts), and the tag colour twice (colors.ts,
DerivedTint.kt). Only Rust and Kotlin had tests. core/testdata/grammar.json now
holds one set of cases (task lines, rendered items, tags, standalone-tag lifts
and the tint hashes), and every suite reads it.

- web: vitest, a dev dependency approved for #5166, with `npm test`.
  grammar.test.ts runs the fixture, and titles.test.ts pins #5165's palette fix.
- ci.yml runs the web tests in the job the image build needs. desktop.yml's
  verify job runs them too, because the installers embed this frontend and
  can't see ci.yml's verdict (rule 177).
- core: derive.rs reads the fixture. server: tests/test_grammar_fixture.py.
- Android keeps its hand-written tint values; its doc now points at the fixture.

The server is expected red here, on purpose. tags.py only takes a tag after
whitespace and lets it start with a digit or `_`, while the core (the
definition) takes any non-tag boundary and needs a letter. So `(#todo)` is a
label on the phone and plain text on the server. The fix follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:11:26 -04:00
bvandeusenandClaude Opus 5.5 af0389ed13 web: the command palette finds notes written since it was first opened
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python tests (push) Successful in 16s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python lint (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / integration (push) Successful in 41s
CI & Build / Build & push image (push) Successful in 58s
Desktop (Tauri) / Clippy, tests and rustfmt (push) Successful in 3m30s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m43s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m23s
Desktop (Tauri) / Update manifest (push) Successful in 4s
The palette's note list loaded once per session behind a `loaded` flag, and
the `reload()` that would have cleared it had no caller. So a note written
after the first open couldn't be found by name until the page reloaded
(#5165, audit B4). The list is now fetched again on every open, and the last
list stays visible meanwhile. The input takes focus before the fetch, and a
failed fetch keeps the old list instead of breaking the palette.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:51:36 -04:00
bvandeusenandClaude Opus 5.5 8db9f3685b server: one save path for a note's text, so a restored link gets its preview
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 11s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Clippy, tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 41s
CI & Build / Build & push image (push) Successful in 46s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
A body edit is a sequence: keep a revision, rename the note, lift #tags,
commit, queue link previews. It was written out in PATCH, the item routes,
restore and sync push, and the copies had drifted. Now they all call
`notes/body.py: write_body`, which says how the old text is kept ("session",
"always" for restore, "never" for a new note) and returns whether the text
changed. The routes commit through `_commit_note`, which queues previews after
the commit.

Fixes, both red on 1a2f71e (run 8441):
- restoring a revision queues previews for its links (#5164, audit B5);
- a pushed note keeps the client's edit time when a standalone #tag is lifted.
  The lift's extra flush used to let `onupdate` stamp the server clock over it.

Sync push also queues its previews after the batch commits, not mid-batch,
where a fast fetch could look for a note that wasn't committed yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:31:41 -04:00
bvandeusenandClaude Opus 5.5 86409e02b0 ci: drop the deliberate failure — the gate held on run 8437
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 14s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 7s
CI & Build / integration (push) Failing after 49s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Clippy, tests and rustfmt (push) Successful in 2m26s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m54s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m52s
Desktop (Tauri) / Update manifest (push) Successful in 7s
Run 8437 failed `verify` on purpose, and the Linux build, the Windows
installer and the update manifest all reported skipped. This removes the red
step, so this push is the other direction: a green verify still publishes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:26:48 -04:00
bvandeusenandClaude Opus 5.5 1a2f71e381 tests: every note-text write path is pinned, and two of them fail today
Integration tests for the edit sequence at each door: create, PATCH, ticking an
item, restoring a revision and sync push. Two fail on today's code, on purpose:

- restoring a revision never queues link previews, so a restored link stays a
  bare URL (#5164, audit B5);
- a pushed note whose standalone #tag gets lifted stores the server's clock as
  its edit time instead of the client's, because the lift's second flush lets
  the column's onupdate overwrite it.

The fix follows in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:26:15 -04:00
bvandeusenandClaude Opus 5.5 4d61b34b85 ci: the Windows installer waits for the Rust checks, like the Linux bundles do
Android / Build, or is the channel already serving this? (push) Successful in 2s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 33s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 17s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Build & push image (push) Successful in 33s
Desktop (Tauri) / Clippy, tests and rustfmt (push) Failing after 2m58s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
Clippy, the workspace tests and rustfmt move out of the Linux `build` job
into their own `verify` job, and both publishing jobs need it. Before this,
`windows` needed only `decide`, so on run 8411 a red clippy stopped the Linux
lane while the Windows installer built and published to dev-rolling (#5184,
rule 177).

This commit also carries a deliberately failing step at the end of `verify`.
It is the red half of the proof: both publishers must report `skipped`. The
next commit removes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:22:28 -04:00
bvandeusenandClaude Opus 5.5 be200641cd core: notes show their real created and edited times, and desktop search works
CI & Build / Build now, or wait for Android? (push) Canceled after 0s
CI & Build / TypeScript typecheck (push) Canceled after 0s
CI & Build / Python lint (push) Canceled after 0s
CI & Build / Python tests (push) Canceled after 0s
CI & Build / integration (push) Canceled after 0s
CI & Build / Build & push image (push) Canceled after 0s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 4m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 7m41s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 10m41s
Both bugs were caught red by the tests in 732fd7a (run 8418: 5 failed, 147
passed) before this fix.

- load_note reads columns by NAME. Dropping `color` (fa89da1) shifted every
  column after it and the two timestamps were missed, so created_at showed the
  last edit and updated_at showed the trash time — null on any live note. Only
  the read was wrong; nothing stored is, so no data needs repairing.
- The board's text facet binds its pattern once for its one placeholder. It
  pushed it twice after the title column went (95aa10c), and rusqlite refused
  every query with InvalidParameterCount — every desktop search failed.
  Android searches through store::search and was never affected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:42:51 -04:00
bvandeusenandClaude Opus 5.5 732fd7a827 core: the store tests pass clippy and rustfmt, so they reach the test step
CI & Build / Build now, or wait for Android? (push) Canceled after 0s
CI & Build / TypeScript typecheck (push) Canceled after 0s
CI & Build / Python lint (push) Canceled after 0s
CI & Build / Python tests (push) Canceled after 0s
CI & Build / integration (push) Canceled after 0s
CI & Build / Build & push image (push) Canceled after 0s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 3m48s
Desktop (Tauri) / Update manifest (push) Canceled after 0s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Canceled after 3m55s
Android / Kotlin + Rust (APK) (push) Canceled after 4m29s
7bc8e04 never got as far as running them: clippy's cloned_ref_to_slice_refs
rejected four `&[x.clone()]` slices, and the file wasn't rustfmt-formatted.
Still tests only — the expected RED is the two timestamp tests and the
text-search tests, ahead of the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:35:10 -04:00
bvandeusenandClaude Opus 5.5 7bc8e04518 core: the local store has tests, and two of them fail on today's code
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 33s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m45s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m46s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 5m43s
store.rs had none, which is how two bugs reached desktop and Android unseen.
These exercise every board facet, the timestamps, tags, revisions, items,
trash, reminders and label merges against a real migrated schema.

Two are expected RED on this commit, on purpose, so CI shows they catch what
they were written for:
- each_timestamp_comes_from_its_own_column / a_new_note_carries_both_timestamps:
  load_note reads created_at and updated_at one column too far right since
  fa89da1 dropped `color`.
- text_search_*: the text facet binds its LIKE pattern twice for one `?`,
  left over from title+body (95aa10c).

The fix follows in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:29:33 -04:00
bvandeusenandClaude Opus 5.5 c5f93cf9f1 rename: the sign-in screen shows Inkwell's mark, and every tab says Inkwell
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python tests (push) Successful in 12s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / integration (push) Successful in 43s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build & push image (push) Successful in 1m0s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m28s
Desktop (Tauri) / Update manifest (push) Successful in 10s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m22s
The login and register screens still drew a hard-coded "TS" tile. They now
use /icon.svg, the same mark the shell's header shows.

Browser tabs took index.html's static <title> and never changed it, so every
tab read the same, and some browsers showed the URL instead. usePageTitle,
mounted once in App.vue, sets "<page> · <site name>". Routes outside the shell
name themselves with meta.title. Board lenses use the lens name the header
already shows, now in useLensName so the tab and the header read from one
place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:52:50 -04:00
bvandeusenandClaude Opus 5.5 6d082b2ad8 rename: the docs say Inkwell, and nothing else still says ThoughtSync by accident
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 16s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 17s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 50s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m14s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m2s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m36s
Step 6 of milestone 481. README, docs/*, ci-requirements.md, the desktop and
Arch READMEs, alembic.ini, .gitignore, the frontend package name, the service
worker's cache name (its activate handler deletes any cache by another name, so
the old one is cleaned up), and the Android names in the release body.

What still says thoughtsync does so on purpose (Scribe note 5071):
- the desktop data crossover (crossover.rs) and its startup log
- the old-export import marker
- the "Upgrading from ThoughtSync" block in .env.example, and compose's pointer
  to it
- the packages being retired: deb conflicts/replaces thought-sync, pacman
  thoughtsync and thoughtsync-desktop
- the Android signing keyAlias, which names a key in the existing keystore
- history: shipped alembic migrations, and the test-binary hashes that
  ci-requirements.md records from 2026-08-18

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:35:02 -04:00
bvandeusenandClaude Opus 5.5 81cd719327 rename: Android is Inkwell — package, applicationId, uniffi class, assets
Step 4 of milestone 481 (Scribe note 5071: a full rename).

- namespace and applicationId com.fabledsword.inkwell; the Kotlin package moves
  with them, and ktlint re-sorted the imports the rename reordered (checked
  locally with CI's ktlint 1.4.0 and detekt 1.23.7, both clean)
- uniffi: class Inkwell in com.fabledsword.inkwell.core, InkwellApplication,
  InkwellTheme, Theme.Inkwell, log tags, prefs and work names, client agent
  inkwell-android
- the lane publishes inkwell.apk / inkwell-android.json; fetch-clients,
  guard-forward, publish-release and write-manifest read the same names

A new applicationId is a new app. The old ThoughtSync app keeps its own store
and stays installed beside it. Notes cross over by syncing, and the old app is
then removed by hand.

Kept: the signing keyAlias is still "thoughtsync". It names the key inside the
existing keystore, and the key, and so the certificate, are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:34:02 -04:00
bvandeusenandClaude Opus 5.5 256fba3610 icon: Inkwell's mark is a black inkpot and quill on the brand yellow
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 43s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 4m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 7m13s
Desktop (Tauri) / Update manifest (push) Successful in 8s
Android / Kotlin + Rust (APK) (push) Canceled after 11m42s
Step 5 of milestone 481. Replaces the linked-notes constellation, which had been
stale since note links were dropped (alembic 0024). The colour scheme stays.

packaging/icons.py draws the mark once and renders every variant from it: the
rounded tile (web, desktop), the maskable full-bleed web icon, and the Android
adaptive foreground. The detail (shaft, vane splits, glint) is cut out of the ink
with a mask rather than painted on in yellow, because the Android foreground is
now transparent and its alpha is also the themed-icon silhouette. The old
foreground was the opaque maskable tile, which a themed icon would have drawn as
a solid square.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:30:51 -04:00
bvandeusenandClaude Opus 5.5 fe6f0746b2 rename: the desktop is Inkwell — crates, Tauri identity, data move, packaging
Step 3 of milestone 481 (Scribe note 5071: a full rename).

- crates thoughtsync-{core,desktop,ffi,uniffi-bindgen} → inkwell-*, the
  Cargo.lock entries moved to match (checked with `cargo metadata --locked`)
- Tauri: productName "Inkwell", identifier com.fabledsword.inkwell, binary
  `inkwell`, updater feed on bvandeusen/inkwell, store file inkwell.db
- client agent inkwell-desktop, headers X-Inkwell-Client/-Protocol (the server
  reads neither), capture event inkwell://captured, display-version env
- .deb: conflicts + replaces thought-sync, so the updater's install retires the
  old package instead of colliding on it. kebab-case("Inkwell") is `inkwell`, so
  the package name finally matches the command and verify.sh now asserts it
- pacman: inkwell, conflicting with and replacing thoughtsync and
  thoughtsync-desktop
- AppImage ~/Applications/Inkwell.AppImage, menu entry inkwell.desktop,
  installer, release titles, desktop asset names in fetch-clients.sh

The one shim, chosen by the operator because it is the only copy of a
local-first user's notes: crossover.rs moves the old
com.fabledsword.thoughtsync app-data dir's contents into the new one on startup,
before the store opens, renaming thoughtsync.db and its -wal/-shm with it. It
skips when the new dir already has a store, and anything already in the new dir
wins (the installer writes its channel marker there first). Tested.

Android's Kotlin side (package, applicationId, uniffi class) is step 4. Its
release asset names stay thoughtsync.* until then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:27:26 -04:00
bvandeusenandClaude Opus 5.5 a706644455 rename: the server is Inkwell — package, env vars, image, compose, export marker
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 45s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 4m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 7m33s
Desktop (Tauri) / Update manifest (push) Successful in 7s
Android / Kotlin + Rust (APK) (push) Successful in 11m25s
Step 2 of milestone 481. The operator chose a full rename (Scribe note 5071), so
this goes past the display strings into the identities:

- src/thoughtsync → src/inkwell; every import, the Dockerfile and both compose
  commands, alembic env, pyproject
- THOUGHTSYNC_* → INKWELL_* (database URL, secret key, log level, tag/port/bind)
- container data dir /var/thoughtsync → /var/inkwell
- image git.fabledsword.com/bvandeusen/inkwell; Postgres user/db default inkwell;
  CI's integration service follows
- the files the image serves are inkwell.*. fetch-clients.sh still fetches the
  thoughtsync-named release assets, because the lanes that publish them are
  renamed in steps 3 and 4
- exports are written with app "inkwell"

Two deliberate exceptions, both because data rides on them:

- compose volumes are now named explicitly and overridable (INKWELL_DB_VOLUME,
  INKWELL_DATA_VOLUME), so a deployment installed as ThoughtSync points at the
  volumes and DB identity it already has. .env.example says exactly what to set
- import still accepts app "thoughtsync", because exports written before the
  rename are backups. Tested both ways

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:18:49 -04:00
bvandeusenandClaude Opus 5.5 f806e35d41 rename: the apps say Inkwell — web, desktop, Android and server strings
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 11s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / integration (push) Successful in 58s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 15s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 4m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 7m50s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 11m37s
ThoughtSync is renamed Inkwell ("Fabled Inkwell" in full; Scribe note 5071).
This is step 1 of milestone 481: every string a person reads in the running
apps. Identities installed clients depend on are deliberately untouched — the
Tauri productName (it derives the .deb Package: field), identifier and binary
name, applicationId, X-ThoughtSync-* headers, the export's app marker, env vars,
module and crate names.

- web: title, PWA manifest (name "Fabled Inkwell", short_name "Inkwell"),
  offline page, icon labels, build labels, prompts, notification title
- server: site_name default, import error, link-preview User-Agent
- 0030: a stored site_name of exactly the old default follows the rename. The
  Settings page saves every key, so most servers hold "ThoughtSync" without an
  admin ever having chosen it; a name they typed is left alone
- desktop: window title, default device name, local-mode site name, log line
- android: app_name and the strings that name the app
- core: probe and compatibility messages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:16:14 -04:00
bvandeusenandClaude Opus 5 fb469ed73a update: wrap the dev-rolling feed assertion the way rustfmt wants
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 32s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m55s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m14s
Desktop (Tauri) / Update manifest (push) Successful in 4s
7296889 lengthened `/dev/latest.json` to `/dev-rolling/latest.json` in
each_channel_has_its_own_fixed_feed, which pushed the assert past the
line width. `cargo fmt --all --check` failed the Linux desktop job (run
6358) after Clippy and the tests had passed, so that build, its publish
and the manifest job never ran. Layout taken verbatim from the diff
rustfmt printed; no behaviour change.

Scribe #2184.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwoKYuw3qJmUUYsJeNherB
2026-09-10 18:44:02 -04:00
bvandeusenandClaude Opus 5 72968897ab channels: the dev channel publishes on dev-rolling, so its tag stops shadowing the branch
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 44s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 3m20s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m20s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 9m13s
The rolling dev release lived on a tag named `dev`, beside the branch
named `dev`. Once a clone had fetched tags, `git push origin dev` failed
with "src refspec dev matches more than one" (Scribe #2184, note #3042),
and every session had to know to spell out refs/heads/dev.

The channel is still `dev` everywhere a person sees it: the app's
setting, `install.sh --channel dev`, the stored pref. Only the release
tag moves, to `dev-rolling`, matching roundtable-android. `stable` has no
branch to collide with and keeps its name.

- packaging/channel-tag.sh is the one channel -> tag mapping CI reads:
  the publish steps in android.yml and desktop.yml, the manifest job,
  fetch-clients.sh and guard-forward.sh. guard-forward exits 2 on an
  unmapped channel instead of fetching an empty URL and passing.
- update.rs and install.sh carry their own copy because neither can run
  it; update.rs gains a test that no channel feed is named like a branch.
- tests/test_channel_tag.py runs the script: no tag is a branch name,
  dev is exactly dev-rolling, an unknown channel fails with no output.
- publish-release.sh titles the release "ThoughtSync dev (rolling)", so
  the tag name does not leak into what people read.

TEMPORARY bridge: desktop apps installed before this have
.../download/dev/latest.json compiled in. The dev manifest job sets
BRIDGE_TAG=dev, and write-manifest.sh writes the same latest.json to
the old `dev` release. Its URLs name dev-rolling assets, so those apps
update once into a build that reads the new tag. The bridge, and the old
release and tag, are removed once installed apps have crossed over.
Until then the push still needs the explicit refspec, as
ci-requirements.md now says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwoKYuw3qJmUUYsJeNherB
2026-09-10 18:34:11 -04:00
bvandeusenandClaude Opus 5 53d51ce01c ci: artifact uploads move to stock upload-artifact@v7
CI & Build / Python lint (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 45s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m11s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m33s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Android / Kotlin + Rust (APK) (push) Successful in 8m45s
The Android APK upload and both desktop bundle uploads (Linux and
Windows) went through the bvandeusen fork mirror, with comments saying
stock upload-artifact throws GHESNotSupportedError on this hostname. That
stopped being true when the runner moved to gitea/runner 3.x, which
edits the refusal out of the action bundle; stock upload v4-v7 and
download v4-v8 were proven on 2026-09-10 (Scribe spike #3843) and the
same swap is verified on four other repos.

Artifact names, paths, if-no-files-found: error and the no
continue-on-error stance are unchanged. ci-requirements.md now says
stock v7 and keeps what is still true: @v3 uploads are invisible.

Scribe snippet #2271, milestone 395.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwoKYuw3qJmUUYsJeNherB
2026-09-10 17:32:55 -04:00
bvandeusenandClaude Opus 5 cf2854a029 ktlint: a multiline .border() left the next '.' orphaned, exactly as #3110 records
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 5s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 25s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m2s
`standard:chain-method-continuation` on `LinkPreviewRow.kt:83`. The `.border(…)`
call took three arguments across four lines, and the `.padding(…)` after it then
began a line with a `.` — which the rule only accepts glued to the closing
paren, `).padding(…)`.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Now: a 1s idle pause writes.

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

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

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

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

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

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

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

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

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

The feature itself, unchanged from 42e06da:

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

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

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

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

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

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

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

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

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

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

## There is no default shortcut, deliberately

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

## Stored and live are reported separately

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

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

## Two windows, one database, no shared store

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

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

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

## Where the setting lives

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

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

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

## The note is created, not pre-filled

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

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

## launchMode="singleTop"

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

## Subject and text, both

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

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

## Not included: images

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

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

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

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

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

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

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

## The two calls this needed

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

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

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

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

## The board had to learn something

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two things this did NOT do, both deliberate:

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

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

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

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

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

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

  android         2026.08.30.1711  code=3504552        57.6 MB
  linux-appimage  2026.08.30.1711  code='1.0.3504551'  95.3 MB  signed
  linux-deb       2026.08.30.1711  code='1.0.3504551'   3.3 MB
  linux-pacman    2026.08.30.1711  code='1.0.3504551'   2.7 MB
  windows         2026.08.30.1711  code='1.0.3504551'   2.6 MB
2026-08-30 13:21:03 -04:00
Bryan Van Deusen d2f9d316cf tests: 300 comes back as "300" from a platform whose key is not an integer
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 15s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Build & push image (push) Successful in 41s
The precedence test wrote `version_code=300` for all five platforms and compared
the desktop's against the int it wrote. It comes back as `"300"`, because the
module preserves each platform's own comparator type instead of flattening both
to int — which is the behaviour the change it was testing had just introduced.

A `coded()` helper now says which shape to expect and why, and the assertion runs
over every non-Android platform rather than spot-checking `linux-deb`. The test
caught a real inconsistency in itself precisely because it compared against a
concrete value rather than round-tripping what it wrote.
2026-08-30 13:19:18 -04:00
Bryan Van Deusen ff6e99eb62 image: bake every client in, not just the phone
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Failing after 15s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m10s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 10s
Android / Kotlin + Rust (APK) (push) Successful in 8m3s
~104 MB on top of ~85 MB, almost all of it the AppImage. That is what the product
being complete costs (rule 23): a self-hoster gets a working app for their machine
from the server holding their notes, with no account on a forge that is private.
The AppImage is not optional within that — it is the only bundle that can replace
itself in place, so a server without one cannot serve in-app updates to anybody.

`packaging/fetch-clients.sh` replaces the inline fetch and writes the fixed names
and sidecars `client_dist.py` reads. It never fails: a platform with nothing
published means the server advertises nothing for it and the UI hides that
download, and eight fetches must not become eight ways to redden a green lane.

THE VERSION IS FETCHED, NOT DERIVED, and this is the part that would have been
wrong the easy way. The obvious shortcut is `version.sh display desktop` in the
image job — it has the checkout. But this commit may not be the commit the channel
is serving: a push touching only `src/` does not rebuild the desktop, so the
channel still holds an older build and a locally-derived version would describe
those bytes with this commit's number. `client_dist.py`'s size check could not
catch it, because size IS measured from the real file — it would sail through and
lie about the version alone. So `write-manifest.sh` now publishes
`thoughtsync-desktop.json` beside `latest.json`, from the same two values in the
same breath, and only size/sha256 are measured at bake time.

Which needed the prune's keep-list, or the sidecar would have been uploaded and
deleted again in the same run — a fixed name is self-limiting, which is exactly
why that list exists.

`version_code` is NOT uniformly an integer, and coercing it was a leftover from
the days when Android was the only platform. Android's must stay a JSON number:
`ClientRelease` in core declares it `i64` and a string fails to deserialize on
every phone in the field. The desktop's is Tauri's semver key `1.0.<minutes>` —
the value its updater actually compares — and `int()` would have rejected every
desktop sidecar CI writes. The table now says which is which, and tests pin both
directions.

Also retires the comment above the fetch step, which claimed the APK came from
"always the rolling dev release" and mentioned `:<version>` images. M314 step 3
made the channel conditional in the code directly below it, and step 6 removed
version-shaped image tags entirely.

Verified against the live dev channel before pushing: the Android half resolves
and exits 0, the desktop half degrades with a warning because the sidecar does not
exist yet, and all five constructed bundle filenames return 200.
2026-08-30 13:11:04 -04:00
Bryan Van Deusen ef8aa9340f clients: the server hands out five platforms, not "the Android client"
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Successful in 30s
`client_dist.py` was written for one platform and everything structural in it was
already right — drop-in beats baked, the pair must describe one build, absence is
an ordinary answer, metadata public and bytes authenticated. This widens it to a
table rather than building beside it. Its own docstring made the argument years
before there was a second platform: a self-hoster should not need an account on
someone else's forge to get the app for their own notes.

Server side only. CI bakes nothing new until step 3 and the UI reads nothing new
until step 4, so this lands green and inert.

Five rows — android, linux-deb, linux-pacman, linux-appimage, windows — each
naming its artifact, sidecar and mimetype. Fixed filenames, version only in the
sidecar: a version-stamped name would force a glob, and a glob over a directory an
operator drops files into is how you serve the older of two builds, which is the
failure write-manifest.sh already carries a comment about.

THE ANDROID NAMES AND ROUTE DO NOT MOVE. The lane publishes those exact filenames,
clients in the field poll /api/client/android, and `android_client` stays on
/api/config beside the new `clients` map. Renaming them to match the pattern would
buy tidiness and strand every installed phone; retiring the key belongs to a later
change made when nothing polls it, not to the change introducing its replacement.
Fields were added, not moved — `ClientRelease` in core is a plain serde struct and
ignores what it does not know.

PRECEDENCE IS PER PLATFORM, which is the trap the table introduces. "First
directory holding anything wins" would mean dropping in an APK silently retracts
the four desktop downloads. Pinned by a test.

The AppImage needs a third file. It is the only bundle that replaces itself in
place, so the updater verifies a minisign signature before it does — and a bundle
that cannot be verified cannot be offered. A missing or empty `.sig` therefore
makes it absent rather than merely unsigned, and the signature travels WITH the
version so an updater can never pair one build's version with another's signature.

The tests parametrize over the table instead of testing Android and trusting the
rest. The bugs this module can have are not platform-specific, and a suite that
only exercised one platform is how the other four would ship untested.
2026-08-30 12:52:40 -04:00
Bryan Van Deusen f992439588 version: every surface can say which build it is, and two of them were lying
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m50s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m59s
Note 3127 §5 removed version tags, so an artifact's self-report is now the only
answer to "which build is this?" — and nothing exists to contradict it when it
is wrong. Three surfaces gain a dim build line: the foot of the web rail, the
login screen, and the foot of Sync on Android.

The login screen because "I can't sign in" is a bug report like any other, and
requiring an account to read a build number withholds it from exactly the people
who can't get past that page. `/api/config` is already public.

Two of the values it was going to show were wrong, which is the part worth
knowing about.

The DESKTOP reported `env!("CARGO_PKG_VERSION")` from `config_get` and from the
startup log. `cargo tauri build --config '{"version": ...}'` overrides
tauri.conf.json, not Cargo's own metadata — so both read the literal `0.2.0` in
Cargo.toml, on every build ever shipped. They now read a display version baked in
by the lane through `option_env!`, hoisted to the crate root because two readers
of one fact is how this repo keeps producing 2181-2183. Not the ordering key
either: `1.0.<minutes>` is the opaque value Tauri's updater compares and must
never be shown to a person, and `update.rs` still reads it because a comparator
is exactly what it is (rule 149).

The SERVER fell back to `__version__` when APP_VERSION was absent, so a server
run from a checkout reported `0.2.0` — a real-looking version naming no build
anybody could obtain. `__init__.py` already asserted the honest answer was
"APP_VERSION being missing, which app.py already handles"; it did not, and a
comment claiming a behaviour two files away is how that stayed true-sounding.
Now an explicit "unknown", with the packaging version left where "unknown" is
not a legal value.

Android reads the INSTALLED package's versionName rather than BuildConfig, so it
reports what is actually on the phone.

Everything renders "unknown" rather than blank when it cannot say. A blank looks
like a layout bug; a plausible default cannot be caught by anything.

build.rs gets `rerun-if-env-changed` for the baked value: cargo does not track an
`option_env!` variable on its own, and the desktop lane having no cache today is
what makes that easy to forget the day one is added.
2026-08-29 23:07:29 -04:00
Bryan Van Deusen 544cf72735 install: the stable fallback is dead now that stable publishes its own bundles
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 19s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Build & push image (push) Successful in 29s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m25s
Desktop (Tauri) / Update manifest (push) Successful in 4s
It existed for one window: `stable` was a manifest-only pointer at whatever `v*`
tag had last been cut, and `stable` is the DEFAULT channel, so without the
fallback `curl … | sh` was broken for everyone between step 3 landing and the
first merge to `main`. That merge happened (`b6673c6`), and `stable` now holds
its own signed bundles at 1.0.3503145 — AppImage, deb and pacman, all resolving
by the one lookup both channels share.

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

The header now says the transition is finished and that neither channel should
be special-cased again, because the shape of that code invites re-adding it.
2026-08-29 16:59:44 -04:00
302 changed files with 17658 additions and 2102 deletions
+24 -5
View File
@@ -1,4 +1,4 @@
# ThoughtSync production settings. Copy to `.env` and edit:
# Inkwell production settings. Copy to `.env` and edit:
#
# cp .env.example .env
#
@@ -29,15 +29,15 @@ POSTGRES_PASSWORD=
#
# NOTE: `main` can sit well behind `dev`. If a feature you expect is missing,
# check which branch it actually landed on before assuming a bug.
#THOUGHTSYNC_TAG=latest
#INKWELL_TAG=latest
# The host port the app is published on.
#THOUGHTSYNC_PORT=5000
#INKWELL_PORT=5000
# Which interface to bind. The default (all interfaces) is what lets desktop
# clients on your network reach the server. Behind a reverse proxy, set this to
# 127.0.0.1 so only the proxy can talk to it.
#THOUGHTSYNC_BIND=0.0.0.0
#INKWELL_BIND=0.0.0.0
# NOTE: how many proxies sit in front of this app is a SETTING, not an env var —
# Settings → Security → "Trusted proxy hops" in the admin UI. It defaults to 1 (one
@@ -47,12 +47,31 @@ POSTGRES_PASSWORD=
# How much the app says. Credential events (sign-ins, failures, throttles, new
# accounts, device tokens issued) are logged at INFO and read with
# `docker compose logs app`.
#THOUGHTSYNC_LOG_LEVEL=INFO
#INKWELL_LOG_LEVEL=INFO
# Database identity. Changing these AFTER the first start does not rename anything
# that already exists — the volume keeps whatever the first run created.
#POSTGRES_USER=inkwell
#POSTGRES_DB=inkwell
# --- Upgrading from ThoughtSync ---------------------------------------------
#
# Inkwell was called ThoughtSync, and a deployment installed under that name has
# its data in volumes and a database named for it. Point at them instead of
# renaming anything. Leaving these unset on such a deployment starts Inkwell
# against EMPTY volumes; the old data is untouched, but you would not see it.
#
# Use the full names `docker volume ls` prints — compose prefixes them with the
# project name, so they usually look like `thoughtsync_thoughtsync-db`:
#INKWELL_DB_VOLUME=thoughtsync_thoughtsync-db
#INKWELL_DATA_VOLUME=thoughtsync_thoughtsync-data
#
# And the database identity the first start created, which a volume keeps:
#POSTGRES_USER=thoughtsync
#POSTGRES_DB=thoughtsync
#
# Rename any THOUGHTSYNC_* lines already in your .env to INKWELL_* — the old
# names are no longer read.
# --- a note on HTTPS --------------------------------------------------------
#
+25 -23
View File
@@ -4,7 +4,7 @@ name: Android
#
# Replaces the Tauri-mobile lane deleted in step 2. What changed is what this
# builds, not that Android has a lane: the UI is Compose, and the store and sync
# engine are `thoughtsync-core` cross-compiled by cargo-ndk and loaded through
# engine are `inkwell-core` cross-compiled by cargo-ndk and loaded through
# uniffi.
#
# CI can only prove this BUILDS. A Linux runner cannot execute an APK, so anything
@@ -134,7 +134,7 @@ jobs:
echo "code=$code" >> $GITHUB_OUTPUT
if [ -n "${ANDROID_KEYSTORE_BASE64:-}" ]; then
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/thoughtsync-release.jks
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/inkwell-release.jks
echo "variant=Release" >> $GITHUB_OUTPUT
echo "label=release" >> $GITHUB_OUTPUT
# DEBUG profile, in a release APK, deliberately — see the note above
@@ -143,7 +143,7 @@ jobs:
# outright (run 4077). Unpicking that is worth doing and is not worth
# blocking signed builds on.
echo "profile=debug" >> $GITHUB_OUTPUT
echo "keystore=/tmp/thoughtsync-release.jks" >> $GITHUB_OUTPUT
echo "keystore=/tmp/inkwell-release.jks" >> $GITHUB_OUTPUT
echo "apk=android/app/build/outputs/apk/release/app-release.apk" >> $GITHUB_OUTPUT
echo "Signed release build — $version (versionCode $code)"
else
@@ -167,7 +167,7 @@ jobs:
# from the built .so. Run as its own step so a Rust failure is legible as a
# Rust failure instead of arriving inside a Gradle stack trace.
- name: Build the native library and bindings
run: ./gradlew generateUniffiBindings -PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }}
run: ./gradlew generateUniffiBindings -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }}
# The image's PINNED CLIs, not Gradle plugins. ci-rust-android carries both
# (M12 step 3) precisely so this lane needs no second image, and going
@@ -193,7 +193,7 @@ jobs:
# not exist (run 4082). It costs one extra Kotlin compile and buys the
# type-check on the debug variant, which is the one an emulator build
# would use.
run: ./gradlew testDebugUnitTest -PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }}
run: ./gradlew testDebugUnitTest -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }}
- name: Assemble the APK
env:
@@ -203,9 +203,9 @@ jobs:
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
run: |
./gradlew assemble${{ steps.build.outputs.variant }} \
-PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }} \
-PTHOUGHTSYNC_VERSION_NAME=${{ steps.build.outputs.name }} \
-PTHOUGHTSYNC_VERSION_CODE=${{ steps.build.outputs.code }}
-PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }} \
-PINKWELL_VERSION_NAME=${{ steps.build.outputs.name }} \
-PINKWELL_VERSION_CODE=${{ steps.build.outputs.code }}
# Prints the certificate the APK was actually signed with, so the operator
# can compare it against the fingerprint recorded when the key was
@@ -226,10 +226,10 @@ jobs:
if: steps.build.outputs.keystore != ''
run: |
mkdir -p dist
cp "app/build/outputs/apk/release/app-release.apk" dist/thoughtsync.apk
size="$(wc -c < dist/thoughtsync.apk | tr -d ' ')"
sha="$(sha256sum dist/thoughtsync.apk | cut -d' ' -f1)"
cat > dist/thoughtsync-android.json <<JSON
cp "app/build/outputs/apk/release/app-release.apk" dist/inkwell.apk
size="$(wc -c < dist/inkwell.apk | tr -d ' ')"
sha="$(sha256sum dist/inkwell.apk | cut -d' ' -f1)"
cat > dist/inkwell-android.json <<JSON
{
"version_name": "${{ steps.build.outputs.name }}",
"version_code": ${{ steps.build.outputs.code }},
@@ -237,7 +237,7 @@ jobs:
"sha256": "$sha"
}
JSON
cat dist/thoughtsync-android.json
cat dist/inkwell-android.json
# The rolling channel for this branch, the same fixed-tag releases the desktop
# bundles use. CI artifacts are per-run and auth-gated, so they are no use as a
@@ -256,25 +256,27 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
case "$GITHUB_REF_NAME" in
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
main) channel=stable; RELEASE_PRERELEASE=false ;;
*) channel=dev; RELEASE_PRERELEASE=true ;;
esac
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
# (packaging/channel-tag.sh). A tag named `dev` shadowed the branch.
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing the APK to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh
- name: Upload the APK
# Mirrored action, never actions/upload-artifact. @v4+ throws
# GHESNotSupportedError client-side on this hostname, and @v3 is worse —
# it reports success while Gitea serves artifacts back only through the
# v4 API, so the upload is stored and invisible. Pinned by SHA because
# the mirror auto-syncs; full URL because DEFAULT_ACTIONS_URL sends bare
# owner/repo to github.com. See Scribe issues 2255 / 2270.
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
# Stock action: it works on this forge since the runner moved to
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
# out of the action bundle (Scribe snippet #2271). Never @v3 — it reports
# success while Gitea serves artifacts back only through the v4 API, so the
# upload is stored and invisible (Scribe 2270).
uses: actions/upload-artifact@v7
with:
# The APK's variant, NOT the Cargo profile — those are the same word
# for different things and the profile is pinned to debug (#2810).
name: thoughtsync-android-${{ steps.build.outputs.label }}-${{ github.sha }}
name: inkwell-android-${{ steps.build.outputs.label }}-${{ github.sha }}
path: ${{ steps.build.outputs.apk }}
if-no-files-found: error
+41 -43
View File
@@ -54,7 +54,7 @@ permissions:
env:
REGISTRY: git.fabledsword.com
IMAGE: git.fabledsword.com/bvandeusen/thoughtsync
IMAGE: git.fabledsword.com/bvandeusen/inkwell
jobs:
# Should this push build an image now, or is the Android lane about to publish a
@@ -143,8 +143,12 @@ jobs:
echo "build=true" >> $GITHUB_OUTPUT
fi
# The web's whole verification: the type check, then its unit tests. Both in the
# job `build` already needs, so a red web test blocks the image like any other
# lane (rule 177). The unit tests arrived with #5166; before them the web copy of
# the note grammar, mirrored in Rust and Python, was guarded by nothing.
typecheck:
name: TypeScript typecheck
name: Web typecheck and unit tests
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
container:
@@ -156,8 +160,14 @@ jobs:
run: npm ci
working-directory: frontend
# tsconfig.test.json, not the default: the default leaves the unit tests out
# so the Docker build (which has only frontend/) can type-check the app.
- name: Type check
run: npx vue-tsc --noEmit
run: npx vue-tsc --noEmit -p tsconfig.test.json
working-directory: frontend
- name: Unit tests
run: npm test
working-directory: frontend
lint:
@@ -215,11 +225,11 @@ jobs:
# Postgres it will actually meet.
image: postgres:16-alpine
env:
POSTGRES_USER: thoughtsync
POSTGRES_USER: inkwell
POSTGRES_PASSWORD: ci_integration
POSTGRES_DB: thoughtsync_test
POSTGRES_DB: inkwell_test
options: >-
--health-cmd "pg_isready -U thoughtsync"
--health-cmd "pg_isready -U inkwell"
--health-interval 10s
--health-timeout 5s
--health-retries 10
@@ -243,7 +253,7 @@ jobs:
test -n "$PG"
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
test -n "$PG_IP"
export THOUGHTSYNC_DATABASE_URL="postgresql+asyncpg://thoughtsync:ci_integration@${PG_IP}:5432/thoughtsync_test"
export INKWELL_DATABASE_URL="postgresql+asyncpg://inkwell:ci_integration@${PG_IP}:5432/inkwell_test"
# Wait for Postgres to accept connections. `run:` is busybox sh (rule 81) —
# no bash /dev/tcp — so use the Python that is always present here.
/opt/venv/bin/python - "$PG_IP" <<'PY'
@@ -323,53 +333,41 @@ jobs:
docker system prune -af || true
docker builder prune --keep-storage 5g -f || true
# Bake the Android client in, on EVERY image build, so :dev, :latest and
# :<version> all carry one and a `docker compose pull` delivers a new client
# along with the new server.
# Bake EVERY client in, on every image build, so a self-hoster gets a working
# app for their machine from the server holding their notes — without an
# account on this forge, which is private (issue 2091) and is why serving them
# from a release page was never an option for anybody but the operator.
#
# Always the rolling `dev` release — the newest build there is. A versioned
# image therefore carries the newest client rather than one pinned to that
# version; the two negotiate a sync protocol version before linking, so
# "newest" is safe in a way "matching" would not buy anything over.
# ~104 MB on top of the ~85 MB image, almost all of it the AppImage. That is
# the price of the product being complete (rule 23), and the AppImage is not
# optional within it: it is the ONLY bundle that can replace itself in place,
# so a server without one cannot serve in-app updates to anyone.
#
# Fetched by the JOB, not by the Dockerfile: the release is private, and a
# Fetched by the JOB, not by the Dockerfile: the releases are private, and a
# token used inside a build lands in the context or a layer.
#
# NEVER fails the build. An image with no Android client advertises none and
# hides the download — a supported state, and the only one available before
# the first Android build has ever published.
- name: Fetch the Android client to bake in
# NEVER fails the build — see the script. A platform with nothing published
# means the server advertises nothing for it and the UI hides that download,
# which is a supported state and the only one available before that platform's
# first build has ever published.
- name: Fetch the clients to bake in
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
mkdir -p client
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves the dev
# client; :latest serves the stable one. This read `download/dev`
# unconditionally until M314 step 3, on every branch — so every stable
# server shipped a dev-channel APK to anyone who downloaded the client
# from it. Not a versioning gap; a plain defect, fixed here because this
# is the step that gave `stable` an APK to point at.
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves dev clients;
# :latest serves stable ones. This read `download/dev` unconditionally
# until M314 step 3, on every branch — so every stable server shipped a
# dev-channel APK to anyone who downloaded the client from it. Not a
# versioning gap; a plain defect, and the reason the channel is chosen here
# rather than inside the script: the caller is what knows which image it is
# building.
case "${{ github.ref_name }}" in
main) channel=stable ;;
*) channel=dev ;;
esac
echo "Baking in the $channel client."
base="${{ github.server_url }}/${{ github.repository }}/releases/download/$channel"
ok=1
for f in thoughtsync.apk thoughtsync-android.json; do
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "client/$f" "$base/$f" || ok=0
done
if [ "$ok" = 1 ]; then
echo "Baking in:"
cat client/thoughtsync-android.json
ls -l client/thoughtsync.apk
else
# Both or neither. Half a pair is worse than none: the server would
# read a sidecar describing an APK that isn't there, or an APK it
# cannot state a version for.
echo "::warning::No Android client on the dev release — this image ships without one."
rm -f client/thoughtsync.apk client/thoughtsync-android.json
fi
sh packaging/fetch-clients.sh "$channel" client
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
+116 -45
View File
@@ -75,41 +75,47 @@ jobs:
sh packaging/guard-forward.sh desktop "$channel"
echo "build=$(sh packaging/should-build.sh desktop "$channel")" >> $GITHUB_OUTPUT
build:
name: Tauri desktop (Linux)
# The workspace checks, in their OWN job, so that BOTH publishing jobs can need
# them (rule 177: nothing publishes on red).
#
# They used to be steps inside `build`, which gated the Linux publish and nothing
# else. `windows` needed only `decide`, so on run 8411 clippy failed, the Linux job
# stopped, and the Windows installer built and published to the dev release in the
# same minute (#5184). An edge from each publisher to this job is the gate; a
# verdict inside a sibling job is only a report.
#
# Both publishers need it directly rather than through each other, so a Windows
# failure still never blocks the Linux bundles, and neither waits on the other's
# bundling. No `always()` or `continue-on-error` anywhere on this path: a skipped
# or failed verify must leave both publishers skipped.
verify:
name: Web tests, clippy, Rust tests and rustfmt
needs: [decide]
if: needs.decide.outputs.build == 'true'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
env:
# AppImage tooling (linuxdeploy) FUSE-mounts itself by default; CI containers
# have no /dev/fuse, so tell it to extract-and-run instead. Without this the
# AppImage bundle step fails with a FUSE error.
APPIMAGE_EXTRACT_AND_RUN: "1"
steps:
# No version is derived here, so the default shallow checkout is enough.
- uses: actions/checkout@v6
with:
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
# the lane green — note 3127 §6.1, and the direction you cannot recover
# from. `packaging/version.sh` fails loudly on an empty result rather than
# emitting something plausible, which is what turns this into a red lane
# if it is ever dropped.
fetch-depth: 0
# tauri's generate_context! embeds the built frontend at compile time, so the
# frontend must exist before any cargo compile (clippy/test/build), not just
# at bundle time.
# frontend must exist before clippy or the tests can compile the desktop crate.
- name: Build the shared frontend
run: npm ci && npm run build
working-directory: frontend
# --locked on the FIRST cargo invocation of the job is the lockfile gate: it
# fails the run if Cargo.toml and the committed Cargo.lock disagree, instead
# of silently re-resolving. Everything after it in this job then compiles the
# exact versions recorded in the lockfile, so the flag isn't repeated on the
# bundle build (issue 2102).
# The web's unit tests, HERE as well as in ci.yml. The installers embed this
# frontend, and ci.yml's verdict is invisible to this workflow — run there only,
# a red web test would still let both installers publish (rule 177).
- name: Web unit tests
run: npm test
working-directory: frontend
# --locked on the FIRST cargo invocation is the lockfile gate: it fails the
# run if Cargo.toml and the committed Cargo.lock disagree, instead of silently
# re-resolving (issue 2102). Both publishing jobs need this job, so neither
# bundles a commit whose lockfile drifted.
#
# Run from the REPO ROOT with --workspace, not from desktop/src-tauri.
#
@@ -132,11 +138,40 @@ jobs:
# but there is no Rust toolchain on the workstation (the desktop lane is
# verified entirely here), so a formatting nit failing first SKIPS clippy and
# the tests, and one CI cycle teaches nothing but whitespace. Running it here
# means every push reports its real problems too. Still before the ~20-40 min
# bundle build, so a fmt failure doesn't burn that.
# means every push reports its real problems too. Still before either bundle
# build, so a fmt failure doesn't burn one.
- name: Rust format check
run: cargo fmt --all --check
build:
name: Tauri desktop (Linux)
needs: [decide, verify]
if: needs.decide.outputs.build == 'true'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
env:
# AppImage tooling (linuxdeploy) FUSE-mounts itself by default; CI containers
# have no /dev/fuse, so tell it to extract-and-run instead. Without this the
# AppImage bundle step fails with a FUSE error.
APPIMAGE_EXTRACT_AND_RUN: "1"
steps:
- uses: actions/checkout@v6
with:
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
# the lane green — note 3127 §6.1, and the direction you cannot recover
# from. `packaging/version.sh` fails loudly on an empty result rather than
# emitting something plausible, which is what turns this into a red lane
# if it is ever dropped.
fetch-depth: 0
# tauri's generate_context! embeds the built frontend at compile time, so the
# frontend must exist before cargo compiles anything, not just at bundle time.
- name: Build the shared frontend
run: npm ci && npm run build
working-directory: frontend
# Frontend already built above; skip the beforeBuildCommand rebuild.
#
# createUpdaterArtifacts is applied only when a signing key exists (M10.9):
@@ -162,6 +197,14 @@ jobs:
# separate value and arrives with the UI that shows it (#3181).
version="$(sh ../../packaging/version.sh key desktop)"
echo "Building desktop ordering key $version"
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
# A different value for a different audience: this is the one a person
# quotes in a bug report, the key above is the one only a comparator
# sees. Exported rather than passed as a flag because the macro that
# reads it is in Rust source, not in Tauri's config.
INKWELL_DISPLAY_VERSION="$(sh ../../packaging/version.sh display desktop)"
export INKWELL_DISPLAY_VERSION
echo "Baking display version $INKWELL_DISPLAY_VERSION"
cargo tauri build \
--config '{"build":{"beforeBuildCommand":""}}' \
--config "{\"version\":\"$version\"}" \
@@ -222,18 +265,17 @@ jobs:
run: bash desktop/packaging/arch/package-prebuilt.sh
# Make the built .deb + .AppImage downloadable from the run (for hand-testing).
# Mirrored action, never actions/upload-artifact: @v4+ throws
# GHESNotSupportedError on the hostname before it connects, and @v3 uploads
# Stock action: it works on this forge since the runner moved to
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
# out of the action bundle (Scribe snippet #2271). Never @v3 — it uploads
# something Gitea stores but will never serve back (it returns artifacts only
# through the v4 API, which filters on content_encoding='application/zip').
# Pinned by SHA — the mirror auto-syncs, so a moved upstream tag would
# silently change what runs. See Scribe issues 2255 / 2270.
# No continue-on-error: a swallowed upload failure is exactly how 110
# unreachable artifacts accumulated here unnoticed. Fail loudly instead.
- name: Upload bundles
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
uses: actions/upload-artifact@v7
with:
name: thoughtsync-linux
name: inkwell-linux
path: |
target/release/bundle/appimage/*.AppImage
target/release/bundle/deb/*.deb
@@ -271,9 +313,12 @@ jobs:
# `prerelease` is true for dev so it does not read as a supported build,
# and false for stable, which is the real thing.
case "$GITHUB_REF_NAME" in
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
main) channel=stable; RELEASE_PRERELEASE=false ;;
*) channel=dev; RELEASE_PRERELEASE=true ;;
esac
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
# (packaging/channel-tag.sh). A tag named `dev` shadowed the branch.
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh
@@ -293,7 +338,7 @@ jobs:
# built, not that it runs. A real-machine check stays mandatory before trusting it.
windows:
name: Windows installer (cross-compiled)
needs: [decide]
needs: [decide, verify]
if: needs.decide.outputs.build == 'true'
runs-on: python-ci
container:
@@ -324,8 +369,8 @@ jobs:
run: cargo tauri icon app-icon.png
working-directory: desktop/src-tauri
# This lane's lockfile gate (the Linux job gets it from `cargo clippy
# --locked`). It has to be its own step here because the build is this job's
# This lane's own lockfile check (`verify` has already run `cargo clippy
# --locked` for the workspace). It has to be its own step here because the build is this job's
# only crate-graph command, and discovering the drift 30 minutes into a
# cross-compile is the expensive way to learn it. Fetching for the Windows
# target also pre-warms exactly the crates the build will want.
@@ -347,6 +392,14 @@ jobs:
# separate value and arrives with the UI that shows it (#3181).
version="$(sh ../../packaging/version.sh key desktop)"
echo "Building desktop ordering key $version"
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
# A different value for a different audience: this is the one a person
# quotes in a bug report, the key above is the one only a comparator
# sees. Exported rather than passed as a flag because the macro that
# reads it is in Rust source, not in Tauri's config.
INKWELL_DISPLAY_VERSION="$(sh ../../packaging/version.sh display desktop)"
export INKWELL_DISPLAY_VERSION
echo "Baking display version $INKWELL_DISPLAY_VERSION"
updater='{}'
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
updater='{"bundle":{"createUpdaterArtifacts":true}}'
@@ -360,13 +413,11 @@ jobs:
--config "$updater"
working-directory: desktop/src-tauri
# Mirrored action, never actions/upload-artifact — see the Linux job's
# Upload bundles step for the full reasoning. Pinned by SHA because the
# mirror auto-syncs.
# Stock action — see the Linux job's Upload bundles step for why.
- name: Upload installer
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
uses: actions/upload-artifact@v7
with:
name: thoughtsync-windows
name: inkwell-windows
path: target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
if-no-files-found: error
@@ -399,9 +450,12 @@ jobs:
# `prerelease` is true for dev so it does not read as a supported build,
# and false for stable, which is the real thing.
case "$GITHUB_REF_NAME" in
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
main) channel=stable; RELEASE_PRERELEASE=false ;;
*) channel=dev; RELEASE_PRERELEASE=true ;;
esac
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
# (packaging/channel-tag.sh). A tag named `dev` shadowed the branch.
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh
@@ -447,6 +501,12 @@ jobs:
# match the binary it points at is an updater that never settles. It must
# be `key`: this value is matched against bundle filenames.
version="$(sh packaging/version.sh key desktop)"
# The version a PERSON reads, published beside the manifest as
# `inkwell-desktop.json`. The image build reads it to describe the
# bundles it bakes in (packaging/fetch-clients.sh) without re-deriving
# anything from its own checkout — which would be a different commit
# whenever the desktop did not rebuild.
display="$(sh packaging/version.sh display desktop)"
# Both channels are rolling: the manifest lands on the same release that
# holds the bundles, and the previous build's bundles are dropped once it
# points at this one. Nothing can reach them, and they are ~100 MB a push.
@@ -454,10 +514,21 @@ jobs:
# No tag arm any more. A `v*` tag does not reach this workflow at all — it
# triggers release.yml, which writes a changelog and builds nothing.
case "${GITHUB_REF_NAME}" in
main) export RELEASE_TAG=stable
main) RELEASE_TAG="$(sh packaging/channel-tag.sh stable)"
export RELEASE_NOTES="Stable build from ${GITHUB_SHA}" ;;
*) export RELEASE_TAG=dev
*) RELEASE_TAG="$(sh packaging/channel-tag.sh dev)"
# TEMPORARY one-shot bridge (Scribe #2184). Desktop apps installed
# before the rename have .../download/dev/latest.json compiled in,
# so the manifest is also written to the old `dev` release; its
# URLs name dev-rolling assets, so those apps update once into a
# build that reads the new tag. Delete this line together with the
# old `dev` release and tag.
export BRIDGE_TAG=dev
export RELEASE_NOTES="Development build from ${GITHUB_SHA}" ;;
esac
# Assigned bare above so a failing channel-tag.sh fails this step;
# `export X="$(...)"` would swallow its exit status.
export RELEASE_TAG
export PRUNE_OLD_ASSETS=true
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
APP_VERSION="$version" DISPLAY_VERSION="$display" \
bash desktop/packaging/write-manifest.sh
+2 -2
View File
@@ -209,5 +209,5 @@ android/local.properties
# The Android client CI bakes into the server image. Fetched fresh on every image
# build, so it is never worth 55 MiB of git history. client/.keep IS tracked, so
# the Dockerfile's COPY always has a directory to copy.
client/thoughtsync.apk
client/thoughtsync-android.json
client/inkwell.apk
client/inkwell-android.json
Generated
+681 -59
View File
File diff suppressed because it is too large Load Diff
+16 -10
View File
@@ -20,22 +20,28 @@ RUN --mount=type=cache,target=/root/.cache/pip \
# Bake the built SPA into the package's static dir (served by app.py). PYTHONPATH
# points at /app/src so the runtime imports this source tree (with static/ present),
# not the pip-installed copy.
COPY --from=build-frontend /build/dist/ src/thoughtsync/static/
COPY --from=build-frontend /build/dist/ src/inkwell/static/
COPY alembic.ini .
COPY alembic/ alembic/
# The Android client this server hands out. CI fetches the newest published build
# into ./client immediately before this runs (ci.yml), so every image tag — :dev,
# :latest and :<version> alike — ships a client, and a `docker compose pull`
# delivers a new one with no file copying by hand.
# The clients this server hands out — the APK and all four desktop bundles. CI
# fetches the newest published build of each into ./client immediately before this
# runs (packaging/fetch-clients.sh), so both image tags ship a full set and a
# `docker compose pull` delivers new ones with no file copying by hand.
#
# Fetched by the JOB rather than here on purpose: the release is private, and a
# ~104 MB of this image is that set, almost all of it the AppImage.
#
# Fetched by the JOB rather than here on purpose: the releases are private, and a
# token used inside a build ends up in the build context or a layer.
#
# LAST of the COPYs, deliberately: this directory changes on every build, so
# putting it above the `pip install` layer would invalidate that layer every time.
#
# The directory is tracked (client/.keep) so this COPY cannot fail on a tree where
# that step never ran. An image with no APK is a supported state — the server
# advertises nothing and the web UI hides the download (client_dist.py).
COPY client/ src/thoughtsync/client/
# that step never ran. An image with no clients — or with some and not others — is
# a supported state: the server advertises what it has and the web UI hides the
# rest (client_dist.py).
COPY client/ src/inkwell/client/
ENV PYTHONPATH=/app/src
@@ -46,4 +52,4 @@ EXPOSE 5000
# Wait for the database, run migrations, then serve. The DB wait keeps a briefly
# slow/unready database from crash-looping the container. Family convention
# (rule 82): schema is built by real migrations, never metadata.create_all.
CMD ["sh", "-c", "python -m thoughtsync.dbwait && alembic upgrade head && hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
CMD ["sh", "-c", "python -m inkwell.dbwait && alembic upgrade head && hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
+16 -16
View File
@@ -1,4 +1,4 @@
# ThoughtSync
# Inkwell
Self-hosted personal thought-capture web app in the **FabledSword** family — a
Google-Keep-style **masonry post-it board** for capturing disparate thoughts in
@@ -13,7 +13,7 @@ under a second, designed to grow into a lightweight second brain (labels, search
## Layout
```
src/thoughtsync/ Quart app (app factory, auth, models, ACL, config, db)
src/inkwell/ Quart app (app factory, auth, models, ACL, config, db)
alembic/ async migrations (schema built via `alembic upgrade head`)
tests/ DB-free unit tests (pytest)
frontend/ Vue 3 + Vite + TypeScript + Tailwind SPA
@@ -23,12 +23,12 @@ docker-compose.yml local app + Postgres stack
## Development
Backend (needs a Postgres reachable at `THOUGHTSYNC_DATABASE_URL`):
Backend (needs a Postgres reachable at `INKWELL_DATABASE_URL`):
```sh
pip install -e ".[dev]"
alembic upgrade head
hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000
hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000
```
Frontend (proxies `/api` to `:5000`):
@@ -64,40 +64,40 @@ services:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: thoughtsync
POSTGRES_USER: inkwell
POSTGRES_PASSWORD: CHANGE_ME # change this
POSTGRES_DB: thoughtsync
POSTGRES_DB: inkwell
volumes:
- thoughtsync-db:/var/lib/postgresql/data
- inkwell-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U thoughtsync"]
test: ["CMD-SHELL", "pg_isready -U inkwell"]
interval: 5s
timeout: 5s
retries: 10
app:
image: git.fabledsword.com/bvandeusen/thoughtsync:latest # :dev for the current dev build
image: git.fabledsword.com/bvandeusen/inkwell:latest # :dev for the current dev build
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
THOUGHTSYNC_DATABASE_URL: postgresql+asyncpg://thoughtsync:CHANGE_ME@db:5432/thoughtsync
INKWELL_DATABASE_URL: postgresql+asyncpg://inkwell:CHANGE_ME@db:5432/inkwell
volumes:
- thoughtsync-data:/var/thoughtsync # uploaded images; omit if you don't use attachments
- inkwell-data:/var/inkwell # uploaded images; omit if you don't use attachments
ports:
- "5000:5000"
volumes:
thoughtsync-db:
thoughtsync-data:
inkwell-db:
inkwell-data:
```
Then open `http://<host>:5000` and register — **the first account becomes the admin**.
- **Only `THOUGHTSYNC_DATABASE_URL` is required.** `THOUGHTSYNC_SECRET_KEY` is optional; if
- **Only `INKWELL_DATABASE_URL` is required.** `INKWELL_SECRET_KEY` is optional; if
unset, a signing key is generated and persisted in the database (sessions survive restarts).
- Uploaded images live under the `thoughtsync-data` volume at `/var/thoughtsync`.
- Uploaded images live under the `inkwell-data` volume at `/var/inkwell`.
- The app waits for the database and runs migrations (`alembic upgrade head`) automatically on start.
- **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev`
build) · `:<git-sha>` on `main` only (immutable, the rollback unit). There are
@@ -108,7 +108,7 @@ Then open `http://<host>:5000` and register — **the first account becomes the
port, and back up the attachment volume as well as the database. See
[docs/public-hosting.md](docs/public-hosting.md), which also lists what the app
hardens on its own and what it deliberately doesn't.
- **Install as an app (PWA):** ThoughtSync is installable ("Add to Home Screen" / the
- **Install as an app (PWA):** Inkwell is installable ("Add to Home Screen" / the
browser's install button) for an app-like window. Browsers only offer install over a
**secure context**, so put the app behind a reverse proxy terminating **HTTPS** (or reach
it via `localhost`) — plain `http://<host>:5000` won't show the install prompt.
+3 -3
View File
@@ -1,12 +1,12 @@
# Alembic single-database async configuration for ThoughtSync.
# Alembic single-database async configuration for Inkwell.
[alembic]
script_location = %(here)s/alembic
prepend_sys_path = . src
path_separator = os
# Local-dev default; overridden at runtime by THOUGHTSYNC_DATABASE_URL (see env.py).
sqlalchemy.url = postgresql+asyncpg://thoughtsync:thoughtsync@localhost:5432/thoughtsync
# Local-dev default; overridden at runtime by INKWELL_DATABASE_URL (see env.py).
sqlalchemy.url = postgresql+asyncpg://inkwell:inkwell@localhost:5432/inkwell
[loggers]
+3 -3
View File
@@ -8,8 +8,8 @@ from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from thoughtsync.models import Base
import thoughtsync.models.all # noqa: F401 — registers every model on Base.metadata
from inkwell.models import Base
import inkwell.models.all # noqa: F401 — registers every model on Base.metadata
config = context.config
@@ -18,7 +18,7 @@ if config.config_file_name is not None:
config.set_main_option(
"sqlalchemy.url",
os.environ.get("THOUGHTSYNC_DATABASE_URL", config.get_main_option("sqlalchemy.url")),
os.environ.get("INKWELL_DATABASE_URL", config.get_main_option("sqlalchemy.url")),
)
target_metadata = Base.metadata
@@ -0,0 +1,51 @@
"""a stored site_name of the old default follows the rename to Inkwell
Revision ID: 0030
Revises: 0029
Create Date: 2026-10-06
The product is renamed ThoughtSync → Inkwell (Scribe note 5071), and the `site_name`
registry default moves with it. On its own that only reaches servers whose settings
table has no `site_name` row — and most servers have one, because the Settings page
saves EVERY key on Save, not just the one that changed. An admin who opened Settings
to flip registration off has persisted `"ThoughtSync"` without ever choosing it.
So the row is rewritten, but only when it holds exactly the old default. A name the
admin actually typed is theirs and is left alone; the old default is the one value we
can be sure nobody chose.
The match is on the stored JSON text (`settings.value` is `json.dumps(value)`), so
`'"ThoughtSync"'` is the whole of what it looks for.
## Downgrade
Puts the old default back on a row holding exactly the new one. A server where the
admin typed "Inkwell" themselves between the two cannot be told apart from one this
migration rewrote; on downgrade both read "ThoughtSync", which is what the old code
would have shown for either.
"""
from alembic import op
import sqlalchemy as sa
revision = "0030"
down_revision = "0029"
branch_labels = None
depends_on = None
_OLD = '"ThoughtSync"'
_NEW = '"Inkwell"'
def _swap(old: str, new: str) -> None:
op.get_bind().execute(
sa.text("UPDATE settings SET value = :new WHERE key = 'site_name' AND value = :old"),
{"old": old, "new": new},
)
def upgrade() -> None:
_swap(_OLD, _NEW)
def downgrade() -> None:
_swap(_NEW, _OLD)
@@ -0,0 +1,38 @@
"""a link preview's insert, update or delete bumps its note's sync revision
Revision ID: 0031
Revises: 0030
Create Date: 2026-10-07
0015 made every child table bump its parent note's `sync_revision`, so a note syncs
as a whole. `note_link_previews` arrived later (0020) and was never added, and two
things have been missing on every linked device since:
- A preview fetched in the background after a save (`unfurl_queue.py`) never reached
a device that had already pulled the note. The note's revision was assigned when
the TEXT was saved, before the preview existed, and nothing moved it afterwards.
- A preview dismissed on the web stayed on every other device, for the same reason.
The trigger is the same function 0015 installs on the other child tables.
## Downgrade
Drops the trigger. Previews go back to not propagating; nothing is lost.
"""
from alembic import op
revision = "0031"
down_revision = "0030"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"CREATE TRIGGER trg_note_link_previews_bump_note AFTER INSERT OR UPDATE OR DELETE "
"ON note_link_previews FOR EACH ROW EXECUTE PROCEDURE ts_bump_parent_note_revision()"
)
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS trg_note_link_previews_bump_note ON note_link_previews")
+42
View File
@@ -0,0 +1,42 @@
"""invites: single-use, expiring registration links an admin issues
Revision ID: 0032
Revises: 0031
Create Date: 2026-10-07
Until now the only way to add a second person was to re-open registration to the
whole internet while they signed up (#2939 §1). An invite lets one person register
while registration stays closed. Only the token's SHA-256 hash is stored.
## Downgrade
Drops the table. Accounts created through invites are untouched; the record of who
invited them goes with it.
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import CITEXT, UUID
revision = "0032"
down_revision = "0031"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"invites",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("token_hash", sa.Text(), nullable=False, unique=True),
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("email", CITEXT(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("redeemed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("redeemed_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_table("invites")
+50
View File
@@ -0,0 +1,50 @@
"""password resets: an admin-issued, one-hour link; sessions an account can outlive
Revision ID: 0033
Revises: 0032
Create Date: 2026-10-07
A forgotten password used to need a hand on the database (#2939 §2), and the app has
no mail path to send a reset by. An admin makes a reset link for the account and
hands it over (#5173). Only the token's SHA-256 hash is stored.
`users.session_epoch` is what lets a reset sign the account out everywhere. Sessions
are signed cookies held by the browser, so the server can't delete them; each one
carries the epoch it was signed in under, and a reset moves the account's epoch on.
It starts at 0, the value a cookie from before this migration is read as, so nobody
is signed out by the upgrade itself.
## Downgrade
Drops the table and the column. Outstanding reset links stop working; sessions keep
working, since nothing checks an epoch any more.
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision = "0033"
down_revision = "0032"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("users", sa.Column("session_epoch", sa.Integer(), nullable=False, server_default="0"))
op.create_table(
"password_resets",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("token_hash", sa.Text(), nullable=False, unique=True),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_password_resets_user_id", "password_resets", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_password_resets_user_id", table_name="password_resets")
op.drop_table("password_resets")
op.drop_column("users", "session_epoch")
@@ -0,0 +1,50 @@
"""shares: one grant per note and person, and only the permissions the app knows
Revision ID: 0034
Revises: 0033
Create Date: 2026-10-07
Nothing wrote a share until #5174, so the table never needed these. Now the Share
dialog does:
- A unique index on (resource_type, resource_id, shared_with_user_id) where a user is
the target, so sharing a note with someone twice updates the one grant rather than
stacking two (the same for a group, ahead of step 15).
- A check that `permission` is `view` or `edit`.
- An index on `shared_with_user_id` for "Shared with me".
## Downgrade
Drops the indexes and the check. The rows stay.
"""
from alembic import op
revision = "0034"
down_revision = "0033"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_index(
"uq_shares_resource_user",
"shares",
["resource_type", "resource_id", "shared_with_user_id"],
unique=True,
postgresql_where="shared_with_user_id IS NOT NULL",
)
op.create_index(
"uq_shares_resource_group",
"shares",
["resource_type", "resource_id", "shared_with_group_id"],
unique=True,
postgresql_where="shared_with_group_id IS NOT NULL",
)
op.create_index("ix_shares_user", "shares", ["shared_with_user_id"])
op.create_check_constraint("ck_shares_permission", "shares", "permission IN ('view', 'edit')")
def downgrade() -> None:
op.drop_constraint("ck_shares_permission", "shares", type_="check")
op.drop_index("ix_shares_user", table_name="shares")
op.drop_index("uq_shares_resource_group", table_name="shares")
op.drop_index("uq_shares_resource_user", table_name="shares")
@@ -0,0 +1,50 @@
"""share_revocations: telling a recipient's devices a shared note has left them
Revision ID: 0035
Revises: 0034
Create Date: 2026-10-07
The change feed carries every note a person can see, including notes shared with
them (#5175). When a share ends, the note stops being visible, so it simply stops
appearing in the feed. A device that already holds a copy would keep it forever. A
revocation is that missing signal: one row per (note, person) who lost the note,
stamped from the same `sync_revision_seq` the notes and labels draw from, so it sits
on the one cursor every client already pages by.
Sharing the note with that person again deletes the row, so a device that never
heard of the revocation never gets told to delete a note it is meant to have.
## Downgrade
Drops the table. Devices that missed a revocation keep their copy.
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision = "0035"
down_revision = "0034"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"share_revocations",
sa.Column("note_id", UUID(as_uuid=True), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column(
"sync_revision",
sa.BigInteger(),
nullable=False,
server_default=sa.text("nextval('sync_revision_seq')"),
),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint("note_id", "user_id"),
)
op.create_index("ix_share_revocations_user_revision", "share_revocations", ["user_id", "sync_revision"])
def downgrade() -> None:
op.drop_index("ix_share_revocations_user_revision", table_name="share_revocations")
op.drop_table("share_revocations")
+55
View File
@@ -0,0 +1,55 @@
"""note_user_state: a recipient's own pin, archive and place for a shared note
Revision ID: 0036
Revises: 0035
Create Date: 2026-10-07
Pin, archive and board order are personal organization, and until now they were
columns on `notes`, so on a shared note they could only ever mean the owner's
(#5176). This table holds them for everyone else: one row per (note, person it is
shared with) who has pinned, archived or moved it. The owner keeps the note's own
columns; a recipient with no row sees the note unpinned, unarchived, in the owner's
order.
`sync_revision` is stamped by the same `ts_set_sync_revision()` trigger the notes
and labels use (0015), so a recipient's change reaches their own devices on the one
cursor they already page by, and never moves the note on anyone else's.
## Downgrade
Drops the table. Recipients lose their own pins and archives; the notes are
untouched.
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision = "0036"
down_revision = "0035"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"note_user_state",
sa.Column("note_id", UUID(as_uuid=True), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("pinned", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("archived", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("position", sa.Integer(), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("sync_revision", sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint("note_id", "user_id"),
)
op.create_index("ix_note_user_state_user_revision", "note_user_state", ["user_id", "sync_revision"])
op.execute(
"CREATE TRIGGER trg_note_user_state_sync_revision BEFORE INSERT OR UPDATE ON note_user_state "
"FOR EACH ROW EXECUTE PROCEDURE ts_set_sync_revision()"
)
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS trg_note_user_state_sync_revision ON note_user_state")
op.drop_index("ix_note_user_state_user_revision", table_name="note_user_state")
op.drop_table("note_user_state")
+14 -11
View File
@@ -16,7 +16,7 @@ val workspaceRoot: Directory = layout.projectDirectory.dir("../..")
val androidAbis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
/**
* Cross-compile `thoughtsync-ffi` for each Android ABI and drop the resulting
* Cross-compile `inkwell-ffi` for each Android ABI and drop the resulting
* `.so` into jniLibs, where AGP packages it.
*
* `ExecOperations` injected rather than `project.exec`: the latter was REMOVED in
@@ -49,7 +49,7 @@ abstract class CargoNdkBuild : DefaultTask() {
args += "-t"
args += abi
}
args += listOf("-o", jniLibsDir.get().asFile.absolutePath, "build", "-p", "thoughtsync-ffi")
args += listOf("-o", jniLibsDir.get().asFile.absolutePath, "build", "-p", "inkwell-ffi")
// --locked so an Android build cannot silently re-resolve the workspace
// lockfile the desktop lanes are gated on.
args += "--locked"
@@ -94,7 +94,7 @@ abstract class UniffiBindgen : DefaultTask() {
"run",
"--locked",
"-p",
"thoughtsync-uniffi-bindgen",
"inkwell-uniffi-bindgen",
"--",
"generate",
"--library",
@@ -141,7 +141,7 @@ val rustInputs =
* build, and neither is worth holding signed APKs up for. Scribe #2810.
*/
val rustProfile =
(project.findProperty("THOUGHTSYNC_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() }
(project.findProperty("INKWELL_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() }
?: "debug"
val jniLibsOut = layout.buildDirectory.dir("rustJniLibs")
@@ -149,7 +149,7 @@ val bindingsOut = layout.buildDirectory.dir("generated/uniffi")
val cargoNdk =
tasks.register<CargoNdkBuild>("cargoNdk") {
description = "Cross-compile thoughtsync-ffi for the Android ABIs."
description = "Cross-compile inkwell-ffi for the Android ABIs."
rustSources.from(rustInputs)
abis.set(androidAbis)
cargoProfile.set(rustProfile)
@@ -163,17 +163,17 @@ val generateBindings =
dependsOn(cargoNdk)
// arm64 is arbitrary — every ABI carries the same uniffi metadata, and
// reading one is cheaper than reading four.
libraryFile.set(jniLibsOut.map { it.file("arm64-v8a/libthoughtsync_ffi.so") })
libraryFile.set(jniLibsOut.map { it.file("arm64-v8a/libinkwell_ffi.so") })
workspaceDir.set(workspaceRoot)
outputDir.set(bindingsOut)
}
android {
namespace = "com.fabledsword.thoughtsync"
namespace = "com.fabledsword.inkwell"
compileSdk = 36
defaultConfig {
applicationId = "com.fabledsword.thoughtsync"
applicationId = "com.fabledsword.inkwell"
// 26 (Android 8, 2017) matches Minstrel and clears the NDK's floor with
// room to spare.
minSdk = 26
@@ -181,9 +181,9 @@ android {
// Injected by CI from the git tag + commit count for a release; "dev"
// locally so the About screen reads honestly rather than claiming 1.0.
val nameOverride =
(project.findProperty("THOUGHTSYNC_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
(project.findProperty("INKWELL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
val codeOverride =
(project.findProperty("THOUGHTSYNC_VERSION_CODE") as String?)?.toIntOrNull()
(project.findProperty("INKWELL_VERSION_CODE") as String?)?.toIntOrNull()
versionCode = codeOverride ?: 1
versionName = nameOverride ?: "dev"
@@ -215,7 +215,10 @@ android {
// Hardcoded, and NOT a secret: the alias is fixed for the life of
// this app and is written into the certificate every install
// already carries. Hiding it would buy nothing and stop this file
// describing its own signing setup.
// describing its own signing setup. It still says `thoughtsync`
// after the rename to Inkwell, because it names the key inside the
// existing keystore, and renaming it would only stop that key being
// found. Same key, so the signing certificate did not change either.
keyAlias = "thoughtsync"
// PKCS12 cannot hold a key password distinct from the store
// password — keytool refuses to set one — so this is the same
+1 -1
View File
@@ -4,4 +4,4 @@
# is the worst possible time to learn it.
-keep class com.sun.jna.** { *; }
-keepclassmembers class * extends com.sun.jna.** { public *; }
-keep class com.fabledsword.thoughtsync.core.** { *; }
-keep class com.fabledsword.inkwell.core.** { *; }
+67 -3
View File
@@ -91,23 +91,71 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:name=".ThoughtSyncApplication"
android:name=".InkwellApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ThoughtSync"
android:theme="@style/Theme.Inkwell"
android:usesCleartextTraffic="true">
<!--
launchMode="singleTop" exists for the SHARE filters below.
The reminder notification adds FLAG_ACTIVITY_SINGLE_TOP to its own
intent, so onNewIntent already worked for that one. A share intent is
built by the OTHER app — Chrome, a reader, the text-selection toolbar —
and nothing here can add a flag to it. Without singleTop declared on the
activity itself, every share while the app is running would stack a
second MainActivity on top of the first: a second view model, a second
board, and a back press that lands on a stale copy of the same app.
-->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.ThoughtSync">
android:theme="@style/Theme.Inkwell">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!--
Capture without opening the app first: Share → Inkwell from
anywhere, and the selection toolbar in any text field.
Text, and images one at a time or several at once. A shared image
becomes a note carrying it as an attachment, stored on the phone
and uploaded on the next sync that reaches a server (#5169).
image/* rather than */*: a photo or a screenshot is what people
share into a notes app. Claiming every type would put Inkwell in the
share sheet for APKs, contacts and calendar entries, where it would
be noise. Other files attach from inside the editor, whose picker
takes any type.
-->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<!--
The label is what appears in the text-selection menu beside Copy and
Share, where "Inkwell" would say who rather than what.
-->
<intent-filter android:label="@string/capture_process_text">
<action android:name="android.intent.action.PROCESS_TEXT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<!--
@@ -126,6 +174,22 @@
PackageInstaller. Without it a failed install would be indistinguishable
from someone declining the dialog (Scribe #2438).
-->
<!--
Hands an attachment to the app that opens its type. Not exported, as a
FileProvider must not be: access is granted one URI at a time, on the
intent that opens it. It serves only the cache copies listed in
res/xml/file_paths.xml, never the note store.
-->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<receiver
android:name=".UpdateReceiver"
android:exported="false" />
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync
package com.fabledsword.inkwell
import android.content.Context
import android.content.Intent
@@ -42,7 +42,7 @@ import java.io.File
* [UpdateReceiver], which is why a failure can be shown rather than guessed at.
*/
object AppUpdate {
private const val TAG = "ThoughtSyncUpdate"
private const val TAG = "InkwellUpdate"
/** This build's versionCode — what the server's is compared against. */
fun installedVersionCode(context: Context): Long =
@@ -173,5 +173,5 @@ object AppUpdate {
.intentSender
}
private const val WRITE_NAME = "thoughtsync-update"
private const val WRITE_NAME = "inkwell-update"
}
@@ -0,0 +1,21 @@
package com.fabledsword.inkwell
import android.content.Context
/**
* The `versionName` of the INSTALLED package, or null when it cannot be read.
*
* From the package manager rather than from `BuildConfig`: this reports what is
* actually on the phone, which is the question both callers are asking — a bug
* report reading the foot of Sync, and a server log reading the client header. It
* also needs no `buildFeatures.buildConfig`, which this module does not enable.
*
* Returns null rather than a fallback string, because the two callers want
* different ones: the UI wants a localized "unknown" from string resources, the
* client header wants the literal the core recognizes. Note 3127 §5 governs both —
* with no version tags, the artifact's self-report is the only answer to "which
* build is this?", so a missing name must read as missing and never as a plausible
* default that nothing can contradict.
*/
fun Context.installedVersionName(): String? =
runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull()
@@ -1,8 +1,9 @@
package com.fabledsword.thoughtsync
package com.fabledsword.inkwell
import android.app.Application
import android.util.Log
import com.fabledsword.thoughtsync.core.ThoughtSync
import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.inkwell.core.setClientAgent
/**
* Opens the shared Rust core once, for the process lifetime.
@@ -15,14 +16,14 @@ import com.fabledsword.thoughtsync.core.ThoughtSync
* removed on uninstall, and never on external media. The core does not guess at
* platform paths; Android is the only thing that knows where this is.
*/
class ThoughtSyncApplication : Application() {
class InkwellApplication : Application() {
/**
* Null only if the store could not be opened — a corrupt or unwritable
* database. The UI reports that honestly rather than crashing on first
* touch, because a user whose notes won't open needs a message, not a
* stack trace.
*/
var core: ThoughtSync? = null
var core: Inkwell? = null
private set
var openFailure: String? = null
@@ -30,8 +31,16 @@ class ThoughtSyncApplication : Application() {
override fun onCreate() {
super.onCreate()
// Introduce this app to any server it links to, before anything can sync.
// The core cannot name us — the same crate is compiled into the desktop app,
// and it used to announce every phone as `inkwell-desktop` carrying the
// core crate's own version. "unknown" rather than a guess when the package
// manager will not say (note 3127 §5).
setClientAgent("inkwell-android", installedVersionName() ?: "unknown")
try {
val handle = ThoughtSync(filesDir.absolutePath)
val handle = Inkwell(filesDir.absolutePath)
core = handle
Log.i(TAG, "local store ready — ${handle.summary()}")
} catch (e: Exception) {
@@ -44,6 +53,6 @@ class ThoughtSyncApplication : Application() {
}
private companion object {
const val TAG = "ThoughtSync"
const val TAG = "Inkwell"
}
}
@@ -1,7 +1,8 @@
package com.fabledsword.thoughtsync
package com.fabledsword.inkwell
import android.Manifest
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
@@ -11,6 +12,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
@@ -21,21 +23,28 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.core.content.IntentCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import com.fabledsword.thoughtsync.core.ThoughtSync
import com.fabledsword.thoughtsync.ui.BoardScreen
import com.fabledsword.thoughtsync.ui.BoardSync
import com.fabledsword.thoughtsync.ui.BoardUpdate
import com.fabledsword.thoughtsync.ui.BoardViewModel
import com.fabledsword.thoughtsync.ui.ForegroundTransitions
import com.fabledsword.thoughtsync.ui.NoteEditorScreen
import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen
import com.fabledsword.thoughtsync.ui.SyncScreen
import com.fabledsword.thoughtsync.ui.SyncState
import com.fabledsword.thoughtsync.ui.SyncViewModel
import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme
import com.fabledsword.thoughtsync.ui.UpdateViewModel
import com.fabledsword.thoughtsync.ui.olderThan
import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.inkwell.ui.AttachmentFiles
import com.fabledsword.inkwell.ui.BoardScreen
import com.fabledsword.inkwell.ui.BoardSync
import com.fabledsword.inkwell.ui.BoardUpdate
import com.fabledsword.inkwell.ui.BoardViewModel
import com.fabledsword.inkwell.ui.ForegroundTransitions
import com.fabledsword.inkwell.ui.InkwellTheme
import com.fabledsword.inkwell.ui.LocalAttachmentFiles
import com.fabledsword.inkwell.ui.NoteEditorScreen
import com.fabledsword.inkwell.ui.ShareSheet
import com.fabledsword.inkwell.ui.ShareViewModel
import com.fabledsword.inkwell.ui.StoreUnavailableScreen
import com.fabledsword.inkwell.ui.SyncScreen
import com.fabledsword.inkwell.ui.SyncState
import com.fabledsword.inkwell.ui.SyncViewModel
import com.fabledsword.inkwell.ui.TagsScreen
import com.fabledsword.inkwell.ui.TagsViewModel
import com.fabledsword.inkwell.ui.UpdateViewModel
import com.fabledsword.inkwell.ui.olderThan
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -51,15 +60,27 @@ class MainActivity : ComponentActivity() {
*/
private val requestedNote = mutableStateOf<String?>(null)
/**
* Text or files shared into the app from elsewhere, waiting to become a note.
*
* Same shape and same reason as [requestedNote]: a share that arrives while
* the app is already running lands in [onNewIntent], long after the
* composition was built, so a piece of state it is already reading is the only
* way in. The activity is `singleTop` in the manifest precisely so that this
* path exists for an intent another app built.
*/
private val shared = mutableStateOf<SharedIn?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val app = application as ThoughtSyncApplication
val app = application as InkwellApplication
requestedNote.value = takeRequestedNote(intent)
shared.value = takeShared(intent)
setContent {
ThoughtSyncTheme {
InkwellTheme {
val core = app.core
if (core == null) {
// The store never opened. There is no board to show and no
@@ -67,7 +88,12 @@ class MainActivity : ComponentActivity() {
// than render an empty board that looks like data loss.
StoreUnavailableScreen(reason = app.openFailure)
} else {
App(core, requestedNote)
// Application context inside, so holding it for the
// composition's life cannot leak this activity.
val files = remember(core) { AttachmentFiles(this, core) }
CompositionLocalProvider(LocalAttachmentFiles provides files) {
App(core, files, requestedNote, shared)
}
}
}
}
@@ -77,6 +103,7 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent)
setIntent(intent)
requestedNote.value = takeRequestedNote(intent)
shared.value = takeShared(intent)
}
/**
@@ -92,10 +119,83 @@ class MainActivity : ComponentActivity() {
intent.removeExtra(Reminders.EXTRA_NOTE_ID)
return id
}
/**
* Read what a share or a text selection brought in, and CONSUME it.
*
* Consumed for the same reason [takeRequestedNote] is: the activity keeps the
* intent it was launched with, so without removing the extras a rotation would
* replay the share and mint the same note again, with nothing on screen to
* explain where the duplicates were coming from.
*/
private fun takeShared(intent: Intent?): SharedIn? {
val incoming =
when (intent?.action) {
Intent.ACTION_SEND -> SharedIn(intent.takeSendText(), intent.takeStreams())
Intent.ACTION_SEND_MULTIPLE -> SharedIn(intent.takeSendText(), intent.takeStreams())
Intent.ACTION_PROCESS_TEXT -> SharedIn(intent.takeProcessText(), emptyList())
else -> null
}
return incoming?.takeIf { !it.text.isNullOrBlank() || it.files.isNotEmpty() }
}
}
/**
* What a share brought: words, files, or both — a photo shared from the gallery
* often comes with a caption.
*
* The files are content URIs the sending app granted this one read access to. The
* grant lasts while this activity does, which is longer than reading them takes.
*/
data class SharedIn(
val text: String?,
val files: List<Uri>,
)
/** The file or files a SEND or SEND_MULTIPLE carried, consumed like the text. */
private fun Intent.takeStreams(): List<Uri> {
val streams =
if (action == Intent.ACTION_SEND_MULTIPLE) {
IntentCompat.getParcelableArrayListExtra(this, Intent.EXTRA_STREAM, Uri::class.java).orEmpty()
} else {
listOfNotNull(IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java))
}
removeExtra(Intent.EXTRA_STREAM)
return streams
}
/**
* The shared text, with a subject line above it when the sender gave one.
*
* Sharing a page from a browser sends EXTRA_SUBJECT as the page title and
* EXTRA_TEXT as the URL. Keeping both makes the note read as its title, because
* the core names a note by its first line — so this is not decoration, it is what
* turns a board of identical-looking links into a board you can scan.
*
* `distinct` because plenty of apps put the same string in both, and a note that
* says the URL twice is worse than one that says it once.
*/
private fun Intent.takeSendText(): String? {
val body = getStringExtra(Intent.EXTRA_TEXT)
val subject = getStringExtra(Intent.EXTRA_SUBJECT)
removeExtra(Intent.EXTRA_TEXT)
removeExtra(Intent.EXTRA_SUBJECT)
return listOfNotNull(subject, body)
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
.joinToString("\n")
}
/** The selection from another app's text field, via the selection toolbar. */
private fun Intent.takeProcessText(): String? {
val text = getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)?.toString()
removeExtra(Intent.EXTRA_PROCESS_TEXT)
return text
}
/** Which screen is up. Exactly one at a time. */
private enum class Screen { BOARD, EDITOR, SYNC }
private enum class Screen { BOARD, EDITOR, SYNC, TAGS }
/**
* The whole app, once the store is open.
@@ -104,21 +204,23 @@ private enum class Screen { BOARD, EDITOR, SYNC }
* both cover the display completely, so keeping the board's two-column grid
* measuring and recomposing underneath one would be pure waste.
*
* Still no navigation library. Three destinations, each entered from exactly one
* Still no navigation library. Four destinations, each entered from exactly one
* place and left by back — a nav graph would be ceremony around an enum, and the
* state that actually matters (which note is open, whether this device is linked)
* already lives in view models.
*/
@Composable
private fun App(
core: ThoughtSync,
core: Inkwell,
files: AttachmentFiles,
requestedNote: MutableState<String?>,
shared: MutableState<SharedIn?>,
) {
val context = LocalContext.current
val board: BoardViewModel =
viewModel(
factory =
BoardViewModel.factory(core) {
BoardViewModel.factory(core, readFile = files::read) {
// Any store write can have moved the next reminder. Called on
// the IO dispatcher by the view model, which is where it has to
// be — this reads every note carrying a reminder.
@@ -135,6 +237,15 @@ private fun App(
}
}
// Cleared the same way and for the same reason: without it every later
// recomposition would capture the share again as a new note.
LaunchedEffect(shared.value) {
shared.value?.let {
board.captureShared(it.text, it.files)
shared.value = null
}
}
ReminderAlarms(core)
// A pull can rewrite every note the board is holding, so a sync that changed
// anything tells it to reload. Wired here, at the one place that owns both.
@@ -149,6 +260,18 @@ private fun App(
// opens the editor on an unsaved draft, so writing a note and editing one are the
// same surface with the same toolbar.
var showingSync by rememberSaveable { mutableStateOf(false) }
var showingTags by rememberSaveable { mutableStateOf(false) }
// Tag writes reach the board two ways at once: the drawer lists tags, and the
// board may be LOOKING at one that a delete or a merge just removed. Both are
// `refreshLabels`, which also leaves a lens whose tag stopped existing.
val tags: TagsViewModel =
viewModel(factory = TagsViewModel.factory(core, onStoreChanged = board::refreshLabels))
// A share landing changes the note's `shared` flag, which the board's card chip
// reads, so the board reloads after one.
val share: ShareViewModel =
viewModel(factory = ShareViewModel.factory(core, onStoreChanged = board::refresh))
val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context))
val settings = remember(context) { SyncSettings(context) }
@@ -161,6 +284,9 @@ private fun App(
val screen =
when {
showingSync -> Screen.SYNC
// Above the editor: tags are reached only from the board's drawer, so
// there is never an open note underneath one to go back to.
showingTags -> Screen.TAGS
editing != null -> Screen.EDITOR
else -> Screen.BOARD
}
@@ -188,6 +314,18 @@ private fun App(
onInstallOutcome = update::consumeInstallOutcome,
)
Screen.TAGS ->
TagsScreen(
state = tags.state,
onClose = { showingTags = false },
onCreate = tags::create,
onRename = tags::rename,
onColour = tags::setColour,
onDelete = tags::remove,
onMerge = tags::merge,
onDismissError = tags::dismissError,
)
Screen.EDITOR ->
NoteEditorScreen(
// Non-null by construction: `screen` is EDITOR only when it is.
@@ -196,6 +334,7 @@ private fun App(
labels = board.state.labels,
saving = board.state.saving,
error = board.state.error,
onShare = { share.open(editing.id) },
// The one seam between the editor and the store. Exhaustive at the
// other end, so a new action cannot be added without being handled.
onAction = { board.onEditorAction(editing, it) },
@@ -218,6 +357,7 @@ private fun App(
onDismissError = sync::dismissSyncError,
),
onOpenSync = { showingSync = true },
onManageTags = { showingTags = true },
onSearch = board::search,
onCompose = board::compose,
onToggleItem = board::toggleItem,
@@ -244,9 +384,20 @@ private fun App(
}
}
// Over whichever screen opened it; a ModalBottomSheet handles its own back.
if (share.state.noteId != null) {
ShareSheet(
state = share.state,
onShare = share::share,
onUnshare = share::unshare,
onDismiss = share::close,
)
}
// The sync screen has no back handler of its own, so one lives here. The
// editor keeps its own, because it has to save the open note before leaving.
BackHandler(enabled = showingSync) { showingSync = false }
BackHandler(enabled = showingTags) { showingTags = false }
}
/**
@@ -263,7 +414,7 @@ private fun App(
* notifications this app would send — is spending it on nothing.
*/
@Composable
private fun ReminderAlarms(core: ThoughtSync) {
private fun ReminderAlarms(core: Inkwell) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
// Off the main thread: this reads every note that has a reminder, and a phone
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync
package com.fabledsword.inkwell
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -8,7 +8,7 @@ import android.content.Intent
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.fabledsword.thoughtsync.core.Note
import com.fabledsword.inkwell.core.Note
/**
* What a due reminder looks like in the shade.
@@ -117,5 +117,5 @@ internal object ReminderNotification {
)
private const val CHANNEL = "reminders"
private const val TAG = "ThoughtSyncReminders"
private const val TAG = "InkwellReminders"
}
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync
package com.fabledsword.inkwell
import android.content.BroadcastReceiver
import android.content.Context
@@ -36,7 +36,7 @@ class ReminderReceiver : BroadcastReceiver() {
context: Context,
intent: Intent,
) {
val core = (context.applicationContext as? ThoughtSyncApplication)?.core ?: return
val core = (context.applicationContext as? InkwellApplication)?.core ?: return
val action = intent.action
val noteId = intent.getStringExtra(Reminders.EXTRA_NOTE_ID)
val app = context.applicationContext
@@ -64,9 +64,9 @@ class ReminderReceiver : BroadcastReceiver() {
}
companion object {
const val ACTION_DUE = "com.fabledsword.thoughtsync.REMINDER_DUE"
const val ACTION_DONE = "com.fabledsword.thoughtsync.REMINDER_DONE"
const val ACTION_SNOOZE = "com.fabledsword.thoughtsync.REMINDER_SNOOZE"
private const val TAG = "ThoughtSyncReminders"
const val ACTION_DUE = "com.fabledsword.inkwell.REMINDER_DUE"
const val ACTION_DONE = "com.fabledsword.inkwell.REMINDER_DONE"
const val ACTION_SNOOZE = "com.fabledsword.inkwell.REMINDER_SNOOZE"
private const val TAG = "InkwellReminders"
}
}
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync
package com.fabledsword.inkwell
import android.app.AlarmManager
import android.app.PendingIntent
@@ -7,8 +7,8 @@ import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import com.fabledsword.thoughtsync.core.Note
import com.fabledsword.thoughtsync.core.ThoughtSync
import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.inkwell.core.Note
import java.time.OffsetDateTime
/**
@@ -52,7 +52,7 @@ object Reminders {
private const val MISSED_WINDOW_MS = 24L * 60 * 60 * 1000
private const val SNOOZE_MINUTES = 60L
private const val TAG = "ThoughtSyncReminders"
private const val TAG = "InkwellReminders"
/**
* Announce what is due, then schedule the next one.
@@ -63,7 +63,7 @@ object Reminders {
*/
fun refresh(
context: Context,
core: ThoughtSync,
core: Inkwell,
) {
ReminderNotification.ensureChannel(context)
val notes =
@@ -111,7 +111,7 @@ object Reminders {
*/
fun promptToNotifyDue(
context: Context,
core: ThoughtSync,
core: Inkwell,
): Boolean =
!Announced(context).askedToNotify &&
!NotificationManagerCompat.from(context).areNotificationsEnabled() &&
@@ -123,7 +123,7 @@ object Reminders {
/** Clear the reminder, as the notification's Done action. */
fun complete(
context: Context,
core: ThoughtSync,
core: Inkwell,
noteId: String,
) {
runCatching { core.completeReminder(noteId) }
@@ -134,7 +134,7 @@ object Reminders {
/** Push the reminder an hour out, as the notification's Snooze action. */
fun snooze(
context: Context,
core: ThoughtSync,
core: Inkwell,
noteId: String,
) {
runCatching { core.snoozeReminder(noteId, SNOOZE_MINUTES) }
@@ -237,7 +237,7 @@ private class Announced(
fun markAsked() = prefs.edit().putBoolean(KEY_ASKED, true).apply()
private companion object {
const val FILE = "thoughtsync-reminders"
const val FILE = "inkwell-reminders"
const val KEY_SEEN = "announced"
const val KEY_PRIMED = "primed"
const val KEY_ASKED = "asked_to_notify"
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync
package com.fabledsword.inkwell
import android.content.Context
import androidx.work.BackoffPolicy
@@ -85,8 +85,8 @@ object SyncSchedule {
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
private const val PERIODIC = "thoughtsync-periodic-sync"
private const val PUSH = "thoughtsync-push-pending"
private const val PERIODIC = "inkwell-periodic-sync"
private const val PUSH = "inkwell-push-pending"
/** WorkManager's own minimum for periodic work. Asking for less gets this. */
private const val PERIOD_MINUTES = 15L
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync
package com.fabledsword.inkwell
import android.content.Context
@@ -38,7 +38,7 @@ class SyncSettings(
}
private companion object {
const val FILE = "thoughtsync-sync"
const val FILE = "inkwell-sync"
const val KEY_AUTOMATIC = "automatic"
}
}
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync
package com.fabledsword.inkwell
import android.content.Context
import android.util.Log
@@ -8,7 +8,7 @@ import androidx.work.WorkerParameters
/**
* One sync cycle, run by the system rather than by a person.
*
* WorkManager may start the process to run this, which means [ThoughtSyncApplication.onCreate]
* WorkManager may start the process to run this, which means [InkwellApplication.onCreate]
* has already opened the store by the time [doWork] is called — the same handle
* the UI uses, so there is never a second SQLite connection racing the first.
*
@@ -30,7 +30,7 @@ class SyncWorker(
params: WorkerParameters,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val core = (applicationContext as? ThoughtSyncApplication)?.core
val core = (applicationContext as? InkwellApplication)?.core
// Both of these are "nothing to do", not "something went wrong", so both
// report success and let the run retire quietly:
@@ -67,6 +67,6 @@ class SyncWorker(
}
private companion object {
const val TAG = "ThoughtSyncWorker"
const val TAG = "InkwellWorker"
}
}
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync
package com.fabledsword.inkwell
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync
package com.fabledsword.inkwell
import android.content.BroadcastReceiver
import android.content.Context
@@ -71,7 +71,7 @@ class UpdateReceiver : BroadcastReceiver() {
}
companion object {
const val ACTION_INSTALLED = "com.fabledsword.thoughtsync.UPDATE_INSTALLED"
private const val TAG = "ThoughtSyncUpdate"
const val ACTION_INSTALLED = "com.fabledsword.inkwell.UPDATE_INSTALLED"
private const val TAG = "InkwellUpdate"
}
}
@@ -0,0 +1,264 @@
package com.fabledsword.inkwell.ui
import android.content.Context
import android.content.Intent
import android.database.Cursor
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.media.ExifInterface
import android.net.Uri
import android.provider.OpenableColumns
import android.util.LruCache
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.content.FileProvider
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.core.Attachment
import com.fabledsword.inkwell.core.Inkwell
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.IOException
import java.io.InputStream
/**
* A file picked or shared into the app, read and ready to attach — or the reason
* it could not be.
*/
sealed interface PickedFile {
// A plain class: a data class would compare `bytes` by identity, and nothing
// here compares two picked files anyway.
class Ready(
val name: String,
val mime: String,
val bytes: ByteArray,
) : PickedFile
data class Refused(
val reason: String,
) : PickedFile
}
/** What [AttachmentFiles.opener] made: an intent to start, or why there isn't one. */
sealed interface Opener {
data class Ready(
val intent: Intent,
) : Opener
data class Failed(
val message: String,
) : Opener
}
/**
* Everything the app does with attachment FILES, as opposed to attachment rows:
* reading a picked file, decoding an image for display, and handing a file to
* another app to open.
*
* Holds the application context, never an Activity, so the board's view model can
* keep a reference to [read] without leaking a screen.
*/
class AttachmentFiles(
context: Context,
private val core: Inkwell,
) {
private val app = context.applicationContext
/**
* Decoded images, keyed by hash and size. Sized in bytes against an eighth of
* the heap, which is the platform's own guidance for an in-memory image cache.
*/
private val images =
object : LruCache<String, ImageBitmap>(cacheBytes()) {
override fun sizeOf(
key: String,
value: ImageBitmap,
): Int = value.width * value.height * BYTES_PER_PIXEL
}
/**
* Read a picked or shared file, all of it, with its name and type.
*
* Blocking; call it off the main thread. Refuses anything over
* [MAX_ATTACH_MB] — before reading it when the sender says how big it is — so
* a long video shared by mistake is a message rather than an out-of-memory
* crash.
*/
fun read(uri: Uri): PickedFile {
val (label, declared) = describe(uri)
val overDeclared = (declared ?: 0) > MAX_ATTACH_BYTES
val bytes = if (overDeclared) null else readCapped(uri)
return when {
overDeclared -> tooLarge(label)
bytes == null -> PickedFile.Refused(app.getString(R.string.attach_unreadable, label))
bytes.size > MAX_ATTACH_BYTES -> tooLarge(label)
else -> PickedFile.Ready(label, app.contentResolver.getType(uri) ?: GENERIC_MIME, bytes)
}
}
/**
* The image, decoded no larger than it will be drawn, or null when this device
* doesn't hold the file yet or it isn't an image Android can read.
*
* Blocking; call it off the main thread.
*/
fun image(
attachment: Attachment,
maxPx: Int,
): ImageBitmap? {
val sha = attachment.sha256 ?: return null
val key = "$sha@$maxPx"
return images.get(key)
?: core.blobPath(sha)?.let { decode(it, maxPx) }?.also { images.put(key, it) }
}
/**
* An intent that hands the file to whatever app opens its type, or the reason
* there can't be one.
*
* Copied out of the blob store first, under its real name: the store names files
* by hash with no extension, and a viewer shown `3f2a…` cannot tell a PDF from a
* spreadsheet. The copy lives in the cache, which the system reclaims, and only
* that directory is shared (`res/xml/file_paths.xml`).
*
* Blocking; call it off the main thread, then start the intent from a screen.
*/
fun opener(attachment: Attachment): Opener {
val source = attachment.sha256?.let { core.blobPath(it) }?.let(::File)
if (source == null) return Opener.Failed(app.getString(R.string.attach_not_here))
val dir = File(app.cacheDir, "$OPEN_DIR/${attachment.id}")
val copy = File(dir, safeName(attachment.filename))
return try {
dir.mkdirs()
if (!copy.isFile || copy.length() != source.length()) source.copyTo(copy, overwrite = true)
val uri = FileProvider.getUriForFile(app, "${app.packageName}.files", copy)
val view =
Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, attachment.mime)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
Opener.Ready(view)
} catch (e: IOException) {
Opener.Failed(app.getString(R.string.attach_open_failed, e.message ?: attachment.filename.orEmpty()))
}
}
/** The name the sender gives the file and the size it declares, when it does. */
private fun describe(uri: Uri): Pair<String, Long?> {
val row =
try {
app.contentResolver.query(uri, COLUMNS, null, null, null)?.use { firstRow(it) }
} catch (expected: SecurityException) {
// No grant to read it at all; the read that follows says so.
null
}
val name = row?.first?.takeIf { it.isNotBlank() } ?: uri.lastPathSegment ?: FALLBACK_NAME
return name to row?.second
}
/**
* The file's bytes, at most one past the cap — enough to know it is over without
* reading the rest — or null when it can't be read.
*/
private fun readCapped(uri: Uri): ByteArray? =
try {
app.contentResolver.openInputStream(uri)?.use { it.readUpTo(MAX_ATTACH_BYTES + 1) }
} catch (expected: IOException) {
null
} catch (expected: SecurityException) {
null
}
private fun tooLarge(label: String) =
PickedFile.Refused(app.getString(R.string.attach_too_large, label, MAX_ATTACH_MB))
private companion object {
const val BYTES_PER_PIXEL = 4
const val CACHE_FRACTION = 8
const val OPEN_DIR = "open"
const val GENERIC_MIME = "application/octet-stream"
const val FALLBACK_NAME = "file"
val COLUMNS = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
/** How far each EXIF orientation is turned from upright. */
val ROTATIONS =
mapOf(
ExifInterface.ORIENTATION_ROTATE_90 to 90f,
ExifInterface.ORIENTATION_ROTATE_180 to 180f,
ExifInterface.ORIENTATION_ROTATE_270 to 270f,
)
/** DISPLAY_NAME and SIZE from the first row of a [COLUMNS] query; either may be absent. */
fun firstRow(cursor: Cursor): Pair<String?, Long?>? =
if (cursor.moveToFirst()) {
cursor.getString(0) to (if (cursor.isNull(1)) null else cursor.getLong(1))
} else {
null
}
fun cacheBytes(): Int =
(Runtime.getRuntime().maxMemory() / CACHE_FRACTION)
.coerceAtMost(Int.MAX_VALUE.toLong())
.toInt()
fun decode(
path: String,
maxPx: Int,
): ImageBitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val options =
BitmapFactory.Options().apply {
inSampleSize = sampleSize(bounds.outWidth, bounds.outHeight, maxPx)
}
return BitmapFactory.decodeFile(path, options)?.let { upright(it, path).asImageBitmap() }
}
/** Camera photos are stored sideways with a tag saying so; BitmapFactory ignores it. */
fun upright(
bitmap: Bitmap,
path: String,
): Bitmap {
val orientation =
try {
ExifInterface(path).getAttributeInt(ExifInterface.TAG_ORIENTATION, 0)
} catch (expected: IOException) {
// No readable EXIF: draw it as stored.
0
}
val degrees = ROTATIONS[orientation] ?: return bitmap
val turn = Matrix().apply { postRotate(degrees) }
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, turn, true)
}
}
}
/**
* The app's [AttachmentFiles], for the card and the editor. Null in previews and
* anywhere it was never provided, where attachments draw as plain file rows.
*/
val LocalAttachmentFiles = staticCompositionLocalOf<AttachmentFiles?> { null }
/**
* The largest file the phone will attach. Read whole into memory and copied once
* into the core, so the cap is about the phone's heap. The server has its own
* limit (25 MB by default, `max_attachment_mb`); a file over that one is kept here
* and its upload reported as refused.
*/
const val MAX_ATTACH_MB = 50
private const val MAX_ATTACH_BYTES = MAX_ATTACH_MB * 1024 * 1024
/** `InputStream.readNBytes(int)` is API 33; this app supports 26. */
private fun InputStream.readUpTo(limit: Int): ByteArray {
val out = ByteArrayOutputStream()
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var total = 0
while (total < limit) {
val read = read(buffer, 0, minOf(buffer.size, limit - total))
if (read < 0) break
out.write(buffer, 0, read)
total += read
}
return out.toByteArray()
}
@@ -0,0 +1,56 @@
package com.fabledsword.inkwell.ui
import java.util.Locale
import kotlin.math.max
import kotlin.math.roundToInt
// The attachment decisions that need no Android: which files draw as pictures, how
// far to scale a decode, what to name a copy, how to print a size. Kept apart from
// AttachmentFiles so the JVM unit tests can load them without a device.
/**
* Whether a type is drawn as a picture rather than offered as a file. SVG is
* excluded on every surface: it is a document that can carry script (#1981).
* The same rule as `rendersInline` in the web's `notes/attachments.ts`.
*/
fun rendersInline(mime: String): Boolean {
val type = mime.lowercase().substringBefore(';').trim()
return type.startsWith("image/") && type != "image/svg+xml"
}
/**
* The power-of-two step BitmapFactory decodes at, so the result's longer side is
* still at least [maxPx]: never upscaled on screen, never decoded at full camera
* resolution for a thumbnail.
*/
fun sampleSize(
width: Int,
height: Int,
maxPx: Int,
): Int {
val longest = max(width, height)
var sample = 1
while (longest / (sample * 2) >= maxPx) sample *= 2
return sample
}
/** A name safe to create in the cache: no path separators, never empty. */
fun safeName(filename: String?): String {
val base =
filename
?.substringAfterLast('/')
?.substringAfterLast('\\')
?.trim()
.orEmpty()
return base.takeIf { it.isNotEmpty() && it != "." && it != ".." } ?: "file"
}
/** "12 KB", "3.4 MB" — the same rounding as `fmtSize` in the web's NoteEditor.vue. */
fun sizeLabel(bytes: Long): String =
when {
bytes < KIB -> "$bytes B"
bytes < KIB * KIB -> "${(bytes / KIB).roundToInt()} KB"
else -> "%.1f MB".format(Locale.ROOT, bytes / (KIB * KIB))
}
private const val KIB = 1024.0
@@ -0,0 +1,336 @@
package com.fabledsword.inkwell.ui
import android.content.ActivityNotFoundException
import android.content.Context
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.core.Attachment
import com.fabledsword.inkwell.core.LinkPreview
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private val ATTACHMENT_RADIUS = 8.dp
/**
* Decoded no larger than this on the card. A board column on a phone is around
* 180dp wide, which is about 540px at the common densities, so this is sharp on
* every screen the app runs on without decoding a 12-megapixel photo for it.
*/
private const val CARD_IMAGE_PX = 640
/** The editor draws images at the sheet's full width. */
private const val EDITOR_IMAGE_PX = 1440
/**
* The narrowest an image may draw, as width over height. A tall screenshot drawn
* at its own ratio would push the rest of the card off the board, so it is cropped
* to 3:4 instead; anything wider than that draws whole.
*/
private const val MIN_ASPECT = 0.75f
/** How many file names the card lists before it says "and N more". */
private const val CARD_FILE_ROWS = 2
/**
* A note's attachments on its board card: the first image as a picture, then the
* other files by name.
*
* One picture, not a gallery. The card is a glance at the note, and a strip of
* thumbnails would make an image note the tallest thing on the board whatever its
* words say. The editor shows them all.
*/
@Composable
fun CardAttachments(attachments: List<Attachment>) {
val (images, files) = attachments.partition { rendersInline(it.mime) }
images.firstOrNull()?.let { first ->
Spacer(Modifier.height(8.dp))
AttachmentImage(attachment = first, maxPx = CARD_IMAGE_PX) {
FileRow(attachment = first)
}
}
val rest = images.drop(1) + files
rest.take(CARD_FILE_ROWS).forEach { file ->
Spacer(Modifier.height(4.dp))
FileRow(attachment = file)
}
if (rest.size > CARD_FILE_ROWS) {
Text(
text = stringResource(R.string.attach_more, rest.size - CARD_FILE_ROWS),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
}
/**
* Every attachment, in the editor: images at full width, files as rows, each one
* opening with the system and each removable.
*
* A file the server refused says so under it, in its own words — the upload is
* not retried, so without this a file that never reaches the other devices would
* look exactly like one that did.
*/
@Composable
fun EditorAttachments(
attachments: List<Attachment>,
readOnly: Boolean,
onAction: (EditorAction) -> Unit,
) {
val open = rememberOpener()
Column(modifier = Modifier.padding(top = 12.dp)) {
attachments.forEach { attachment ->
val remove = { onAction(EditorAction.RemoveAttachment(attachment.id)) }
Box(modifier = Modifier.padding(vertical = 4.dp)) {
if (rendersInline(attachment.mime)) {
AttachmentImage(
attachment = attachment,
maxPx = EDITOR_IMAGE_PX,
onClick = { open(attachment) },
) {
FileRow(attachment = attachment, onClick = { open(attachment) })
}
} else {
FileRow(attachment = attachment, onClick = { open(attachment) })
}
if (!readOnly) {
IconButton(
onClick = remove,
modifier =
Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(32.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface),
) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.attach_remove),
)
}
}
}
attachment.uploadError?.let { reason ->
Text(
text = stringResource(R.string.attach_refused, reason),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
/**
* The note's link previews in the editor, each dismissable.
*
* Dismissing is the only control: the server made the preview and will not make
* it again, so removing one is a decision about this note rather than a refresh.
*/
@Composable
fun EditorPreviews(
previews: List<LinkPreview>,
readOnly: Boolean,
onAction: (EditorAction) -> Unit,
) {
Column(modifier = Modifier.padding(top = 12.dp)) {
previews.forEach { preview ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 2.dp),
) {
LinkPreviewCard(preview = preview, compact = true, modifier = Modifier.weight(1f))
if (!readOnly) {
IconButton(onClick = { onAction(EditorAction.RemovePreview(preview.id)) }) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.preview_remove),
)
}
}
}
}
}
}
/** Loading, drawn, or not drawable (not downloaded yet, or not an image Android reads). */
private sealed interface ImageLoad {
data object Loading : ImageLoad
data class Ready(
val bitmap: ImageBitmap,
) : ImageLoad
data object Missing : ImageLoad
}
/**
* An attachment drawn as a picture, decoded off the main thread.
*
* [fallback] is what draws when it can't be: a file this device hasn't downloaded
* yet, or bytes that don't decode. It is a file row, so the note still shows that
* something is attached instead of a gap.
*/
@Composable
private fun AttachmentImage(
attachment: Attachment,
maxPx: Int,
onClick: (() -> Unit)? = null,
fallback: @Composable () -> Unit,
) {
val files = LocalAttachmentFiles.current
val load by produceState<ImageLoad>(ImageLoad.Loading, attachment.sha256, maxPx, files) {
val bitmap = files?.let { withContext(Dispatchers.IO) { it.image(attachment, maxPx) } }
value = bitmap?.let { ImageLoad.Ready(it) } ?: ImageLoad.Missing
}
val shape = RoundedCornerShape(ATTACHMENT_RADIUS)
when (val state = load) {
ImageLoad.Loading ->
Box(
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(4f / 3f)
.clip(shape)
.background(MaterialTheme.colorScheme.surfaceVariant),
)
is ImageLoad.Ready -> {
val ratio = (state.bitmap.width.toFloat() / state.bitmap.height).coerceAtLeast(MIN_ASPECT)
val tap = onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier
Image(
bitmap = state.bitmap,
contentDescription = attachment.filename,
contentScale = ContentScale.Crop,
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(ratio)
.clip(shape)
.then(tap),
)
}
ImageLoad.Missing -> fallback()
}
}
/** A file by name and size, behind a paperclip. Tappable in the editor, not on the card. */
@Composable
private fun FileRow(
attachment: Attachment,
onClick: (() -> Unit)? = null,
) {
val shape = RoundedCornerShape(ATTACHMENT_RADIUS)
val tap = onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.clip(shape)
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
.then(tap)
.padding(horizontal = 8.dp, vertical = 6.dp),
) {
Icon(
painter = painterResource(R.drawable.ic_attach),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(6.dp))
Text(
text = attachment.filename?.takeIf { it.isNotBlank() } ?: stringResource(R.string.attach_unnamed),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
attachment.size?.let { bytes ->
Text(
text = sizeLabel(bytes),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
// Clear of the editor's remove button, which sits over this corner.
modifier = Modifier.padding(start = 8.dp, end = if (onClick != null) 32.dp else 0.dp),
)
}
}
}
/**
* Open an attachment with whatever app handles its type, or say why not.
*
* The copy out of the blob store runs on IO; the activity starts on the main
* thread, from the screen's own context, so the viewer opens over this app rather
* than in a task of its own.
*/
@Composable
private fun rememberOpener(): (Attachment) -> Unit {
val files = LocalAttachmentFiles.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
return { attachment ->
if (files != null) {
scope.launch {
when (val opener = withContext(Dispatchers.IO) { files.opener(attachment) }) {
is Opener.Ready -> start(context, opener)
is Opener.Failed -> toast(context, opener.message)
}
}
}
}
}
private fun start(
context: Context,
opener: Opener.Ready,
) {
try {
context.startActivity(opener.intent)
} catch (expected: ActivityNotFoundException) {
toast(context, context.getString(R.string.attach_no_app))
}
}
private fun toast(
context: Context,
message: String,
) = Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
@@ -33,7 +33,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.inkwell.R
/**
* The note's body, as fields and checkboxes rather than as markup.
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -26,6 +26,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CircularProgressIndicator
@@ -62,9 +63,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Label
import com.fabledsword.thoughtsync.core.Note
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.core.Label
import com.fabledsword.inkwell.core.Note
import kotlinx.coroutines.launch
@Composable
@@ -74,6 +75,7 @@ fun BoardScreen(
onOpenNote: (Note) -> Unit,
sync: BoardSync,
onOpenSync: () -> Unit,
onManageTags: () -> Unit,
onSearch: (String) -> Unit,
onCompose: () -> Unit,
onToggleItem: (Note, Int, Boolean) -> Unit,
@@ -138,6 +140,10 @@ fun BoardScreen(
onOpenSync()
scope.launch { drawerState.close() }
},
onManageTags = {
onManageTags()
scope.launch { drawerState.close() }
},
)
},
) {
@@ -367,6 +373,7 @@ private fun NavigationDrawer(
syncSummary: String?,
onOpen: (Destination) -> Unit,
onOpenSync: () -> Unit,
onManageTags: () -> Unit,
) {
ModalDrawerSheet {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
@@ -380,18 +387,36 @@ private fun NavigationDrawer(
DrawerRow(destination, current, onOpen)
}
if (labels.isNotEmpty()) {
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
// The header renders even with no tags, unlike the rows below it: the
// manage screen is where you go to MAKE the first one, and hiding the
// way in until one exists would be a door that appears only once you
// are already inside. It is an action ON the section rather than a row
// in it, so it cannot be mistaken for one more lens.
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 28.dp, end = 16.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.nav_labels),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, bottom = 4.dp),
modifier = Modifier.weight(1f),
)
labels.forEach { label ->
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
IconButton(onClick = onManageTags) {
Icon(
Icons.Filled.Edit,
contentDescription = stringResource(R.string.tags_manage),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
labels.forEach { label ->
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
listOf(Destination.Archive, Destination.Trash).forEach { destination ->
@@ -1,17 +1,18 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.fabledsword.thoughtsync.core.Label
import com.fabledsword.thoughtsync.core.Note
import com.fabledsword.thoughtsync.core.NoteDraft
import com.fabledsword.thoughtsync.core.NoteEdit
import com.fabledsword.thoughtsync.core.NoteQuery
import com.fabledsword.thoughtsync.core.ThoughtSync
import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.inkwell.core.Label
import com.fabledsword.inkwell.core.Note
import com.fabledsword.inkwell.core.NoteDraft
import com.fabledsword.inkwell.core.NoteEdit
import com.fabledsword.inkwell.core.NoteQuery
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -99,7 +100,7 @@ data class BoardState(
*/
@Suppress("TooManyFunctions")
class BoardViewModel(
private val core: ThoughtSync,
private val core: Inkwell,
/**
* Called after any write that could have moved a reminder.
*
@@ -110,6 +111,11 @@ class BoardViewModel(
* view model's `onStoreChanged`.
*/
private val onRemindersChanged: () -> Unit = {},
/**
* Read a picked or shared file. Blocking; only ever called on the IO dispatcher.
* A function rather than a Context for the same reason as the callback above.
*/
private val readFile: (Uri) -> PickedFile = { PickedFile.Refused(FALLBACK_ERROR) },
) : ViewModel() {
var state by mutableStateOf(BoardState())
private set
@@ -123,7 +129,7 @@ class BoardViewModel(
init {
refresh()
loadLabels()
refreshLabels()
}
fun open(destination: Destination) {
@@ -162,13 +168,34 @@ class BoardViewModel(
is Destination.WithLabel -> core.listNotes(query(VIEW_NOTES, labelId = destination.id))
}
private fun loadLabels() {
/**
* Reload the drawer's tags, and leave a lens whose tag no longer exists.
*
* Public because the Tags screen owns operations this board cannot see: a
* delete or a merge removes a tag, and the board may be LOOKING at that tag —
* `Destination.WithLabel` holds an id, and a query for a deleted one returns
* nothing forever. Without the fallback, tidying up tags could strand the board
* on a permanently empty lens whose only escape is the drawer.
*
* A rename needs no fallback: the id survives, and re-listing gives the drawer
* the new name. A rename that MERGED is a delete of one of the two, which this
* catches by id like any other.
*/
fun refreshLabels() {
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
.onSuccess { state = state.copy(labels = it) }
.onSuccess { labels ->
state = state.copy(labels = labels)
val lens = state.destination
if (lens is Destination.WithLabel && labels.none { it.id == lens.id }) {
open(Destination.Notes)
}
}
// A drawer that cannot list labels is a degraded drawer, not a
// broken board — the notes are still there. Failing quietly here
// beats an error banner over working content.
// beats an error banner over working content. The lens is left
// alone in this case on purpose: "I could not read the tags" is not
// evidence that this one is gone.
.onFailure { state = state.copy(labels = emptyList()) }
}
}
@@ -235,6 +262,39 @@ class BoardViewModel(
state = state.copy(editing = blankDraft(), editingSession = state.editingSession + 1)
}
/**
* Capture text shared into the app from somewhere else, and open it.
*
* The note is CREATED here rather than opened as a pre-filled draft, and that
* is the whole design of this path. The editor only flushes when its text
* differs from the note it was handed (`NoteEditorScreen`'s `flush`), so a
* draft arriving already full of the shared text is a draft with nothing to
* save — share a link, press back without typing, and it would be gone. A
* share has already said "keep this"; making the row first is what honours it.
*
* Opening the editor afterwards is then free of that risk: the note exists,
* back leaves it alone, and adding a line of context is optional rather than
* load-bearing.
*/
fun captureShared(
text: String?,
files: List<Uri> = emptyList(),
) {
val content = text?.trim().orEmpty()
if (content.isEmpty() && files.isEmpty()) return
// A share is a new sitting even if the editor was already open on
// something, so the field must be re-keyed onto what arrives. `createFrom
// Draft` deliberately does not bump this — it is written for the autosave
// case, where re-keying mid-typing would be the bug.
draftDismissed = false
state = state.copy(editingSession = state.editingSession + 1)
// A shared photo arrives with no words, and the note it makes is still a
// note: the picture is its content.
createFromDraft(content, allowEmpty = files.isNotEmpty()) { created ->
if (files.isNotEmpty()) onEditorAction(created, EditorAction.Attach(files))
}
}
/**
* Set when a draft's editor closes, so a create still in flight does not reopen
* it. The editor flushes its text and then closes, and the flush is a coroutine —
@@ -263,6 +323,10 @@ class BoardViewModel(
}
EditorAction.DismissError -> dismissError()
is EditorAction.SaveText -> createFromDraft(action.body)
// Attaching to an empty draft is a note with a file and no words yet, so
// it is the one action that may create a note with no text.
is EditorAction.Attach ->
createFromDraft(draft.body, allowEmpty = true) { created -> onEditorAction(created, action) }
// Colour, reminder, pin, labels: attributes OF a note, so there has to be
// a note. With autosave at a second, "typed something" is true by the time
// anyone reaches the toolbar; before that there is nothing to attribute.
@@ -327,7 +391,7 @@ class BoardViewModel(
* mutation that lands between a tap and its dispatch cannot redirect the
* action at a different note.
*
* Both suppressions have ONE cause: [EditorAction] has twenty variants, so a
* Both suppressions have ONE cause: [EditorAction] has over twenty variants, so a
* total function over it is twenty branches and sixty-odd lines no matter how
* it is written. Splitting it into sub-dispatchers is the only way to shorten
* it, and each of those would need an `else` — which throws away precisely the
@@ -387,7 +451,7 @@ class BoardViewModel(
}
// The drawer lists labels with their note counts, and both
// just changed.
loadLabels()
refreshLabels()
}
is EditorAction.SetReminder -> edit(id, NoteEdit.RemindAt(action.at))
@@ -399,6 +463,35 @@ class BoardViewModel(
id,
action.rule?.let { NoteEdit.Recurrence(it) } ?: NoteEdit.ClearRecurrence,
)
is EditorAction.Attach -> attach(id, action.uris)
is EditorAction.RemoveAttachment -> mutate { it.deleteAttachment(id, action.attachmentId) }
is EditorAction.RemovePreview -> mutate { it.deletePreview(id, action.previewId) }
}
}
/**
* Read each file and attach it, one at a time.
*
* A file that can't be read, or is too large, is skipped and named afterwards
* rather than failing the rest: sharing four photos where one is a video should
* still attach the three.
*/
private fun attach(
id: String,
uris: List<Uri>,
) {
val refused = mutableListOf<String>()
mutate(notice = { refused.takeIf { it.isNotEmpty() }?.joinToString("\n") }) { core ->
uris.fold(null as Note?) { latest, uri ->
when (val file = readFile(uri)) {
is PickedFile.Ready -> core.addAttachment(id, file.name, file.mime, file.bytes)
is PickedFile.Refused -> {
refused += file.reason
latest
}
}
}
}
}
@@ -425,13 +518,20 @@ class BoardViewModel(
* correct-until-a-moment-ago content, and flashing it empty would be a worse
* lie than showing it one frame stale.
*
* Search results are left alone — they are the answer to a query, not a live
* view, and re-running the board query underneath them would replace the hits
* with the whole board.
* While a search is running the QUERY is re-run rather than the board's
* destination — running `load` here would replace the hits with the whole
* board, which is why this branch exists at all. It used to keep the existing
* list instead, and that was right for a note whose place in the pile changed
* and wrong for one that left it: trashing a hit left the card sitting there,
* with a snackbar saying it was gone, until the query happened to re-run
* (#3111). Re-asking is still the answer to the query, just a current one.
*/
private fun mutate(
closeEditor: Boolean = false,
block: (ThoughtSync) -> Note?,
// Something to say once the write has landed, shown where an error would be.
// Read after `block` has run, so the block can decide it.
notice: () -> String? = { null },
block: (Inkwell) -> Note?,
) {
viewModelScope.launch {
state = state.copy(saving = true)
@@ -439,10 +539,8 @@ class BoardViewModel(
try {
val updated = withContext(Dispatchers.IO) { block(core) }
val notes =
if (state.searching) {
state.notes
} else {
withContext(Dispatchers.IO) { load(state.destination) }
withContext(Dispatchers.IO) {
if (state.searching) core.searchNotes(state.query) else load(state.destination)
}
// On IO, not here: re-deriving the alarm reads every note
// that carries a reminder, and this line runs on the main
@@ -457,7 +555,7 @@ class BoardViewModel(
// which is exactly what ticking a checkbox on a card did.
editing = if (closeEditor) null else state.editing?.let { updated ?: it },
saving = false,
error = null,
error = notice(),
)
} catch (e: Exception) {
// Broad by intent, as elsewhere: the core reports every failure
@@ -498,13 +596,14 @@ class BoardViewModel(
private const val VIEW_TRASH = "trash"
fun factory(
core: ThoughtSync,
core: Inkwell,
readFile: (Uri) -> PickedFile,
onRemindersChanged: () -> Unit,
): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
BoardViewModel(core, onRemindersChanged) as T
BoardViewModel(core, onRemindersChanged, readFile) as T
}
}
}
@@ -553,4 +652,7 @@ private fun blankDraft(): Note =
previews = emptyList(),
createdAt = null,
updatedAt = null,
permission = "owner",
shared = false,
sharedBy = null,
)
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
// The colour a LABEL wears when nobody picked one for it.
//
@@ -1,4 +1,6 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
import android.net.Uri
/**
* Everything the editor can ask for, as one type.
@@ -93,4 +95,22 @@ sealed interface EditorAction {
data class SetRecurrence(
val rule: String?,
) : EditorAction
/**
* Attach files picked on this phone or shared into the app.
*
* URIs rather than bytes: reading them is blocking I/O, and the view model does
* it on the IO dispatcher where a failure can still become the error banner.
*/
data class Attach(
val uris: List<Uri>,
) : EditorAction
data class RemoveAttachment(
val attachmentId: String,
) : EditorAction
data class RemovePreview(
val previewId: String,
) : EditorAction
}
@@ -1,10 +1,10 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
import androidx.compose.runtime.saveable.Saver
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.fabledsword.thoughtsync.core.checklistItems
import com.fabledsword.thoughtsync.core.checklistRender
import com.fabledsword.inkwell.core.checklistItems
import com.fabledsword.inkwell.core.checklistRender
/**
* One piece of a note body, as the editor DRAWS it.
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
import android.text.format.DateUtils
import androidx.compose.foundation.background
@@ -39,10 +39,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Note
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.core.Note
/**
* The editor's action bar, along the top of the surface.
@@ -75,12 +76,19 @@ import com.fabledsword.thoughtsync.core.Note
fun EditorTopBar(
note: Note,
readOnly: Boolean,
access: NoteAccess,
onClose: () -> Unit,
onStartChecklist: () -> Unit,
onPicker: (Picker) -> Unit,
onAttach: () -> Unit,
onShare: () -> Unit,
onConfirmDelete: () -> Unit,
onAction: (EditorAction) -> Unit,
) {
// Someone else's note (#5175): its text, at `edit`, and this account's own pin
// and archive (#5176) are all that may change here, so the bar keeps the
// checklist button and an overflow of those two, and nothing that is the owner's.
val owner = access == NoteAccess.OWNER
val dark = isSystemInDarkTheme()
TopAppBar(
title = {},
@@ -96,13 +104,15 @@ fun EditorTopBar(
}
},
actions = {
if (!readOnly) {
if (!readOnly && owner) {
IconButton(onClick = { onPicker(Picker.REMINDER) }) {
Icon(
Icons.Filled.Notifications,
contentDescription = stringResource(R.string.editor_reminder),
)
}
}
if (!readOnly) {
// Inserts `- [ ] ` at the caret. Always available, and never hidden:
// a checklist is text now (M304), so there is no section to be
// already-showing and no reason a second list cannot start further
@@ -114,13 +124,26 @@ fun EditorTopBar(
)
}
}
OverflowMenu(
note = note,
readOnly = readOnly,
onPicker = onPicker,
onConfirmDelete = onConfirmDelete,
onAction = onAction,
)
// On the bar rather than in the overflow: attaching a photo is something
// people look for, and a menu of words is where it would not be found.
if (!readOnly && owner) {
IconButton(onClick = onAttach) {
Icon(
painter = painterResource(R.drawable.ic_attach),
contentDescription = stringResource(R.string.editor_attach),
)
}
}
if (owner || !note.trashed) {
OverflowMenu(
note = note,
owner = owner,
onPicker = onPicker,
onShare = onShare,
onConfirmDelete = onConfirmDelete,
onAction = onAction,
)
}
},
// EXPLICIT, and not optional — the same lesson the old bottom bar learned.
// Material derives a bar's content colour from its container via
@@ -256,8 +279,9 @@ private fun savedLabel(
@Composable
private fun OverflowMenu(
note: Note,
readOnly: Boolean,
owner: Boolean,
onPicker: (Picker) -> Unit,
onShare: () -> Unit,
onConfirmDelete: () -> Unit,
onAction: (EditorAction) -> Unit,
) {
@@ -268,7 +292,7 @@ private fun OverflowMenu(
Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.editor_more))
}
DropdownMenu(expanded = open, onDismissRequest = close) {
if (readOnly) {
if (note.trashed) {
MenuItem(R.string.editor_restore, close) { onAction(EditorAction.Restore) }
MenuItem(R.string.editor_delete_forever, close, onConfirmDelete)
} else {
@@ -276,12 +300,20 @@ private fun OverflowMenu(
if (note.pinned) R.string.editor_unpin else R.string.editor_pin,
close,
) { onAction(EditorAction.SetPinned(!note.pinned)) }
MenuItem(R.string.editor_labels, close) { onPicker(Picker.LABELS) }
if (owner) {
MenuItem(R.string.editor_labels, close) { onPicker(Picker.LABELS) }
}
// Not on a draft: the server has nothing to share until it is saved.
if (owner && note.id != DRAFT_ID) {
MenuItem(R.string.editor_share, close, onShare)
}
MenuItem(
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
close,
) { onAction(EditorAction.SetArchived(!note.archived)) }
MenuItem(R.string.editor_trash, close) { onAction(EditorAction.Trash) }
if (owner) {
MenuItem(R.string.editor_trash, close) { onAction(EditorAction.Trash) }
}
}
}
}
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -36,9 +36,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Label
import com.fabledsword.thoughtsync.core.Note
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.core.Label
import com.fabledsword.inkwell.core.Note
import java.time.DayOfWeek
import java.time.Instant
import java.time.LocalDate
@@ -338,7 +338,7 @@ private fun RecurrenceChips(
}
@Composable
private fun SheetTitle(labelRes: Int) {
internal fun SheetTitle(labelRes: Int) {
Text(
text = stringResource(labelRes),
style = MaterialTheme.typography.titleMedium,
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -16,7 +16,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.inkwell.R
// Shared by the board and the editor.
//
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -0,0 +1,121 @@
package com.fabledsword.inkwell.ui
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.fabledsword.inkwell.core.LinkPreview
import com.fabledsword.inkwell.core.Note
private val PREVIEW_RADIUS = 8.dp
/**
* A URL that is the WHOLE body, whitespace either side allowed.
*
* Mirrors `LONE_URL_RE` in `NoteCard.vue` deliberately — the two surfaces have to
* agree on what counts as "this note is a link", or the same note reads as a card
* on one and a paragraph on the other. Someone pasting a link rarely trims it,
* which is why the surrounding whitespace is tolerated rather than rejected.
*/
private val LONE_URL = Regex("""^\s*(https?://[^\s<>"'\]\)]+)\s*$""")
/**
* The preview for a note that is nothing but a URL, or null.
*
* Null covers three different situations that all render the same way — the body
* is not a lone URL, the server has not unfurled it yet, or it never could. The
* card falls back to showing the URL as text in every one of them, so it is never
* blank and the link is never unreachable.
*
* A note written on the phone and not yet synced is permanently in the middle
* case: the unfurl happens server-side (`unfurl_queue.py`) and arrives on a later
* pull. That is the honest behaviour and it has to look deliberate, which showing
* the URL does.
*/
fun loneUrlPreview(note: Note): LinkPreview? {
if (!LONE_URL.matches(note.body)) return null
val url = note.body.trim()
return note.previews.firstOrNull { it.url == url }
}
/** True when the body is a lone URL, whether or not a preview has arrived for it. */
fun isLoneUrl(note: Note): Boolean = LONE_URL.matches(note.body)
/**
* A fetched link preview, in one of two sizes.
*
* [compact] is a single row — one line of title and the site — for a URL mentioned
* *inside* a note that has its own words. The note is the thing; the link is a
* footnote to it. Full size is for a note that IS a URL, where the link is the
* note and a compact strip would be a card with nothing on it.
*
* No image, unlike the web's `LinkPreview.vue`. `image_url` is a REMOTE
* third-party address, so drawing it would have this app fetch from whatever host
* a link happens to point at — on a phone, on possibly metered data, and as the
* first image loading anywhere in this client. That is a decision about privacy
* and data use rather than a rendering detail, so the text card ships and the
* image is left to be asked for (Scribe #3307).
*/
@Composable
fun LinkPreviewCard(
preview: LinkPreview,
compact: Boolean,
modifier: Modifier = Modifier,
) {
// Every element of the Modifier chain stays on ONE line, which is why the shape
// and the two paddings are named first. `standard:chain-method-continuation`
// wants a `.` that follows a MULTILINE element glued to its closing paren —
// `).padding(…)` — which is unreadable, so the multiline element is avoided
// instead (Scribe #3110).
val shape = RoundedCornerShape(PREVIEW_RADIUS)
val padH = if (compact) 8.dp else 10.dp
val padV = if (compact) 6.dp else 8.dp
Column(
modifier =
modifier
.fillMaxWidth()
.clip(shape)
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
.padding(horizontal = padH, vertical = padV),
) {
preview.siteName?.takeIf { it.isNotBlank() }?.let { site ->
Text(
text = site.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
// The URL stands in for a missing title so the row always says SOMETHING
// about where it goes.
text = preview.title?.takeIf { it.isNotBlank() } ?: preview.url,
style = if (compact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// First thing to go when there is no room — the compact row is a footnote and
// a description would make it the loudest part of the card.
if (!compact) {
preview.description?.takeIf { it.isNotBlank() }?.let { body ->
Text(
text = body,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@@ -0,0 +1,29 @@
package com.fabledsword.inkwell.ui
import com.fabledsword.inkwell.core.Note
/**
* How this account holds a note (#5175), read from the core's `permission`.
*
* Someone else's note at [EDIT] may have its TEXT changed here; at [VIEW] it may
* not. Either way its pin and archive are this account's own (#5176). The core
* refuses the rest anyway — this only keeps the editor from offering what would be
* refused.
*/
enum class NoteAccess { OWNER, EDIT, VIEW }
val Note.access: NoteAccess
get() =
when (permission) {
"edit" -> NoteAccess.EDIT
"view" -> NoteAccess.VIEW
else -> NoteAccess.OWNER
}
/** Its content can't change here: it is in the trash, or shared with us to view. */
val Note.readOnlyHere: Boolean
get() = trashed || access == NoteAccess.VIEW
/** The owner's own controls apply: tags, reminder, files, previews, share, trash. */
val Note.ownerControlsHere: Boolean
get() = !readOnlyHere && access == NoteAccess.OWNER
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -38,12 +38,12 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.BodyItem
import com.fabledsword.thoughtsync.core.Note
import com.fabledsword.thoughtsync.core.NoteLabel
import com.fabledsword.thoughtsync.core.bodyTags
import com.fabledsword.thoughtsync.core.checklistItems
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.core.BodyItem
import com.fabledsword.inkwell.core.Note
import com.fabledsword.inkwell.core.NoteLabel
import com.fabledsword.inkwell.core.bodyTags
import com.fabledsword.inkwell.core.checklistItems
@Composable
fun NoteCard(
@@ -123,16 +123,48 @@ fun NoteCard(
Spacer(Modifier.height(8.dp))
}
// A note that is NOTHING but a URL renders as its preview and nothing
// else — printing the raw address under a card that already says where it
// goes is saying the same thing twice, badly. Until the unfurl lands, or
// if it never does, `preview` is null and the body falls through to
// NoteBody, which shows the URL. Never a blank card.
val lonePreview = remember(note.body, note.previews) { loneUrlPreview(note) }
// Body then checklist, in order — a note can carry both (M13 step 2). The
// first line of the body IS the note's name, at the same weight as the rest of
// it (M13 steps 3 and 4).
if (note.body.isNotBlank()) {
NoteBody(note = note, onToggleItem = onToggleItem)
if (lonePreview != null) {
LinkPreviewCard(preview = lonePreview, compact = false)
} else if (note.body.isNotBlank()) {
// A note shared with us to view has inert boxes (#5175): ticking one is
// changing its text, which is not ours to do.
NoteBody(
note = note,
onToggleItem = if (note.access == NoteAccess.VIEW) INERT_TOGGLE else onToggleItem,
)
}
// Links mentioned INSIDE a note: a compact strip at the foot of the card,
// under the note's own words rather than stacked on top of them. Putting
// them above would set a stranger's headline where the note's first line
// should be — the web learned that in M13 and moved them down.
if (!isLoneUrl(note) && note.previews.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
note.previews.forEach { preview ->
LinkPreviewCard(preview = preview, compact = true)
Spacer(Modifier.height(4.dp))
}
}
// Under the words and the links, like the web's card: a photo is often what a
// note is ABOUT, but its first line is still its name.
if (note.attachments.isNotEmpty()) {
CardAttachments(attachments = note.attachments)
}
// A note with nothing in it still has to occupy the board legibly — otherwise
// it reads as a rendering bug.
if (note.body.isBlank()) {
if (note.body.isBlank() && note.previews.isEmpty() && note.attachments.isEmpty()) {
Text(
text = stringResource(R.string.board_empty_note),
style = MaterialTheme.typography.bodyMedium,
@@ -145,6 +177,8 @@ fun NoteCard(
Spacer(Modifier.height(8.dp))
ReminderChip(instant = at, recurrence = note.recurrence)
}
SharedChip(note)
}
NoteMenu(
@@ -195,7 +229,11 @@ private fun NoteMenu(
onAction: (EditorAction) -> Unit,
onConfirmDelete: () -> Unit,
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
// Pin and archive are this account's own on someone else's note too (#5176);
// trash is the owner's. A note shared with us never reaches our Trash, so the
// trashed set is only ever the owner's.
val owner = note.access == NoteAccess.OWNER
DropdownMenu(expanded = expanded && (owner || !note.trashed), onDismissRequest = onDismiss) {
if (note.trashed) {
MenuItem(R.string.editor_restore, onDismiss) { onAction(EditorAction.Restore) }
MenuItem(R.string.editor_delete_forever, onDismiss, onConfirmDelete)
@@ -208,7 +246,9 @@ private fun NoteMenu(
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
onDismiss,
) { onAction(EditorAction.SetArchived(!note.archived)) }
MenuItem(R.string.editor_trash, onDismiss) { onAction(EditorAction.Trash) }
if (owner) {
MenuItem(R.string.editor_trash, onDismiss) { onAction(EditorAction.Trash) }
}
}
}
}
@@ -418,6 +458,42 @@ private fun ReminderChip(
)
}
/**
* Whose note this is, when it is not only ours (#5175): "From Robin" on a note someone
* shared with us, "Shared" on one of ours that we shared. Nothing on a private note.
*/
@Composable
private fun SharedChip(note: Note) {
val text =
when {
note.access != NoteAccess.OWNER ->
stringResource(
R.string.share_chip_by,
note.sharedBy?.displayName?.takeIf { it.isNotBlank() } ?: stringResource(R.string.share_someone),
)
note.shared -> stringResource(R.string.share_chip_shared)
else -> return
}
val dark = isSystemInDarkTheme()
val tint = noteTint("default")
Spacer(Modifier.height(8.dp))
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = tint.chipForeground(dark),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.clip(RoundedCornerShape(CHIP_RADIUS))
.background(tint.chipBackground(dark))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
/** A box that does nothing when tapped: someone else's note, shared to view. */
private val INERT_TOGGLE: (Int, Boolean) -> Unit = { _, _ -> }
private const val MAX_PREVIEW_LINES = 8
/** How far one long line of a card may wrap before it is cut. */
@@ -1,6 +1,8 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -25,8 +27,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.core.Label
import com.fabledsword.thoughtsync.core.Note
import com.fabledsword.inkwell.core.Label
import com.fabledsword.inkwell.core.Note
import kotlinx.coroutines.delay
/**
@@ -56,6 +58,7 @@ fun NoteEditorScreen(
labels: List<Label>,
saving: Boolean,
error: String?,
onShare: () -> Unit,
onAction: (EditorAction) -> Unit,
) {
val dark = isSystemInDarkTheme()
@@ -88,7 +91,13 @@ fun NoteEditorScreen(
// A note in the trash is a record, not a document: editing one would silently
// resurrect work that was meant to be thrown away. It renders read-only, with
// Restore and Delete forever as the only things to do with it.
val readOnly = note.trashed
//
// A note shared with us to view is read-only for the same reason it is on the
// web: nothing about it is ours to change (#5175). At edit, the text is, and the
// rows that are the owner's (tags, reminder, files, previews) stay inert.
val access = note.access
val readOnly = note.readOnlyHere
val ownerControls = note.ownerControlsHere
// Persist the text, if it changed. The baseline check is what makes "open a
// note, read it, back out" write nothing at all — without it every glance
@@ -132,6 +141,14 @@ fun NoteEditorScreen(
BackHandler(onBack = leave)
// The system picker, for any type: a note can carry a PDF as readily as a photo.
// Leaving for it stops this screen, so FlushOnStop has already saved the text by
// the time the files come back.
val pickFiles =
rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
if (uris.isNotEmpty()) onAction(EditorAction.Attach(uris))
}
// Leaving the APP is not closing the editor, so the text has to be saved
// without the screen being torn down. Losing a paragraph to an incoming call
// is exactly the failure that makes someone stop trusting a notes app.
@@ -167,6 +184,7 @@ fun NoteEditorScreen(
EditorTopBar(
note = note,
readOnly = readOnly,
access = access,
onClose = leave,
onStartChecklist = {
val (next, id) = blocks.plusTask()
@@ -174,6 +192,11 @@ fun NoteEditorScreen(
focus = id
},
onPicker = { picker = it },
onAttach = { pickFiles.launch(ANY_TYPE) },
onShare = {
flush()
onShare()
},
onConfirmDelete = { confirmingDelete = true },
onAction = onAction,
)
@@ -214,6 +237,9 @@ fun NoteEditorScreen(
)
}
// Whose note this is, and what may be done with it here.
SharedByLine(note)
// A note is its body; its NAME is that body's first line, so there
// is nothing separate to type into and nothing rendered bolder than
// the line beneath it (M13 steps 3 and 4). What 2992 changed is only
@@ -232,17 +258,29 @@ fun NoteEditorScreen(
// list on screen twice.
if (note.labels.isNotEmpty()) {
EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction)
EditorLabelRow(note = note, readOnly = !ownerControls, onAction = onAction)
}
note.remindAt?.let { at ->
EditorReminderRow(
at = at,
recurrence = note.recurrence,
readOnly = readOnly,
readOnly = !ownerControls,
onAction = onAction,
)
}
if (note.attachments.isNotEmpty()) {
EditorAttachments(
attachments = note.attachments,
readOnly = !ownerControls,
onAction = onAction,
)
}
if (note.previews.isNotEmpty()) {
EditorPreviews(previews = note.previews, readOnly = !ownerControls, onAction = onAction)
}
}
}
}
@@ -307,6 +345,9 @@ private fun EditorOverlays(
*/
private const val AUTOSAVE_IDLE_MS = 1_000L
/** What the file picker offers: everything. The server takes any type. */
private const val ANY_TYPE = "*/*"
/**
* The card's top corner radius — Material's extra-large, which is what a bottom
* sheet uses. Same shape as the capture surface this replaced, on purpose.
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.background
@@ -20,7 +20,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.inkwell.R
/**
* A bordered block, tinted from the same table the notes use.
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.fillMaxWidth
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
import android.app.AlarmManager
import android.content.Context
@@ -17,8 +17,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationManagerCompat
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.Reminders
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.Reminders
/**
* Says so when a reminder would not actually reach anyone.
@@ -0,0 +1,238 @@
package com.fabledsword.inkwell.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.core.Member
import com.fabledsword.inkwell.core.Note
import com.fabledsword.inkwell.core.NoteShare
/** "Shared by Robin · view only" over someone else's note; nothing over our own. */
@Composable
fun SharedByLine(note: Note) {
if (note.access == NoteAccess.OWNER) return
val who = note.sharedBy?.displayName?.takeIf { it.isNotBlank() } ?: stringResource(R.string.share_someone)
val line =
if (note.access == NoteAccess.EDIT) {
stringResource(R.string.share_by_can_edit, who)
} else {
stringResource(R.string.share_by_view_only, who)
}
Text(
text = line,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
}
private const val VIEW = "view"
private const val EDIT = "edit"
/**
* The Share sheet: who the note is shared with, and adding someone.
*
* A peer of the web's `ShareDialog.vue`. Each person's permission is a pair of
* chips rather than a menu, because there are exactly two and both fit.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShareSheet(
state: ShareState,
onShare: (userId: String, permission: String) -> Unit,
onUnshare: (shareId: String) -> Unit,
onDismiss: () -> Unit,
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp)
.navigationBarsPadding(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SheetTitle(R.string.share_title)
when {
state.loading -> Muted(stringResource(R.string.share_loading))
state.needsServer -> Muted(stringResource(R.string.share_needs_server))
state.loadError != null -> ErrorText(state.loadError)
else -> ShareBody(state, onShare, onUnshare)
}
}
}
}
@Composable
private fun ShareBody(
state: ShareState,
onShare: (userId: String, permission: String) -> Unit,
onUnshare: (shareId: String) -> Unit,
) {
if (state.shares.isEmpty()) {
Muted(stringResource(R.string.share_none))
}
state.shares.forEach { share ->
ShareRow(
share = share,
busy = state.busy,
onPermission = { onShare(share.member.id, it) },
onRemove = { onUnshare(share.id) },
)
}
if (state.available.isEmpty()) {
Muted(stringResource(R.string.share_everyone))
} else {
AddPerson(state = state, onShare = onShare)
}
state.error?.let { ErrorText(it) }
}
@Composable
private fun ShareRow(
share: NoteShare,
busy: Boolean,
onPermission: (String) -> Unit,
onRemove: () -> Unit,
) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
MemberName(share.member, Modifier.weight(1f))
IconButton(onClick = onRemove, enabled = !busy) {
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.share_remove))
}
}
PermissionChips(selected = share.permission, enabled = !busy, onSelect = onPermission)
}
}
@Composable
private fun AddPerson(
state: ShareState,
onShare: (userId: String, permission: String) -> Unit,
) {
var pick by remember(state.noteId) { mutableStateOf<String?>(null) }
var permission by remember(state.noteId) { mutableStateOf(VIEW) }
Text(
text = stringResource(R.string.share_add),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(top = 8.dp),
)
// Capped, like the tag picker: a sheet that grows past the screen makes its own
// scroll fight the sheet's drag.
LazyColumn(modifier = Modifier.heightIn(max = MEMBER_LIST_MAX_HEIGHT)) {
items(items = state.available, key = { it.id }) { member ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().clickable { pick = member.id },
) {
RadioButton(selected = pick == member.id, onClick = { pick = member.id })
MemberName(member, Modifier.weight(1f))
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
PermissionChips(selected = permission, enabled = !state.busy, onSelect = { permission = it })
Spacer(Modifier.weight(1f))
TextButton(
enabled = pick != null && !state.busy,
onClick = {
pick?.let { onShare(it, permission) }
pick = null
},
) { Text(stringResource(R.string.share_action)) }
}
}
@Composable
private fun PermissionChips(
selected: String,
enabled: Boolean,
onSelect: (String) -> Unit,
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = selected == VIEW,
enabled = enabled,
onClick = { onSelect(VIEW) },
label = { Text(stringResource(R.string.share_can_view)) },
)
FilterChip(
selected = selected == EDIT,
enabled = enabled,
onClick = { onSelect(EDIT) },
label = { Text(stringResource(R.string.share_can_edit)) },
)
}
}
@Composable
private fun MemberName(
member: Member,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Text(
text = member.displayName.ifBlank { member.email },
style = MaterialTheme.typography.bodyLarge,
)
if (member.displayName.isNotBlank()) {
Text(
text = member.email,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun Muted(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable
private fun ErrorText(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
private val MEMBER_LIST_MAX_HEIGHT = 240.dp
@@ -0,0 +1,116 @@
package com.fabledsword.inkwell.ui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.fabledsword.inkwell.core.CoreException
import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.inkwell.core.Member
import com.fabledsword.inkwell.core.NoteShare
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** Everything the Share sheet renders from. [noteId] null means the sheet is closed. */
data class ShareState(
val noteId: String? = null,
val loading: Boolean = false,
val members: List<Member> = emptyList(),
val shares: List<NoteShare> = emptyList(),
/** This device has no server, and sharing is between people on one. */
val needsServer: Boolean = false,
/** Why the sheet couldn't load, when it has a server and still failed. */
val loadError: String? = null,
/** An in-flight write, for disabling the controls that would race it. */
val busy: Boolean = false,
val error: String? = null,
) {
/** The people it isn't shared with yet. */
val available: List<Member>
get() {
val taken = shares.map { it.member.id }.toSet()
return members.filterNot { it.id in taken }
}
}
/**
* Sharing a note with other people on the linked server (#5175).
*
* A peer of the web's `ShareDialog.vue`. Shares belong to the server, so every call
* here is a network round-trip through the core — `suspend` functions on uniffi's
* tokio runtime, not blocking store calls, so no IO dispatcher is needed. The one
* thing the core keeps locally is the note's `shared` flag, which is why a write
* that landed tells the board to reload: its card chip reads that flag.
*/
class ShareViewModel(
private val core: Inkwell,
/** Called after a share was granted, changed or removed. */
private val onStoreChanged: () -> Unit,
) : ViewModel() {
var state by mutableStateOf(ShareState())
private set
fun open(noteId: String) {
state = ShareState(noteId = noteId, loading = true)
viewModelScope.launch {
// Unlinked is not a failure: it is the phone working as intended, so the
// sheet says what sharing needs rather than showing an error.
state =
try {
if (!withContext(Dispatchers.IO) { core.syncStatus().linked }) {
state.copy(loading = false, needsServer = true)
} else {
val members = core.shareDirectory()
val shares = core.noteShares(noteId)
state.copy(loading = false, members = members, shares = shares)
}
} catch (e: CoreException) {
state.copy(loading = false, loadError = e.describeShareFailure())
}
}
}
fun close() {
state = ShareState()
}
/** Share with someone, or change what they may do. */
fun share(
userId: String,
permission: String,
) = write { noteId -> core.shareNote(noteId, userId, permission) }
fun unshare(shareId: String) = write { noteId -> core.unshareNote(noteId, shareId) }
private fun write(call: suspend (String) -> List<NoteShare>) {
val noteId = state.noteId ?: return
state = state.copy(busy = true, error = null)
viewModelScope.launch {
state =
try {
val shares = call(noteId)
onStoreChanged()
state.copy(busy = false, shares = shares)
} catch (e: CoreException) {
state.copy(busy = false, error = e.describeShareFailure())
}
}
}
companion object {
fun factory(
core: Inkwell,
onStoreChanged: () -> Unit,
): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = ShareViewModel(core, onStoreChanged) as T
}
}
}
/** The core's message is written to be shown ("The server doesn't have this note yet…"). */
private fun Throwable.describeShareFailure(): String = message ?: "Something went wrong."
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
import android.os.Build
import androidx.compose.foundation.layout.Arrangement
@@ -27,9 +27,9 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Compatibility
import com.fabledsword.thoughtsync.core.RevokeOutcome
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.core.Compatibility
import com.fabledsword.inkwell.core.RevokeOutcome
// Becoming linked: the probe-then-sign-in flow, and the notices around it.
//
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -31,18 +31,20 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.UpdateOutcome
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.UpdateOutcome
import com.fabledsword.inkwell.installedVersionName
/**
* Opt-in server pairing.
*
* The whole screen is written around one idea: **being unlinked is not a
* problem.** ThoughtSync is local-first and completely usable having never opened
* problem.** Inkwell is local-first and completely usable having never opened
* this screen, so the unlinked state leads with "Working offline on this device"
* and explains what connecting would ADD, rather than presenting an empty form as
* unfinished setup.
@@ -120,10 +122,38 @@ fun SyncScreen(
onDismissRevokeNotice = onDismissRevokeNotice,
)
}
BuildLine()
}
}
}
/**
* The build, dim, at the foot of Sync — the same thing the web UI puts at the
* bottom of its rail (#3181).
*
* Note 3127 §5 is why it is here at all. With version tags gone, an artifact's own
* self-report is the only answer to "which build is this?" — so it renders
* "unknown" rather than nothing when the name is absent, because a blank line looks
* like a layout bug and a plausible default cannot be caught by anything.
*
* The read itself is `installedVersionName()`, shared with the client header the
* app sends its server: one answer to "which build is on this phone", so the line
* a person quotes in a bug report and the line in the server's log cannot disagree.
*/
@Composable
private fun BuildLine() {
val context = LocalContext.current
val unknown = stringResource(R.string.build_unknown)
val version = remember(context) { context.installedVersionName() ?: unknown }
Text(
text = version,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 16.dp),
)
}
// ───────────────────────────────── linked ─────────────────────────────────
@Composable
@@ -1,11 +1,11 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Compatibility
import com.fabledsword.thoughtsync.core.SyncOutcome
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.core.Compatibility
import com.fabledsword.inkwell.core.SyncOutcome
// Turning sync results into sentences.
//
@@ -32,6 +32,8 @@ fun syncSummary(outcome: SyncOutcome): String {
if (sent > 0) parts += stringResource(R.string.sync_summary_sent, sent)
if (received > 0) parts += stringResource(R.string.sync_summary_received, received)
if (blobs > 0) parts += pluralStringResource(R.plurals.sync_summary_attachments, blobs, blobs)
val uploaded = outcome.push.uploaded.toInt()
if (uploaded > 0) parts += pluralStringResource(R.plurals.sync_summary_uploaded, uploaded, uploaded)
val line =
if (parts.isEmpty()) {
@@ -44,11 +46,13 @@ fun syncSummary(outcome: SyncOutcome): String {
// rather than an error — but saying nothing would leave a missing image
// looking like data loss.
val failed = outcome.pull.blobsFailed.toInt()
return if (failed > 0) {
line + " " + pluralStringResource(R.plurals.sync_summary_attachments_failed, failed, failed)
} else {
line
}
val notUploaded = outcome.push.uploadFailed.toInt()
val notes = mutableListOf(line)
if (failed > 0) notes += pluralStringResource(R.plurals.sync_summary_attachments_failed, failed, failed)
// A refused upload is recorded on its attachment and shown in the editor; this
// line is what sends someone looking.
if (notUploaded > 0) notes += pluralStringResource(R.plurals.sync_summary_upload_failed, notUploaded, notUploaded)
return notes.joinToString(" ")
}
/**
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -6,12 +6,12 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.fabledsword.thoughtsync.core.Compatibility
import com.fabledsword.thoughtsync.core.ProbeResult
import com.fabledsword.thoughtsync.core.RevokeOutcome
import com.fabledsword.thoughtsync.core.SyncOutcome
import com.fabledsword.thoughtsync.core.SyncStatus
import com.fabledsword.thoughtsync.core.ThoughtSync
import com.fabledsword.inkwell.core.Compatibility
import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.inkwell.core.ProbeResult
import com.fabledsword.inkwell.core.RevokeOutcome
import com.fabledsword.inkwell.core.SyncOutcome
import com.fabledsword.inkwell.core.SyncStatus
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -113,7 +113,7 @@ data class SyncState(
* stored cursor next time (Scribe #2736).
*/
class SyncViewModel(
private val core: ThoughtSync,
private val core: Inkwell,
/**
* Called after a sync that changed the store.
*
@@ -316,7 +316,7 @@ class SyncViewModel(
companion object {
fun factory(
core: ThoughtSync,
core: Inkwell,
onStoreChanged: () -> Unit,
): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@@ -344,7 +344,7 @@ private fun SyncOutcome.changedTheStore(): Boolean =
* The message to show for a failure.
*
* The core writes these for people to read — "notes.example.com responded, but not
* with ThoughtSync's configuration" — so they are shown as-is rather than
* with Inkwell's configuration" — so they are shown as-is rather than
* replaced with a generic string that would throw away the only useful part.
*/
private fun Exception.describe(): String = message ?: "Something went wrong."
@@ -0,0 +1,584 @@
package com.fabledsword.inkwell.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.core.Label
/** The swatch shown beside a tag, and tapped to change its colour. */
private val SWATCH = 22.dp
/** What the row's overflow menu is currently asking about. */
private sealed interface TagDialog {
data class Rename(
val tag: Label,
) : TagDialog
/** A rename whose new name another tag already holds — see [RenameDialog]. */
data class ConfirmMerge(
val tag: Label,
val into: Label,
val name: String,
) : TagDialog
data class Merge(
val tag: Label,
) : TagDialog
data class Delete(
val tag: Label,
) : TagDialog
data class Colour(
val tag: Label,
) : TagDialog
}
/**
* Tag management: list, create, rename, recolour, delete, merge.
*
* A destination you go to, not a modal. The web's `LabelsModal.vue` is a modal
* because a desktop has room to float one over the board; on a phone this is a
* place you visit to tidy up, and a full screen is what that is.
*
* It is also, since the per-note colour picker was removed, the ONLY colour
* control in the product. That is why the swatch is a first-class tap target on
* every row rather than something behind the overflow menu.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TagsScreen(
state: TagsState,
onClose: () -> Unit,
onCreate: (String) -> Unit,
onRename: (String, String) -> Unit,
onColour: (String, String) -> Unit,
onDelete: (String) -> Unit,
onMerge: (String, String) -> Unit,
onDismissError: () -> Unit,
) {
var dialog by remember { mutableStateOf<TagDialog?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.tags_title)) },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.tags_back),
)
}
},
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.imePadding(),
) {
// An indeterminate bar rather than blocking the list: a tag write is a
// local SQLite call and usually finishes before this is seen at all.
if (state.busy) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
// The same banner the board and the editor use. A third way of saying
// "that did not work" would be a third thing to keep consistent.
state.error?.let { message ->
ErrorBanner(message = message, onDismiss = onDismissError)
}
NewTagField(
enabled = !state.busy,
onCreate = onCreate,
)
if (!state.loading && state.tags.isEmpty()) {
EmptyTags()
}
// weight, NOT fillMaxSize: this has siblings above it, and filling the
// whole height would measure the list against space the field and the
// banner have already taken — pushing the end of the list off-screen.
LazyColumn(modifier = Modifier.weight(1f)) {
items(state.tags, key = { it.id }) { tag ->
TagRow(
tag = tag,
enabled = !state.busy,
onColour = { dialog = TagDialog.Colour(tag) },
onRename = { dialog = TagDialog.Rename(tag) },
onMerge = { dialog = TagDialog.Merge(tag) },
onDelete = { dialog = TagDialog.Delete(tag) },
)
}
}
}
}
when (val open = dialog) {
null -> Unit
is TagDialog.Rename ->
RenameDialog(
tag = open.tag,
others = state.tags,
onDismiss = { dialog = null },
onRename = { name ->
dialog = null
onRename(open.tag.id, name)
},
// Renaming onto a name another tag holds MERGES the two, and that
// cannot be undone by repeating it, so the confirmation replaces
// this dialog rather than the rename just happening.
onWouldMerge = { into, name -> dialog = TagDialog.ConfirmMerge(open.tag, into, name) },
)
is TagDialog.ConfirmMerge ->
ConfirmDialog(
title = stringResource(R.string.tags_rename_merges_title, hash(open.into.name)),
body = stringResource(R.string.tags_rename_merges_body, hash(open.into.name)),
confirm = stringResource(R.string.tags_rename_merges_confirm),
onDismiss = { dialog = null },
onConfirm = {
dialog = null
onRename(open.tag.id, open.name)
},
)
is TagDialog.Merge ->
MergeDialog(
tag = open.tag,
others = state.tags.filter { it.id != open.tag.id },
onDismiss = { dialog = null },
onMerge = { target ->
dialog = null
onMerge(open.tag.id, target.id)
},
)
is TagDialog.Delete ->
ConfirmDialog(
title = stringResource(R.string.tags_delete_title, hash(open.tag.name)),
// The count is the part that makes the consequence real — "it is on
// 40 notes" is a different decision from "delete this tag?". It comes
// from the LIST, the only call the core populates a count on.
body =
open.tag.count
?.takeIf { it > 0 }
?.let { stringResource(R.string.tags_delete_body_counted, it) }
?: stringResource(R.string.tags_delete_body),
footnote = stringResource(R.string.tags_delete_from_text),
confirm = stringResource(R.string.tags_delete_confirm),
onDismiss = { dialog = null },
onConfirm = {
dialog = null
onDelete(open.tag.id)
},
)
is TagDialog.Colour ->
ColourDialog(
tag = open.tag,
onDismiss = { dialog = null },
onPick = { key ->
dialog = null
onColour(open.tag.id, key)
},
)
}
}
/**
* `#` on the name, everywhere it is spoken about.
*
* The chips already wear it (`NoteCard.kt`, `EditorChrome.kt`) and it is the
* reason these are called tags at all — a dialog that said "Delete grocery?" would
* be talking about something else.
*/
private fun hash(name: String): String = "#$name"
@Composable
private fun NewTagField(
enabled: Boolean,
onCreate: (String) -> Unit,
) {
var text by remember { mutableStateOf("") }
val submit = {
if (text.isNotBlank()) {
onCreate(text)
text = ""
}
}
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
PlainTextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.weight(1f),
hint = R.string.tags_new_hint,
enabled = enabled,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { submit() }),
)
TextButton(onClick = submit, enabled = enabled && text.isNotBlank()) {
Text(stringResource(R.string.tags_create))
}
}
}
/**
* Said out loud rather than left as a blank screen — and it names the `#` route,
* because the operator did not know `#tag` extraction existed at all (Scribe
* #2949) and this is the natural place to say so.
*/
@Composable
private fun EmptyTags() {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = stringResource(R.string.tags_empty_title),
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringResource(R.string.tags_empty_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun TagRow(
tag: Label,
enabled: Boolean,
onColour: () -> Unit,
onRename: () -> Unit,
onMerge: () -> Unit,
onDelete: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = labelTintFor(tag.name, tag.color)
var menuOpen by remember { mutableStateOf(false) }
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier =
Modifier
.size(SWATCH)
.clip(CircleShape)
.background(tint.chipBackground(dark))
.border(1.dp, tint.chipBorder(dark), CircleShape)
.clickable(enabled = enabled, onClick = onColour),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = hash(tag.name),
style = MaterialTheme.typography.bodyLarge,
color = tint.tagInk(dark),
)
Text(
text = countLabel(tag.count),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Column {
IconButton(onClick = { menuOpen = true }, enabled = enabled) {
Icon(
Icons.Filled.MoreVert,
contentDescription = stringResource(R.string.tags_actions),
)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
// Panel.kt's MenuItem, not a bare DropdownMenuItem: every one of
// these raises a dialog, and it closes the menu BEFORE acting so the
// dialog cannot open underneath a menu still hanging over it.
val close = { menuOpen = false }
MenuItem(R.string.tags_rename, close, onRename)
MenuItem(R.string.tags_merge, close, onMerge)
MenuItem(R.string.tags_delete, close, onDelete)
}
}
}
}
/**
* Zero is its own sentence, not "0 notes".
*
* A count of null means the core did not populate one — only `list_labels` does —
* which is a different thing from a tag with no notes, so it reads as unknown
* rather than as empty.
*/
@Composable
private fun countLabel(count: Long?): String =
when {
count == null -> ""
count <= 0L -> stringResource(R.string.tags_count_none)
count == 1L -> stringResource(R.string.tags_count_one)
else -> stringResource(R.string.tags_count, count.toInt())
}
/**
* Rename, with the merge caught before it happens.
*
* The collision is detected HERE, against the list, rather than from what the
* core returns: the merge survivor is whichever tag is older, so it may well be
* the one being renamed, and an unchanged id afterwards would prove nothing.
* Matching is case-insensitive because the core's is.
*/
@Composable
private fun RenameDialog(
tag: Label,
others: List<Label>,
onDismiss: () -> Unit,
onRename: (String) -> Unit,
onWouldMerge: (Label, String) -> Unit,
) {
var text by remember(tag.id) { mutableStateOf(tag.name) }
val trimmed = text.trim()
val clash =
others.firstOrNull { it.id != tag.id && it.name.equals(trimmed, ignoreCase = true) }
val submit = {
when {
trimmed.isEmpty() -> Unit
clash != null -> onWouldMerge(clash, trimmed)
else -> onRename(trimmed)
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.tags_rename_title, hash(tag.name))) },
text = {
PlainTextField(
value = text,
onValueChange = { text = it },
hint = R.string.tags_new_hint,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { submit() }),
)
},
confirmButton = {
TextButton(onClick = submit, enabled = trimmed.isNotEmpty()) {
Text(stringResource(R.string.tags_rename_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
},
)
}
/**
* Merge, with the direction stated and the survivor named.
*
* Unlike a rename — where the OLDER tag survives so that the outcome cannot
* depend on which way round it was typed — this one is deliberate, so the
* direction the person chooses IS the intent and is honoured. The price of that
* is that the direction has to be unmissable, which is why the body names the tag
* that stops existing and every row here is the one that survives.
*/
@Composable
private fun MergeDialog(
tag: Label,
others: List<Label>,
onDismiss: () -> Unit,
onMerge: (Label) -> Unit,
) {
val dark = isSystemInDarkTheme()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.tags_merge_title, hash(tag.name))) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(R.string.tags_merge_body, hash(tag.name)),
style = MaterialTheme.typography.bodyMedium,
)
if (others.isEmpty()) {
Text(
text = stringResource(R.string.tags_merge_none),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
others.forEach { other ->
val tint = labelTintFor(other.name, other.color)
Text(
text = hash(other.name),
style = MaterialTheme.typography.bodyLarge,
color = tint.tagInk(dark),
modifier =
Modifier
.fillMaxWidth()
.clickable { onMerge(other) }
.padding(vertical = 8.dp),
)
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
},
)
}
/**
* The palette, and since the per-note picker was removed this is the only place in
* the product a colour is chosen.
*
* Every key from [NOTE_TINTS], `default` included: a tag whose colour is
* `default` gets a hue derived from its name (`DerivedTint.kt`), so "default" here
* means "let it pick" rather than "grey", and taking it away would leave no way
* back to that.
*/
@Composable
private fun ColourDialog(
tag: Label,
onDismiss: () -> Unit,
onPick: (String) -> Unit,
) {
val dark = isSystemInDarkTheme()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.tags_colour_of, hash(tag.name))) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
NOTE_TINTS.forEach { (key, tint) ->
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { onPick(key) }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier =
Modifier
.size(SWATCH)
.clip(CircleShape)
.background(tint.chipBackground(dark))
.border(1.dp, tint.chipBorder(dark), CircleShape),
)
Text(
text = tint.label,
style = MaterialTheme.typography.bodyMedium,
color =
if (key == tag.color) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
)
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
},
)
}
/** A destructive confirmation: what it is, what it costs, and one way out. */
@Composable
private fun ConfirmDialog(
title: String,
body: String,
confirm: String,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
footnote: String? = null,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = body, style = MaterialTheme.typography.bodyMedium)
footnote?.let {
Text(
text = it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
confirmButton = {
TextButton(onClick = onConfirm) { Text(confirm) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
},
)
}
@@ -0,0 +1,181 @@
package com.fabledsword.inkwell.ui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.inkwell.core.Label
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Everything the Tags screen renders from.
*
* [tags] comes from `list_labels`, which is the only call that populates a
* `count` — the single-tag returns leave it null by design. So the counts a
* confirmation dialog quotes are always the LIST's, never an operation's result.
*/
data class TagsState(
val loading: Boolean = true,
val tags: List<Label> = emptyList(),
/** An in-flight write, for disabling the controls that would race it. */
val busy: Boolean = false,
val error: String? = null,
)
/**
* Create, rename, recolour, delete and merge tags.
*
* A peer of the web's `LabelsModal.vue`, not a reduced companion — the same six
* operations over the same core the desktop uses.
*
* ## Why every write re-lists
*
* A tag operation changes more than the row it names. A merge deletes one tag and
* moves its notes; a delete changes nothing else's count but removes a drawer
* lens; a rename can MERGE (see below) and so can make a different row vanish.
* Re-listing after each write costs one cheap local SQLite read and removes a
* whole class of "the screen thinks there are still two" bugs. Patching the list
* in place would mean re-deriving, in Kotlin, rules the core already owns.
*
* ## Renaming can merge
*
* `rename_label` folds two tags together when the new name is one another tag
* already holds, and the OLDER row survives (Scribe #3324). So it can return a
* tag whose id is not the one passed in, and it can make another tag stop
* existing. The screen asks first; this view model does not, because a
* confirmation belongs to the surface with a person in front of it.
*
* ## Threading
*
* All of these are ordinary blocking FFI into SQLite — no async, no network — so
* they take [Dispatchers.IO], exactly like the board's calls. Sync happens later:
* the core marks the rows dirty and the next sync carries them.
*/
class TagsViewModel(
private val core: Inkwell,
/**
* Called after any write that landed.
*
* The board holds its own snapshot of the tag list for the drawer, and its
* current destination may BE one of these tags — deleting or merging that one
* leaves it looking at a lens that no longer exists. Wiring the two together
* explicitly is less magic than a shared event bus and makes the dependency
* visible at the construction site, the same way [SyncViewModel] does it.
*/
private val onStoreChanged: () -> Unit,
) : ViewModel() {
var state by mutableStateOf(TagsState())
private set
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
state =
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
.fold(
onSuccess = { state.copy(tags = it, loading = false, error = null) },
// Unlike the drawer, this screen cannot fail quietly: it is
// the only thing on the display, and an empty list here
// would read as "you have no tags" rather than "I couldn't
// look".
onFailure = { state.copy(loading = false, error = it.describeTagFailure()) },
)
}
}
fun create(name: String) {
val trimmed = name.trim()
if (trimmed.isEmpty()) return
// Find-or-create in the core: typing a name that exists in another case
// attaches the existing tag rather than minting a near-duplicate.
write { it.createLabel(trimmed) }
}
fun rename(
id: String,
name: String,
) {
val trimmed = name.trim()
if (trimmed.isEmpty()) return
write { it.renameLabel(id, trimmed) }
}
fun setColour(
id: String,
colour: String,
) = write { it.setLabelColor(id, colour) }
fun remove(id: String) = write { it.removeLabel(id) }
/**
* Fold [sourceId] into [targetId]. The source stops existing.
*
* Directional and not undone by repeating it — the caller has to have said
* which one survives before this runs, because afterwards there is nothing
* left to read the direction from.
*/
fun merge(
sourceId: String,
targetId: String,
) {
if (sourceId == targetId) return
write { it.mergeLabels(sourceId, targetId) }
}
fun dismissError() {
state = state.copy(error = null)
}
/**
* Run one store write, then re-list and tell the board.
*
* `busy` is cleared in the same assignment that stores the result, so no path
* out of here can leave the screen stuck with its controls disabled.
*/
private fun write(block: (Inkwell) -> Unit) {
if (state.busy) return
state = state.copy(busy = true, error = null)
viewModelScope.launch {
val failure =
runCatching { withContext(Dispatchers.IO) { block(core) } }
.exceptionOrNull()
val tags =
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
.getOrDefault(state.tags)
state =
state.copy(
tags = tags,
busy = false,
error = failure?.describeTagFailure(),
)
// Even a FAILED write can have changed the store — a merge that threw
// partway still moved rows — so the board is told either way.
onStoreChanged()
}
}
companion object {
fun factory(
core: Inkwell,
onStoreChanged: () -> Unit,
): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = TagsViewModel(core, onStoreChanged) as T
}
}
}
/**
* The core reports problems as one error type carrying a message meant to be
* shown, so the message is used when there is one.
*/
private fun Throwable.describeTagFailure(): String = message ?: "Something went wrong."
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
@@ -59,7 +59,7 @@ private val DarkColors =
)
/**
* Material 3 in ThoughtSync's own colours, following the system light/dark setting.
* Material 3 in Inkwell's own colours, following the system light/dark setting.
*
* DELIBERATELY NOT Material You dynamic colour, which this used until the operator
* saw the first build. Dynamic colour is the more Android-native choice and it
@@ -72,7 +72,7 @@ private val DarkColors =
* If dynamic colour is ever wanted it belongs behind a setting, not as the default.
*/
@Composable
fun ThoughtSyncTheme(
fun InkwellTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
import java.time.LocalDateTime
import java.time.OffsetDateTime
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -25,9 +25,9 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.AppUpdate
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.UpdateOutcome
import com.fabledsword.inkwell.AppUpdate
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.UpdateOutcome
/**
* Updating the app from the server it is linked to.
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
import android.content.Context
import androidx.compose.runtime.getValue
@@ -7,10 +7,10 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.fabledsword.thoughtsync.AppUpdate
import com.fabledsword.thoughtsync.UpdateOutcome
import com.fabledsword.thoughtsync.core.ClientUpdate
import com.fabledsword.thoughtsync.core.ThoughtSync
import com.fabledsword.inkwell.AppUpdate
import com.fabledsword.inkwell.UpdateOutcome
import com.fabledsword.inkwell.core.ClientUpdate
import com.fabledsword.inkwell.core.Inkwell
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -61,7 +61,7 @@ data class UpdateState(
* secret this app holds across two languages for no gain.
*/
class UpdateViewModel(
private val core: ThoughtSync,
private val core: Inkwell,
/**
* MUST be the application context — it outlives this view model, and holding an
* Activity here is the textbook way to leak a window.
@@ -207,7 +207,7 @@ class UpdateViewModel(
companion object {
fun factory(
core: ThoughtSync,
core: Inkwell,
context: Context,
): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The Material paperclip ("attach_file"), for the editor's Attach button and the
file rows under a note.
A drawable rather than an Icons.* constant because material-icons-core does not
carry it, and the extended set is a multi-megabyte dependency for one glyph.
Tinted by whatever draws it, like every other icon in the toolbar.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M16.5,6v11.5c0,2.21 -1.79,4 -4,4s-4,-1.79 -4,-4V5c0,-1.38 1.12,-2.5 2.5,-2.5s2.5,1.12 2.5,2.5v10.5c0,0.55 -0.45,1 -1,1s-1,-0.45 -1,-1V6H10v9.5c0,1.38 1.12,2.5 2.5,2.5s2.5,-1.12 2.5,-2.5V5c0,-2.21 -1.79,-4 -4,-4S7,2.79 7,5v12.5c0,3.04 2.46,5.5 5.5,5.5s5.5,-2.46 5.5,-5.5V6h-1.5z" />
</vector>
@@ -3,10 +3,12 @@
Adaptive icon. minSdk is 26, so this is the ONLY icon Android will ask for —
no legacy raster fallback is needed.
The foreground is the shared maskable asset the web app already ships
(frontend/public/icon-maskable-512.png), which is drawn with the safe-zone
padding adaptive icons require. Reusing it means the phone, the web app and the
desktop all wear the same face rather than three near-misses.
The foreground is the inkwell mark alone on transparency, inside the 66dp safe
circle; the yellow is the background colour. It is rendered by
packaging/icons.py from the same drawing as the web and desktop icons, so all
three wear one face. Transparent rather than a full-bleed tile because the
monochrome layer below reuses it: a themed icon draws its alpha as a silhouette,
and an opaque foreground would come out as a solid square.
-->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

+1 -1
View File
@@ -2,6 +2,6 @@
<resources>
<!-- The product's brand colour, same value the web app's manifest and
<meta name="theme-color"> already use. One source of truth for "what
colour is ThoughtSync" across the three surfaces. -->
colour is Inkwell" across the three surfaces. -->
<color name="ic_launcher_background">#F5C518</color>
</resources>
+108 -12
View File
@@ -1,12 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">ThoughtSync</string>
<string name="app_name">Inkwell</string>
<!-- Search bar -->
<string name="search_hint">Search your notes</string>
<string name="search_clear">Clear search</string>
<string name="nav_open">Open navigation</string>
<string name="nav_labels">Labels</string>
<string name="nav_labels">Tags</string>
<!-- Compose sheet -->
<string name="compose_open">New note</string>
@@ -42,7 +42,7 @@
<string name="editor_body_hint">Take a note…</string>
<string name="editor_add_item">Add item</string>
<string name="editor_remove_item">Remove item</string>
<string name="editor_remove_label">Remove label</string>
<string name="editor_remove_label">Remove tag</string>
<string name="editor_reminder">Set a reminder</string>
<string name="editor_more">More actions</string>
<string name="editor_saving">Saving…</string>
@@ -52,12 +52,45 @@
<string name="editor_done">Done</string>
<string name="editor_pin">Pin</string>
<string name="editor_unpin">Unpin</string>
<string name="editor_labels">Labels…</string>
<string name="editor_labels">Tags…</string>
<string name="editor_archive">Archive</string>
<string name="editor_unarchive">Unarchive</string>
<string name="editor_trash">Move to trash</string>
<string name="editor_restore">Restore</string>
<string name="editor_cancel">Cancel</string>
<string name="editor_share">Share…</string>
<!-- Sharing (#5175). Shares live on the server, so the sheet says so when this
phone isn't linked to one. -->
<string name="share_title">Share</string>
<string name="share_loading">Loading…</string>
<string name="share_needs_server">Sharing is between people on a server. Link this phone in Sync to share notes.</string>
<string name="share_none">Not shared with anyone yet.</string>
<string name="share_everyone">Shared with everyone on this server.</string>
<string name="share_add">Add someone</string>
<string name="share_action">Share</string>
<string name="share_remove">Stop sharing</string>
<string name="share_can_view">Can view</string>
<string name="share_can_edit">Can edit</string>
<string name="share_someone">someone</string>
<string name="share_by_view_only">Shared by %1$s · view only</string>
<string name="share_by_can_edit">Shared by %1$s · you can edit the text</string>
<string name="share_chip_shared">Shared</string>
<string name="share_chip_by">From %1$s</string>
<!-- Attachments. A file is stored on the phone at once and uploaded on a later
sync, so nothing here talks about the network. -->
<string name="editor_attach">Attach a file</string>
<string name="attach_remove">Remove attachment</string>
<string name="attach_unnamed">Unnamed file</string>
<string name="attach_more">+%1$d more</string>
<string name="attach_refused">Not uploaded: %1$s</string>
<string name="attach_too_large">%1$s is over %2$d MB, too large to attach from the phone.</string>
<string name="attach_unreadable">Couldn\'t read %1$s.</string>
<string name="attach_not_here">This file hasn\'t downloaded to this phone yet. Sync, then try again.</string>
<string name="attach_no_app">No app on this phone opens this kind of file.</string>
<string name="attach_open_failed">Couldn\'t open the file: %1$s</string>
<string name="preview_remove">Remove link preview</string>
<!-- Deleting for good is the only thing in the app that cannot be undone, so
the copy says exactly that rather than asking "Are you sure?". -->
@@ -66,11 +99,61 @@
<string name="editor_delete_forever_body">It will be removed from this device and from every device you sync with. This cannot be undone.</string>
<string name="editor_delete_forever_confirm">Delete</string>
<!-- Quick capture from outside the app: the share sheet and the text-selection
toolbar. "New note" says what happens; the activity's own label would say
who it happens in. -->
<string name="capture_process_text">New note</string>
<!-- Tag management. The whole vocabulary is "tag" (see Scribe #2966); the
schema still says Label, and no string here needs to know that. -->
<string name="tags_manage">Manage tags</string>
<string name="tags_title">Tags</string>
<string name="tags_back">Back</string>
<string name="tags_new_hint">New tag</string>
<string name="tags_create">Create</string>
<string name="tags_count">%1$d notes</string>
<string name="tags_count_one">1 note</string>
<string name="tags_count_none">No notes yet</string>
<string name="tags_empty_title">No tags yet</string>
<string name="tags_empty_body">Create one above, or write a #tag in a note and it becomes one.</string>
<string name="tags_actions">More actions</string>
<string name="tags_colour">Colour</string>
<string name="tags_colour_of">Colour for %1$s</string>
<string name="tags_rename">Rename</string>
<string name="tags_rename_title">Rename %1$s</string>
<string name="tags_rename_confirm">Rename</string>
<!-- Renaming onto an existing tag merges the two, older survives (Scribe
#3324). A merge cannot be undone by repeating it and is reachable here by
a typo, so it says so before it happens — same reasoning as #2116. -->
<string name="tags_rename_merges_title">Merge with %1$s?</string>
<string name="tags_rename_merges_body">A tag called %1$s already exists. Renaming will merge these two into one, carrying every note from both. The notes are kept; one of the two tags stops existing, and that cannot be undone.</string>
<string name="tags_rename_merges_confirm">Merge</string>
<string name="tags_merge">Merge into…</string>
<string name="tags_merge_title">Merge %1$s into…</string>
<!-- The survivor is named in the button, not just the title: this is the one
operation here that repeating does not undo. -->
<string name="tags_merge_body">Every note tagged %1$s will be tagged with the one you pick instead, and %1$s will stop existing. The notes are kept.</string>
<string name="tags_merge_none">There is no other tag to merge into.</string>
<string name="tags_delete">Delete</string>
<string name="tags_delete_title">Delete %1$s?</string>
<string name="tags_delete_body">It will be removed from every note that has it, on every device you sync with. The notes themselves are kept.</string>
<string name="tags_delete_body_counted">It is on %1$d notes. It will be removed from all of them, on every device you sync with. The notes themselves are kept.</string>
<string name="tags_delete_confirm">Delete</string>
<!-- A tag written as #tag in a note's body is owned by that text. Deleting the
row cannot un-write the word, so it comes back on that note's next edit —
said here rather than left as a surprise. -->
<string name="tags_delete_from_text">Tags written as #tag in a note come back when that note is next edited.</string>
<string name="tags_cancel">Cancel</string>
<!-- Pickers -->
<string name="label_picker_title">Labels</string>
<string name="label_new_hint">Type a label and press enter</string>
<string name="label_from_tag">from #tag</string>
<string name="label_none_body">No labels yet. Type one above, or write a #tag in a note and it becomes one.</string>
<string name="label_picker_title">Tags</string>
<string name="label_new_hint">Type a tag and press enter</string>
<string name="label_from_tag">from the text</string>
<string name="label_none_body">No tags yet. Type one above, or write a #tag in a note and it becomes one.</string>
<string name="picker_next">Next</string>
<string name="picker_set">Set</string>
<string name="picker_time_title">Pick a time</string>
@@ -114,7 +197,7 @@
<string name="reminder_channel">Reminders</string>
<string name="reminder_channel_description">Notifies you when a note\'s reminder is due.</string>
<string name="reminder_notifications_blocked_title">Reminders can\'t notify you</string>
<string name="reminder_notifications_blocked_body">Notifications are turned off for ThoughtSync, so reminders will only show here on the board.</string>
<string name="reminder_notifications_blocked_body">Notifications are turned off for Inkwell, so reminders will only show here on the board.</string>
<string name="reminder_open_settings">Open settings</string>
<string name="reminder_inexact_title">Reminders may arrive late</string>
<string name="reminder_inexact_body">Without permission for exact alarms, Android delivers reminders when it next wakes the phone — usually within a few minutes, sometimes longer.</string>
@@ -131,7 +214,7 @@
<string name="update_later">Later</string>
<string name="update_failed_title">The update didn\'t install</string>
<string name="update_permission_title">Android needs your permission</string>
<string name="update_permission_body">ThoughtSync has to be allowed to install apps before it can update itself. This is a one-time setting.</string>
<string name="update_permission_body">Inkwell has to be allowed to install apps before it can update itself. This is a one-time setting.</string>
<string name="update_permission_action">Allow installing</string>
<string name="update_needs_server">App updates come from a server you connect. Until then, install new builds yourself.</string>
<string name="sync_footer">Your notes live on this device either way — syncing just keeps a server copy in step, so your other devices can catch up.</string>
@@ -146,14 +229,14 @@
<!-- Unlinked -->
<string name="sync_offline_title">Working offline on this device</string>
<string name="sync_offline_body">Everything works without a server — your notes are stored on this phone. Connect a ThoughtSync server if you want them to reach your other devices.</string>
<string name="sync_offline_body">Everything works without a server — your notes are stored on this phone. Connect an Inkwell server if you want them to reach your other devices.</string>
<string name="sync_address_label">Server address</string>
<string name="sync_address_hint">notes.example.com</string>
<string name="sync_address_help">Uses https unless you type http:// yourself.</string>
<string name="sync_check">Check</string>
<string name="sync_probe_failed">Couldn\'t reach that server</string>
<string name="sync_link_failed">Couldn\'t connect</string>
<string name="sync_server_generic">ThoughtSync server</string>
<string name="sync_server_generic">Inkwell server</string>
<string name="sync_server_version">v%1$s</string>
<string name="sync_compat_ok">Fully compatible.</string>
<string name="sync_compat_degraded">Compatible, but these features aren\'t available on this server: %1$s.</string>
@@ -197,7 +280,20 @@
<item quantity="one">%d attachment didn\'t download — it\'ll retry on the next sync.</item>
<item quantity="other">%d attachments didn\'t download — they\'ll retry on the next sync.</item>
</plurals>
<plurals name="sync_summary_uploaded">
<item quantity="one">uploaded %d file</item>
<item quantity="other">uploaded %d files</item>
</plurals>
<plurals name="sync_summary_upload_failed">
<item quantity="one">%d file didn\'t upload. If the server refused it, its note says why; otherwise it\'ll retry.</item>
<item quantity="other">%d files didn\'t upload. Any the server refused say why on their note; the rest will retry.</item>
</plurals>
<!-- Errors -->
<string name="error_dismiss">Dismiss</string>
<!-- The build, at the foot of Sync. Never blank: an APK with no versionName is
a real state (a bare `gradlew assembleDebug` with no override) and saying
so is better than an empty line that reads as a layout bug. -->
<string name="build_unknown">unknown</string>
</resources>
+1 -1
View File
@@ -6,5 +6,5 @@
truth in XML — the same reason the desktop reads its live theme rather
than hardcoding a window colour.
-->
<style name="Theme.ThoughtSync" parent="android:Theme.Material.NoActionBar" />
<style name="Theme.Inkwell" parent="android:Theme.Material.NoActionBar" />
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
What the FileProvider may hand to another app: ONLY the copies made to open an
attachment (AttachmentFiles.open), never the store or the blob directory itself.
A viewer gets read access to the one file it was asked to show.
-->
<paths>
<cache-path name="open" path="open/" />
</paths>
@@ -0,0 +1,46 @@
package com.fabledsword.inkwell.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AttachmentRulesTest {
@Test
fun `raster images draw inline and SVG never does`() {
assertTrue(rendersInline("image/png"))
assertTrue(rendersInline("image/jpeg"))
assertTrue(rendersInline("IMAGE/WEBP; charset=binary"))
assertFalse(rendersInline("image/svg+xml"))
assertFalse(rendersInline("Image/SVG+XML"))
assertFalse(rendersInline("application/pdf"))
assertFalse(rendersInline(""))
}
@Test
fun `a decode is scaled down but never below the size it is drawn at`() {
assertEquals(1, sampleSize(width = 600, height = 400, maxPx = 640))
assertEquals(1, sampleSize(width = 1279, height = 900, maxPx = 640))
assertEquals(2, sampleSize(width = 1280, height = 900, maxPx = 640))
// A 12-megapixel portrait photo for a card: the longer side decides.
assertEquals(4, sampleSize(width = 3024, height = 4032, maxPx = 640))
assertTrue(4032 / sampleSize(3024, 4032, 640) >= 640)
}
@Test
fun `a cache copy's name cannot leave its directory and is never empty`() {
assertEquals("receipt.pdf", safeName("receipt.pdf"))
assertEquals("passwd", safeName("../../etc/passwd"))
assertEquals("evil.txt", safeName("C:\\temp\\evil.txt"))
assertEquals("file", safeName(".."))
assertEquals("file", safeName(" "))
assertEquals("file", safeName(null))
}
@Test
fun `sizes print like the web prints them`() {
assertEquals("512 B", sizeLabel(512))
assertEquals("2 KB", sizeLabel(1536))
assertEquals("3.5 MB", sizeLabel(3_670_016))
}
}
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync.ui
package com.fabledsword.inkwell.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
@@ -7,12 +7,10 @@ import org.junit.Test
/**
* Pins the derived-colour rule against `frontend/src/notes/colors.ts`.
*
* These are not tests of Kotlin — they are the ONE mechanical guard the mirrored pair
* has. The web side is TypeScript with no test runner (its CI lane is `vue-tsc
* --noEmit` and nothing else), so if these values drift, nothing on that surface will
* say so and a tag will simply be a different colour on the phone than in the browser.
* The same names and hashes are written into colors.ts as a comment; changing either
* side means changing both and re-checking here.
* These are not tests of Kotlin — they pin the pair. The web runs the same values
* from `core/testdata/grammar.json` (`tint`) in `notes/grammar.test.ts`; this file
* writes them out by hand, so changing the hash or the key order means changing the
* fixture and this file together, or one of the two suites goes red.
*
* SMALLER SINCE M315. Half of what this file used to pin — the generated card fill, and
* the resolution order that chose between a picked colour, a tag's and a generated one
+3 -3
View File
@@ -1,13 +1,13 @@
[package]
name = "thoughtsync-uniffi-bindgen"
name = "inkwell-uniffi-bindgen"
version = "0.1.0"
description = "Generates the Kotlin bindings for thoughtsync-ffi"
description = "Generates the Kotlin bindings for inkwell-ffi"
authors = ["bvandeusen"]
edition = "2021"
# A crate whose ONLY dependency is uniffi itself.
#
# This started life as a `[[bin]]` inside thoughtsync-ffi, which failed: building
# This started life as a `[[bin]]` inside inkwell-ffi, which failed: building
# it compiled that crate and therefore the core, reqwest, native-tls and
# openssl-sys — for the HOST. The vendored-OpenSSL block in core/Cargo.toml is
# scoped to `cfg(target_os = "android")`, so a host build looks for a system
+2 -2
View File
@@ -3,8 +3,8 @@
//! Invoked by Gradle (see android/app/build.gradle.kts) as:
//!
//! ```text
//! cargo run --locked -p thoughtsync-uniffi-bindgen -- \
//! generate --library <path/to/libthoughtsync_ffi.so> \
//! cargo run --locked -p inkwell-uniffi-bindgen -- \
//! generate --library <path/to/libinkwell_ffi.so> \
//! --language kotlin --out-dir <build/generated/uniffi>
//! ```
//!
+1 -1
View File
@@ -74,7 +74,7 @@ exceptions:
# right and still applies.
excludes:
- "**/ui/**"
- "**/ThoughtSyncApplication.kt"
- "**/InkwellApplication.kt"
- "**/SyncWorker.kt"
- "**/ReminderReceiver.kt"
- "**/AppUpdate.kt"
+4 -4
View File
@@ -1,7 +1,7 @@
[package]
name = "thoughtsync-ffi"
name = "inkwell-ffi"
version = "0.1.0"
description = "uniffi bindings exposing thoughtsync-core to the native Android client"
description = "uniffi bindings exposing inkwell-core to the native Android client"
authors = ["bvandeusen"]
edition = "2021"
@@ -10,10 +10,10 @@ edition = "2021"
# bindgen binary below — and this crate's own tests — can use the crate normally;
# a cdylib-only crate is unusable from Rust.
crate-type = ["cdylib", "lib"]
name = "thoughtsync_ffi"
name = "inkwell_ffi"
[dependencies]
thoughtsync-core = { path = "../../core" }
inkwell-core = { path = "../../core" }
serde_json = { workspace = true }
log = { workspace = true }
+322 -25
View File
@@ -1,4 +1,4 @@
//! uniffi bindings: `thoughtsync-core` as seen from Kotlin.
//! uniffi bindings: `inkwell-core` as seen from Kotlin.
//!
//! This crate is to Android what `desktop/src-tauri/src/commands/` is to the desktop
//! — a thin shim over the shared core, holding no logic of its own. If something here
@@ -7,7 +7,7 @@
//!
//! ## Shape
//!
//! One `ThoughtSync` object holds the store and the blob directory, mirroring how
//! One `Inkwell` object holds the store and the blob directory, mirroring how
//! Tauri manages them as app state. Kotlin constructs it once, keeps it for the
//! process lifetime, and calls methods on it.
//!
@@ -38,13 +38,13 @@ pub mod models;
use std::path::PathBuf;
use std::sync::Arc;
use thoughtsync_core::local::{self, Db};
use thoughtsync_core::sync::blobs::BlobStore;
use thoughtsync_core::sync::{client, compat, engine, push, state};
use inkwell_core::local::{self, Db};
use inkwell_core::sync::blobs::BlobStore;
use inkwell_core::sync::{client, compat, engine, push, sharing, state};
use models::{
patch_from, BodyItem, BodyTag, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit,
NoteQuery, ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus,
patch_from, BodyItem, BodyTag, ClientUpdate, Identity, Label, Member, Note, NoteDraft,
NoteEdit, NoteQuery, NoteShare, ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus,
};
uniffi::setup_scaffolding!();
@@ -108,30 +108,30 @@ impl CoreError {
/// behind its mutex, the blob store being a path — which is what lets uniffi share
/// one instance across coroutines.
#[derive(uniffi::Object)]
pub struct ThoughtSync {
pub struct Inkwell {
db: Db,
blobs: BlobStore,
}
#[uniffi::export]
impl ThoughtSync {
impl Inkwell {
/// Open (creating on first run) the store under `data_dir`, and the attachment
/// directory beside it.
///
/// `data_dir` comes from Kotlin because only Android knows where its app-private
/// storage is; the core must not guess at a platform path. The layout inside is
/// the core's business and matches the desktop's exactly — `thoughtsync.db` and
/// the core's business and matches the desktop's exactly — `inkwell.db` and
/// `blobs/` — so a store is readable by any client that opens it.
#[uniffi::constructor]
pub fn new(data_dir: String) -> Result<Arc<Self>, CoreError> {
let dir = PathBuf::from(data_dir);
std::fs::create_dir_all(&dir).map_err(CoreError::store)?;
let db = local::open(&dir.join("thoughtsync.db")).map_err(CoreError::store)?;
let db = local::open(&dir.join("inkwell.db")).map_err(CoreError::store)?;
log::info!("local store ready — {}", local::summary(&db));
let blobs = BlobStore::new(dir.join("blobs")).map_err(CoreError::store)?;
Ok(Arc::new(ThoughtSync { db, blobs }))
Ok(Arc::new(Inkwell { db, blobs }))
}
/// A one-line count summary, for the boot log.
@@ -333,6 +333,115 @@ impl ThoughtSync {
.map_err(CoreError::store)
}
/// Rename a label. Every note carrying it follows, because notes reference it
/// by id and never by name.
///
/// Renaming onto a name another tag already holds MERGES the two, and the OLDER
/// row is the survivor — it keeps its id and colour and takes the new spelling.
/// Matching is case-insensitive, like `find_or_create_label`.
///
/// So this call can return a label whose id is NOT the one passed in, and it can
/// make another label stop existing. A UI over it should say so before calling:
/// the merge cannot be undone by repeating it, and here it is reachable by a
/// typo in a text field. The web asks first (`stores/labels.ts`); this binding
/// deliberately does not, because a confirmation belongs to the surface that has
/// a person in front of it, not to the store.
///
/// `store::rename_label` and the server's PATCH implement the same rule, so the
/// phone, the desktop and the web agree on which row survives.
pub fn rename_label(&self, id: String, name: String) -> Result<Label, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::rename_label(&conn, &id, &name)
.map(Label::from)
.map_err(CoreError::store)
}
/// Recolour a label.
///
/// `color` is a palette KEY from the shared vocabulary (`NoteTint.kt` on this
/// side), not a hex value — the point of the shared palette is that a colour
/// picked on the phone resolves to the same swatch on the web and the desktop,
/// which a literal colour could not promise across themes.
pub fn set_label_color(&self, id: String, color: String) -> Result<Label, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::set_label_color(&conn, &id, &color)
.map(Label::from)
.map_err(CoreError::store)
}
/// Delete a label. The notes that carried it are NOT deleted — they simply stop
/// carrying it, which is the thing a confirmation dialog has to say out loud.
///
/// A `#tag` in a body will re-derive the label on the next edit of that note.
/// That is correct rather than a leak: the text mandates it, and deleting the
/// row cannot un-write the word.
pub fn remove_label(&self, id: String) -> Result<(), CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::remove_label(&conn, &id).map_err(CoreError::store)
}
/// Fold `source` into `target` and return the survivor.
///
/// DIRECTIONAL and NOT reversible by repeating it: source stops existing. Any
/// UI over this has to name the survivor before it runs, because afterwards
/// there is nothing left to read the direction from.
pub fn merge_labels(&self, source_id: String, target_id: String) -> Result<Label, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::merge_labels(&conn, &source_id, &target_id)
.map(Label::from)
.map_err(CoreError::store)
}
// ────────────────────────── attachments and previews ──────────────────────────
/// Attach a file. The bytes go into the blob store now and up to the server on
/// the next sync that can reach one, so attaching works with no network.
///
/// The bytes cross the FFI as one buffer. Kotlin caps what it reads before
/// calling, because this copies the whole file into Rust's memory once.
pub fn add_attachment(
&self,
note_id: String,
filename: String,
mime: String,
bytes: Vec<u8>,
) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::add_attachment(&conn, &self.blobs, &note_id, &filename, &mime, &bytes)
.map(Note::from)
.map_err(CoreError::store)
}
pub fn delete_attachment(
&self,
note_id: String,
attachment_id: String,
) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::delete_attachment(&conn, &note_id, &attachment_id)
.map(Note::from)
.map_err(CoreError::store)
}
pub fn delete_preview(&self, note_id: String, preview_id: String) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::delete_preview(&conn, &note_id, &preview_id)
.map(Note::from)
.map_err(CoreError::store)
}
/// Where this device holds an attachment's bytes, or None when it doesn't yet
/// (still downloading, or the download failed).
///
/// A path rather than the bytes, so Kotlin can decode an image at the size it
/// will be drawn instead of pulling the full file across the FFI for a thumbnail.
pub fn blob_path(&self, sha256: String) -> Option<String> {
self.blobs
.path(&sha256)
.filter(|p| p.is_file())
.map(|p| p.to_string_lossy().into_owned())
}
// ─────────────────────────────── sync ────────────────────────────────
pub fn sync_status(&self) -> Result<SyncStatus, CoreError> {
@@ -354,7 +463,7 @@ impl ThoughtSync {
/// functions. Split into its own impl block so the runtime attribute — and the fact
/// that everything in here touches the network — is visible at a glance.
#[uniffi::export(async_runtime = "tokio")]
impl ThoughtSync {
impl Inkwell {
/// Ask a server who it is, without committing to anything. Called as the user
/// finishes typing an address, so they see what answered before handing over
/// credentials.
@@ -466,7 +575,7 @@ impl ThoughtSync {
///
/// Takes the destination rather than choosing one: only Android knows a
/// directory its own package installer can read from, and the core has no
/// business guessing at platform paths — the same reason `ThoughtSync::new`
/// business guessing at platform paths — the same reason `Inkwell::new`
/// takes a data dir.
pub async fn download_client_update(&self, dest_path: String) -> Result<(), CoreError> {
let (base_url, token) = self.credentials()?;
@@ -497,6 +606,55 @@ impl ThoughtSync {
.map(SyncOutcome::from)
.map_err(CoreError::network)
}
// ────────────────────────────── sharing ──────────────────────────────
//
// The Share dialog asks the server directly (#5175). Unlinked, each answers
// `NotLinked`, which the dialog turns into "sharing needs a server".
/// Everyone on the instance a note can be shared with.
pub async fn share_directory(&self) -> Result<Vec<Member>, CoreError> {
self.credentials()?;
let members = sharing::directory(&self.db)
.await
.map_err(CoreError::network)?;
Ok(members.into_iter().map(Member::from).collect())
}
/// Who this note is shared with.
pub async fn note_shares(&self, note_id: String) -> Result<Vec<NoteShare>, CoreError> {
self.credentials()?;
let shares = sharing::list(&self.db, &note_id)
.await
.map_err(CoreError::network)?;
Ok(shares.into_iter().map(NoteShare::from).collect())
}
/// Share with one member at "view" or "edit", or change their permission.
pub async fn share_note(
&self,
note_id: String,
user_id: String,
permission: String,
) -> Result<Vec<NoteShare>, CoreError> {
self.credentials()?;
let shares = sharing::share(&self.db, &note_id, &user_id, &permission)
.await
.map_err(CoreError::network)?;
Ok(shares.into_iter().map(NoteShare::from).collect())
}
pub async fn unshare_note(
&self,
note_id: String,
share_id: String,
) -> Result<Vec<NoteShare>, CoreError> {
self.credentials()?;
let shares = sharing::unshare(&self.db, &note_id, &share_id)
.await
.map_err(CoreError::network)?;
Ok(shares.into_iter().map(NoteShare::from).collect())
}
}
// ── checklist text, as pure functions ───────────────────────────────────────
@@ -520,6 +678,20 @@ pub fn checklist_render(text: String, checked: bool) -> String {
local::derive::render_item(&text, checked)
}
/// Tell the core which app it is running inside, and which build of it.
///
/// Android has to say so because the core cannot: the same crate is compiled into
/// the desktop app, and it used to announce every phone in the field as
/// `inkwell-desktop` carrying the CORE crate's version — a number no build
/// stamps and nobody has seen. The honest value is the installed package's own
/// `versionName`, which is what Kotlin passes here.
///
/// Called once from `InkwellApplication.onCreate`, before anything can sync.
#[uniffi::export]
pub fn set_client_agent(name: String, version: String) {
compat::set_client_agent(&name, &version);
}
/// Every checklist item in a body, with the line each one sits on — so a renderer
/// walking the body line by line knows which lines are boxes and what is in them.
#[uniffi::export]
@@ -547,7 +719,7 @@ pub fn body_tags(body: String) -> Vec<BodyTag> {
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
/// block names, so these stay Rust-side.
impl ThoughtSync {
impl Inkwell {
/// Apply a `{text}` or `{checked}` patch to one checklist item.
///
/// The two public setters differ only in the key they write, and the lock +
@@ -607,7 +779,7 @@ mod tests {
use std::sync::atomic::{AtomicU32, Ordering};
static NEXT: AtomicU32 = AtomicU32::new(0);
let dir = std::env::temp_dir().join(format!(
"thoughtsync-ffi-{}-{}",
"inkwell-ffi-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
@@ -628,7 +800,7 @@ mod tests {
#[test]
fn creates_a_store_and_round_trips_a_note() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let created = app
.create_note(draft("Groceries\nmilk"))
@@ -650,7 +822,7 @@ mod tests {
#[test]
fn a_note_is_named_by_its_first_line() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let created = app
.create_note(draft("just a thought"))
@@ -665,7 +837,7 @@ mod tests {
#[test]
fn a_note_with_only_items_is_named_by_its_first_item() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let created = app
.create_note(NoteDraft {
@@ -684,7 +856,7 @@ mod tests {
#[test]
fn syncing_unlinked_reports_not_linked() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let status = app.sync_status().expect("status should read");
assert!(!status.linked);
@@ -701,7 +873,7 @@ mod tests {
#[test]
fn checklist_items_can_be_added_ticked_retitled_and_removed() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let note = app
.create_note(NoteDraft {
body: "Packing".to_string(),
@@ -755,7 +927,7 @@ mod tests {
#[test]
fn setting_labels_leaves_tag_derived_ones_alone() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let note = app
.create_note(draft("Trip\nbook the ferry #travel"))
@@ -796,7 +968,7 @@ mod tests {
#[test]
fn deleting_forever_removes_the_note() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let note = app.create_note(draft("Ephemeral\nbody")).expect("create");
app.delete_note_forever(note.id.clone())
@@ -809,11 +981,61 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
/// A file attached here lands in the blob store, is findable by its hash, and
/// leaves both the note and the board's view of it when removed.
#[test]
fn an_attached_file_is_stored_found_and_removable() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let note = app.create_note(draft("Receipt")).expect("create");
let attached = app
.add_attachment(
note.id.clone(),
"receipt.png".into(),
"image/png".into(),
b"not really a png".to_vec(),
)
.expect("attach");
assert_eq!(attached.attachments.len(), 1);
let att = &attached.attachments[0];
assert_eq!(att.filename.as_deref(), Some("receipt.png"));
assert_eq!(att.mime, "image/png");
assert_eq!(att.upload_error, None);
let sha = att.sha256.clone().expect("a stored file carries its hash");
let path = app.blob_path(sha.clone()).expect("the bytes are on disk");
assert_eq!(std::fs::read(path).unwrap(), b"not really a png");
assert_eq!(
app.blob_path("0".repeat(64)),
None,
"an unknown hash has no path"
);
let after = app
.delete_attachment(note.id.clone(), att.id.clone())
.expect("remove");
assert!(after.attachments.is_empty());
assert!(
app.add_attachment(
"missing".into(),
"a.txt".into(),
"text/plain".into(),
vec![1]
)
.is_err(),
"attaching to a note that doesn't exist is refused"
);
std::fs::remove_dir_all(&dir).ok();
}
/// Snooze writes a future instant from the CORE's clock; complete clears it.
#[test]
fn reminders_can_be_snoozed_and_completed() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let note = app.create_note(draft("Call back")).expect("create");
assert_eq!(note.remind_at, None);
@@ -839,7 +1061,7 @@ mod tests {
#[test]
fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let note = app.create_note(draft("Water the plants")).expect("create");
let armed = app
@@ -892,6 +1114,81 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
/// Renaming a tag onto a name another tag already holds MERGES the two, and the
/// OLDER row is the survivor.
///
/// Before this, the bare UPDATE met `idx_labels_name` — unique on `lower(name)` —
/// and the user got a raw "UNIQUE constraint failed" from SQLite. Merging is what
/// a person means by typing an existing tag's name onto this one.
#[test]
fn renaming_onto_an_existing_tag_merges_into_the_older_one() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let older = app.create_label("grocery".to_string()).expect("older");
// `created_at` is RFC3339 to the MILLISECOND. Without a gap the two rows can
// share a timestamp, and then the tie-break is under test instead of the age
// rule this test is about.
std::thread::sleep(std::time::Duration::from_millis(5));
let newer = app.create_label("errands".to_string()).expect("newer");
let one = app.create_note(draft("milk")).expect("note one");
let two = app.create_note(draft("stamps")).expect("note two");
app.set_note_labels(one.id.clone(), vec![older.id.clone()])
.expect("tag one");
app.set_note_labels(two.id.clone(), vec![newer.id.clone()])
.expect("tag two");
// The YOUNGER one is renamed onto the older's name, in a different case —
// matching is case-insensitive, and the survivor takes the spelling asked for.
let survivor = app
.rename_label(newer.id.clone(), "Grocery".to_string())
.expect("a rename onto an existing name merges instead of failing");
assert_eq!(
survivor.id, older.id,
"the older row is the one that survives"
);
assert_eq!(survivor.name, "Grocery", "spelled the way the caller asked");
let all = app.list_labels().expect("list");
assert_eq!(all.len(), 1, "the two became one");
assert_eq!(all[0].id, older.id);
assert_eq!(all[0].count, Some(2), "carrying every note from both sides");
std::fs::remove_dir_all(&dir).ok();
}
/// The mirror of the test above. Renaming the OLDER one onto the younger's name
/// still leaves the older row standing — it just changes its name.
///
/// This is the whole reason age decides rather than "whoever already held the
/// name": otherwise the survivor depends on which way round someone typed it,
/// and two devices tidying the same pair would disagree about which id exists.
#[test]
fn the_rename_merge_survivor_does_not_depend_on_the_direction() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let older = app.create_label("grocery".to_string()).expect("older");
std::thread::sleep(std::time::Duration::from_millis(5));
let newer = app.create_label("errands".to_string()).expect("newer");
let survivor = app
.rename_label(older.id.clone(), "errands".to_string())
.expect("rename");
assert_eq!(survivor.id, older.id, "age wins in this direction too");
assert_eq!(survivor.name, "errands");
assert_ne!(
survivor.id, newer.id,
"the younger row is the one that went"
);
assert_eq!(app.list_labels().expect("list").len(), 1);
std::fs::remove_dir_all(&dir).ok();
}
/// A crude RFC3339 sanity check that doesn't pull a date crate into this
/// crate's dev-dependencies to assert one field is well-formed.
fn chrono_free_parse(raw: &str) -> usize {
+104 -17
View File
@@ -1,6 +1,6 @@
//! The types that cross into Kotlin.
//!
//! These MIRROR `thoughtsync_core::local::models` rather than reusing it. The core's
//! These MIRROR `inkwell_core::local::models` rather than reusing it. The core's
//! shapes are serde structs whose field names and optionality are contracted with the
//! shared Vue frontend; hanging uniffi derives on them would couple two very
//! different consumers to one definition and put a `serde_json::Value` (which has no
@@ -13,13 +13,13 @@
//! what to do with it. That is the entire reason for the `let Core { .. } = value`
//! style here; please keep it.
use thoughtsync_core::local::models as core_models;
use thoughtsync_core::sync::client as core_client;
use thoughtsync_core::sync::compat as core_compat;
use thoughtsync_core::sync::engine as core_engine;
use thoughtsync_core::sync::pull as core_pull;
use thoughtsync_core::sync::push as core_push;
use thoughtsync_core::sync::state as core_state;
use inkwell_core::local::models as core_models;
use inkwell_core::sync::client as core_client;
use inkwell_core::sync::compat as core_compat;
use inkwell_core::sync::engine as core_engine;
use inkwell_core::sync::pull as core_pull;
use inkwell_core::sync::push as core_push;
use inkwell_core::sync::state as core_state;
/// A note, with everything needed to render a card or open the editor.
///
@@ -46,6 +46,20 @@ pub struct Note {
pub previews: Vec<LinkPreview>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
/// How this account holds the note (#5175): "owner", or "edit"/"view" for one
/// someone shared with it.
pub permission: String,
/// Whether the owner has shared it with anyone.
pub shared: bool,
/// Who shared it with us; absent on our own notes.
pub shared_by: Option<SharedBy>,
}
/// The owner of a note shared with this account.
#[derive(Debug, Clone, uniffi::Record)]
pub struct SharedBy {
pub id: String,
pub display_name: String,
}
/// A checklist item as it sits in a note's body.
@@ -60,9 +74,9 @@ pub struct BodyItem {
pub checked: bool,
}
impl From<thoughtsync_core::local::derive::DerivedItem> for BodyItem {
fn from(i: thoughtsync_core::local::derive::DerivedItem) -> Self {
let thoughtsync_core::local::derive::DerivedItem {
impl From<inkwell_core::local::derive::DerivedItem> for BodyItem {
fn from(i: inkwell_core::local::derive::DerivedItem) -> Self {
let inkwell_core::local::derive::DerivedItem {
text,
checked,
line,
@@ -88,9 +102,9 @@ pub struct BodyTag {
pub name: String,
}
impl From<thoughtsync_core::local::derive::DerivedTag> for BodyTag {
fn from(t: thoughtsync_core::local::derive::DerivedTag) -> Self {
let thoughtsync_core::local::derive::DerivedTag {
impl From<inkwell_core::local::derive::DerivedTag> for BodyTag {
fn from(t: inkwell_core::local::derive::DerivedTag) -> Self {
let inkwell_core::local::derive::DerivedTag {
line,
start,
end,
@@ -121,12 +135,12 @@ pub struct ClientUpdate {
pub size: i64,
}
impl From<thoughtsync_core::sync::client::ClientRelease> for ClientUpdate {
fn from(r: thoughtsync_core::sync::client::ClientRelease) -> Self {
impl From<inkwell_core::sync::client::ClientRelease> for ClientUpdate {
fn from(r: inkwell_core::sync::client::ClientRelease) -> Self {
// Destructured exhaustively, like every other conversion in this file: a
// field added upstream stops this compiling until Android is told what to
// do with it, which turns silent drift into a build error.
let thoughtsync_core::sync::client::ClientRelease {
let inkwell_core::sync::client::ClientRelease {
version,
version_code,
size,
@@ -167,6 +181,8 @@ pub struct Attachment {
pub mime: String,
pub size: Option<i64>,
pub sha256: Option<String>,
/// Why the server refused a file attached on this device. None otherwise.
pub upload_error: Option<String>,
}
#[derive(Debug, Clone, uniffi::Record)]
@@ -199,6 +215,9 @@ impl From<core_models::Note> for Note {
previews,
created_at,
updated_at,
permission,
shared,
shared_by,
} = value;
Note {
id,
@@ -217,6 +236,12 @@ impl From<core_models::Note> for Note {
previews: previews.into_iter().map(LinkPreview::from).collect(),
created_at,
updated_at,
permission,
shared,
shared_by: shared_by.map(|by| SharedBy {
id: by.id,
display_name: by.display_name,
}),
}
}
}
@@ -264,6 +289,7 @@ impl From<core_models::Attachment> for Attachment {
mime,
size,
sha256,
upload_error,
} = value;
Attachment {
id,
@@ -272,6 +298,7 @@ impl From<core_models::Attachment> for Attachment {
mime,
size,
sha256,
upload_error,
}
}
}
@@ -346,6 +373,8 @@ pub struct NoteFacets {
pub has_attachment: Option<bool>,
pub created_after: Option<String>,
pub created_before: Option<String>,
/// "with_me": only notes someone else shared with this account.
pub shared: Option<String>,
}
impl From<NoteQuery> for core_models::ListQuery {
@@ -374,6 +403,7 @@ impl From<NoteFacets> for core_models::Facets {
has_attachment,
created_after,
created_before,
shared,
} = value;
core_models::Facets {
q,
@@ -382,6 +412,7 @@ impl From<NoteFacets> for core_models::Facets {
has_attachment,
created_after,
created_before,
shared,
}
}
}
@@ -582,6 +613,54 @@ impl From<core_client::Identity> for Identity {
}
}
/// Someone on the instance a note can be shared with (#5175).
#[derive(Debug, Clone, uniffi::Record)]
pub struct Member {
pub id: String,
pub display_name: String,
pub email: String,
}
impl From<core_client::Member> for Member {
fn from(value: core_client::Member) -> Self {
let core_client::Member {
id,
display_name,
email,
} = value;
Member {
id,
display_name,
email,
}
}
}
/// One person a note is shared with, at "view" or "edit".
#[derive(Debug, Clone, uniffi::Record)]
pub struct NoteShare {
pub id: String,
pub member: Member,
pub permission: String,
}
impl From<core_client::NoteShare> for NoteShare {
fn from(value: core_client::NoteShare) -> Self {
// `created_at` stays behind: nothing on the phone orders or shows by it.
let core_client::NoteShare {
id,
member,
permission,
created_at: _,
} = value;
NoteShare {
id,
member: member.into(),
permission,
}
}
}
/// What became of this device's token on the server during an unlink.
///
/// Separate from the local result because the local half always succeeds and the
@@ -637,6 +716,10 @@ pub struct PushSummary {
/// realistic case). Silently retrying forever would be the wrong shape.
pub rejected: u64,
pub errors: Vec<String>,
/// Files attached on this device that reached the server this cycle.
pub uploaded: u64,
/// Files that didn't; their reasons are in `errors`.
pub upload_failed: u64,
}
#[derive(Debug, Clone, uniffi::Record)]
@@ -668,6 +751,8 @@ impl From<core_push::PushSummary> for PushSummary {
noop,
rejected,
errors,
uploaded,
upload_failed,
} = value;
PushSummary {
batches: batches as u64,
@@ -678,6 +763,8 @@ impl From<core_push::PushSummary> for PushSummary {
noop: noop as u64,
rejected: rejected as u64,
errors,
uploaded: uploaded as u64,
upload_failed: upload_failed as u64,
}
}
}
+3 -3
View File
@@ -1,5 +1,5 @@
# Where the generated Kotlin lands. Matches the app's package so the bindings are
# `com.fabledsword.thoughtsync.core.*` rather than something the app has to alias.
# `com.fabledsword.inkwell.core.*` rather than something the app has to alias.
[bindings.kotlin]
package_name = "com.fabledsword.thoughtsync.core"
cdylib_name = "thoughtsync_ffi"
package_name = "com.fabledsword.inkwell.core"
cdylib_name = "inkwell_ffi"
+1 -1
View File
@@ -18,7 +18,7 @@ dependencyResolutionManagement {
mavenCentral()
}
}
rootProject.name = "ThoughtSync"
rootProject.name = "Inkwell"
// `ffi/` sits beside `app/` but is deliberately NOT a Gradle module: it is a Rust
// crate belonging to the Cargo workspace at the repo root. Gradle reaches it by
+35 -29
View File
@@ -1,4 +1,4 @@
# CI Requirements — ThoughtSync
# CI Requirements — Inkwell
> Spec lives in [`docs/process.md`](https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md)
> in the CI-Runner repo.
@@ -39,28 +39,26 @@ entirely on `ci-python:3.14`.
install` cold cost is a non-blocker.
- Build gates on `typecheck` + `lint` only. The `test` job runs in parallel for
visibility but does not block the dev image push. DB-backed / integration tests
run against the dev image manually — ThoughtSync's unit tests are DB-free (no
run against the dev image manually — Inkwell's unit tests are DB-free (no
Postgres service lane in CI yet).
- `dev` push -> `:dev` + `:<sha>`; `v*` tag -> `:latest` + `:<version>` + `:<sha>`
(family rule 46).
- The production runtime `Dockerfile` tracks python:3.12 so test results stay
representative of the deployed image.
- **Artifacts — use the mirrored upload action, never `actions/upload-artifact`.**
- **Artifacts — stock `actions/upload-artifact@v7`, never `@v3`.**
```yaml
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
uses: actions/upload-artifact@v7
```
Upstream's `actions/upload-artifact@v4` cannot work against this instance and
no server-side change will help: its `isGhes()` rejects any hostname that isn't
`github.com` / `*.ghe.com` / `*.localhost` and throws before it opens a
connection, so the server is never asked what it supports. `@v3` is worse — it
reports success, and Gitea then serves artifacts back only through the v4 API
(`content_encoding = application/zip`), so a v3 upload is stored but invisible
to every retrieval path. A green job producing nothing retrievable.
Stock works on this forge since the runner moved to gitea/runner 3.x, which
edits the action's client-side `isGhes()` refusal out of its bundle. Proven on
2026-09-10 for upload-artifact v4–v7 and download-artifact v4–v8 (Scribe spike
#3843). Until then this repo pinned a SHA mirror of the Forgejo project's
fork, because upstream threw on the hostname before it opened a connection.
`bvandeusen/upload-artifact` is our pull mirror of `forgejo/upload-artifact`
(the Forgejo project's fork, one commit on upstream v5.0.0 disabling that
check). Mirrored so CI depends on a commit we hold; pinned by SHA because the
mirror auto-syncs and a moved upstream tag would otherwise change what runs.
`@v3` is still broken: it reports success, and Gitea serves artifacts back only
through the v4 API (`content_encoding = application/zip`), so a v3 upload is
stored but invisible to every retrieval path. A green job producing nothing
retrievable.
Both desktop upload steps also set `if-no-files-found: error` and carry **no**
`continue-on-error`. They previously had both defaults inverted, which is how
@@ -89,7 +87,7 @@ parts. Three of them are family rules for a reason:
`docker ps` by it. A spaced or underscored name breaks the filter.
- **Service hostnames are not routable** on this runner (rule 79), so the step resolves
the Postgres container's bridge IP with `docker ps --filter` + `docker inspect` and
builds `THOUGHTSYNC_DATABASE_URL` from it. `postgres:5432` will not connect.
builds `INKWELL_DATABASE_URL` from it. `postgres:5432` will not connect.
- **`run:` is busybox sh** (rule 81) — no `/dev/tcp` — so the readiness wait is a small
Python heredoc. Its terminator must dedent to column 0 after YAML strips the block
indent; check with `yaml.safe_load` and print the `run` string if you edit it.
@@ -207,7 +205,7 @@ backend/frontend push.
## Android lane — being rebuilt (M12)
The Tauri-mobile Android lane is gone. Android is a native Kotlin/Compose client
over the shared `thoughtsync-core` crate instead — see Scribe note 2730 for the
over the shared `inkwell-core` crate instead — see Scribe note 2730 for the
decision and milestone M12 for the arc.
The image it will run on already exists: **`ci-rust-android:1.97`**, repurposed
@@ -220,7 +218,7 @@ second image, and JDK 25 (which requires **Gradle 9.1+** in this repo's wrapper
the old JDK 17 pin existed only because Tauri generated a Gradle 8.x project).
The Rust pin is in LOCKSTEP with `ci-tauri` and `ci-tauri-win`. All three build
`thoughtsync-core` from one workspace `Cargo.lock` under `--locked`, so a
`inkwell-core` from one workspace `Cargo.lock` under `--locked`, so a
mismatched Rust minor across the lanes would mean divergent resolution for no
reason. Bump the three together or not at all.
@@ -335,8 +333,9 @@ differs from CI is worse than none.
**This reproduces CI exactly, not approximately.** On the 2026-08-18 run the
local test binary hashes (`thoughtsync_core-bbaae79723888ad1`,
`thoughtsync_desktop_lib-9d162263f8d0aca3`, `thoughtsync_ffi-fc557b96dc795e27`)
matched CI run 3931's byte for byte. Same image, same lockfile, same units.
`thoughtsync_desktop_lib-9d162263f8d0aca3`, `thoughtsync_ffi-fc557b96dc795e27`,
named for the crates as they were before the rename to Inkwell) matched CI run
3931's byte for byte. Same image, same lockfile, same units.
`target/` persists on the host between runs, so after the first cold build these
take seconds (~30s for clippy). It is gitignored and reaches ~1.4 GB; delete it
@@ -411,18 +410,25 @@ the lockfile format and the picked versions identical to what CI would have
chosen. Commit the result in the same change as the `Cargo.toml` edit — a
manifest change pushed without it fails the gate.
## Pushing: `dev` is both a branch and a tag
## Channel releases: `dev-rolling` and `stable`
`git push origin dev` fails in this repo:
The two update channels are releases on fixed tags, because Fabled-Git has no
`/releases/latest/download/<asset>` route and the updater needs a permanent URL.
The **channel** is still called `dev` everywhere a person sees it; its **release
tag** is `dev-rolling`. `packaging/channel-tag.sh` is the one mapping CI reads.
`desktop/src-tauri/src/update.rs` and `desktop/packaging/install.sh` carry their
own copy because neither can run it — change all three together.
```
error: src refspec dev matches more than one
```
The tag used to be `dev`, which shadowed the branch of the same name: once a clone
had fetched it, `git push origin dev` failed with
`error: src refspec dev matches more than one` (Scribe #2184). Never name a channel
tag after a branch; `tests/test_channel_tag.py` and the `update.rs` tests fail if
one is.
The rolling update channel is a release on a **fixed tag named `dev`** (the tag
never moves — Fabled-Git has no `/releases/latest/download/<asset>` route, so the
updater needs a permanent URL). Once that tag is fetched locally, the short name
`dev` resolves to both `refs/heads/dev` and `refs/tags/dev`. Fully qualify it:
**Transitional, from 2026-09-10:** the old `dev` release still exists so desktop
apps installed from it can update across — the manifest job writes `latest.json`
to it too (`BRIDGE_TAG=dev` in `desktop.yml`). Until that release and its tag are
deleted, fully qualify pushes:
```
git push origin refs/heads/dev:refs/heads/dev
+11 -2
View File
@@ -1,7 +1,7 @@
[package]
name = "thoughtsync-core"
name = "inkwell-core"
version = "0.1.0"
description = "ThoughtSync client core — local-first SQLite store and opt-in sync engine"
description = "Inkwell client core — local-first SQLite store and opt-in sync engine"
authors = ["bvandeusen"]
edition = "2021"
@@ -26,6 +26,15 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] }
# Verifying downloaded attachment bytes against the sha256 the server advertised.
sha2 = "0.10"
# Export and import with no server (local/portable.rs): the same zip the server
# writes and reads, so a backup crosses between surfaces. Deflate only — every
# other method (bzip2, zstd, lzma, AES) is off, since neither the server's exports
# nor Google Takeout use them and several pull in C code.
zip = { version = "4", default-features = false, features = ["deflate-flate2"] }
# zip's deflate goes through flate2, which needs a backend chosen. miniz_oxide
# (`rust_backend`) is pure Rust, so the Windows and Android cross-compiles stay
# free of C; both crates were already in the lockfile, via the updater and png.
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
# Android has no system OpenSSL to link against, and `native-tls` resolves to
# OpenSSL there — unlike Windows, where it lands on schannel and costs nothing.
+1 -1
View File
@@ -1,4 +1,4 @@
//! ThoughtSync's client core: the on-device SQLite store and the sync engine.
//! Inkwell's client core: the on-device SQLite store and the sync engine.
//!
//! Deliberately free of any UI framework. The desktop wraps it in Tauri commands;
//! the Android client binds it through uniffi. Neither owns it, and a change to
+83 -3
View File
@@ -2,8 +2,10 @@
//! computes on save. Pure string scanning (no regex dependency), kept in lockstep
//! with the frontend's inline rules (see frontend notes/markdown.ts):
//!
//! - `#tag`: `#` at a word boundary followed by tag characters (letter first).
//! On save these become labels attached with `via_tag = true`.
//! - `#tag`: `#` at the start of a line or after whitespace, then a letter, then
//! letters, digits, `_` and `-`. On save these become labels attached with
//! `via_tag = true`. The server and the web run the same cases
//! (core/testdata/grammar.json).
//! - `- [ ] item`: a checklist item. The body IS the checklist (M304) — there is no
//! table of items beside it, so a list can sit between two paragraphs instead of
//! only after them.
@@ -29,7 +31,11 @@ fn line_tags(chars: &[char]) -> Vec<(usize, usize, String)> {
let mut i = 0;
while i < chars.len() {
if chars[i] == '#' {
let boundary = i == 0 || (!is_tag_char(chars[i - 1]) && chars[i - 1] != '#');
// Start of line or after whitespace, and nothing else. Any non-tag
// character used to count, which made `(#todo)` a tag here and plain
// text on the server, and made the `/#section` of a pasted URL a label.
// Whitespace is the rule all three implementations now share (#5166).
let boundary = i == 0 || chars[i - 1].is_whitespace();
// A tag must start with a letter (so "#1" or a bare "#" is not a tag).
if boundary && i + 1 < chars.len() && chars[i + 1].is_alphabetic() {
let mut j = i + 1;
@@ -770,4 +776,78 @@ mod tests {
assert_eq!(touched, body);
assert_eq!(extract_items(&touched), items);
}
// ── the shared fixture ───────────────────────────────────────────────────
//
// core/testdata/grammar.json is the one set of cases the server (pytest), the web
// (vitest) and this file all run. The cases above stay as this file's own
// reasoning; these are the ones the other languages have agreed to.
fn fixture() -> serde_json::Value {
serde_json::from_str(include_str!("../../testdata/grammar.json"))
.expect("grammar.json parses")
}
fn strings(v: &serde_json::Value) -> Vec<String> {
v.as_array()
.expect("an array")
.iter()
.map(|s| s.as_str().expect("a string").to_string())
.collect()
}
#[test]
fn fixture_task_lines() {
for case in fixture()["task_lines"].as_array().unwrap() {
let line = case["line"].as_str().unwrap();
let got: Vec<(String, bool)> = extract_items(line)
.into_iter()
.map(|i| (i.text, i.checked))
.collect();
let want: Vec<(String, bool)> = match &case["item"] {
serde_json::Value::Null => Vec::new(),
item => vec![(
item["text"].as_str().unwrap().to_string(),
item["checked"].as_bool().unwrap(),
)],
};
assert_eq!(got, want, "line {line:?}");
}
}
#[test]
fn fixture_rendered_items() {
for case in fixture()["rendered_items"].as_array().unwrap() {
let text = case["text"].as_str().unwrap();
let checked = case["checked"].as_bool().unwrap();
assert_eq!(render_item(text, checked), case["line"].as_str().unwrap());
}
}
#[test]
fn fixture_tags() {
for case in fixture()["tags"].as_array().unwrap() {
let body = case["body"].as_str().unwrap();
assert_eq!(extract_tags(body), strings(&case["tags"]), "body {body:?}");
}
}
#[test]
fn fixture_lifts() {
for case in fixture()["lifts"].as_array().unwrap() {
let body = case["body"].as_str().unwrap();
let (standalone, inline, lifted) = lift_standalone_tags(body);
assert_eq!(
standalone,
strings(&case["standalone"]),
"standalone, body {body:?}"
);
assert_eq!(inline, strings(&case["inline"]), "inline, body {body:?}");
assert_eq!(
lifted,
case["lifted"].as_str().unwrap(),
"lifted, body {body:?}"
);
}
}
}
+1
View File
@@ -6,6 +6,7 @@
pub mod derive;
pub mod models;
pub mod portable;
pub mod recur;
pub mod retention;
pub mod schema;
+34
View File
@@ -28,6 +28,20 @@ pub struct Note {
pub previews: Vec<LinkPreview>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
/// How this account holds the note (#5175): `owner`, or `edit`/`view` for one
/// someone shared with it. Named and valued as the server's, so the shared
/// frontend gates the editor identically either way.
pub permission: String,
/// Whether the owner has shared it with anyone.
pub shared: bool,
/// Who shared it with us; null on our own notes.
pub shared_by: Option<SharedBy>,
}
#[derive(Serialize)]
pub struct SharedBy {
pub id: String,
pub display_name: String,
}
#[derive(Serialize)]
@@ -55,6 +69,10 @@ pub struct Attachment {
pub mime: String,
pub size: Option<i64>,
pub sha256: Option<String>,
/// Why the server refused a file attached on this device, in words to show on it.
/// Absent for everything else, including a file still waiting to upload.
#[serde(skip_serializing_if = "Option::is_none")]
pub upload_error: Option<String>,
}
#[derive(Serialize)]
@@ -91,6 +109,19 @@ pub struct TitleEntry {
pub title: String,
}
/// A reminder that has come due, as the desktop's reminder worker needs it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DueReminder {
pub id: String,
pub title: String,
/// `remind_at` as stored. It names the occurrence: completing or snoozing a
/// reminder changes it, and that is how an announcer tells a new occurrence
/// from one it has already announced.
pub remind_at: String,
/// The same instant, in milliseconds since the epoch.
pub due_ms: i64,
}
#[derive(Serialize)]
pub struct SavedFilter {
pub id: String,
@@ -156,4 +187,7 @@ pub struct Facets {
pub created_after: Option<String>,
#[serde(default)]
pub created_before: Option<String>,
/// `with_me`: only notes someone else shared with this account.
#[serde(default)]
pub shared: Option<String>,
}
+894
View File
@@ -0,0 +1,894 @@
//! A whole store as a zip, out and back in, with no server.
//!
//! The same archive the server writes at `GET /api/notes/export` and reads at
//! `POST /api/notes/import` (`src/inkwell/notes/import_export.py`), so a backup taken
//! on one surface restores on any other. Both copies are held to
//! `core/testdata/portable.json`; change the format there first.
//!
//! - **Export:** `notes.json` (the machine format), a Markdown file per note for
//! reading, and each attachment this device holds.
//! - **Import:** an Inkwell export (either app marker) or a Google Keep Takeout zip.
//! Additive: notes are created, never matched against existing ones. Decompression
//! is capped per entry and in total, so a zip bomb fails instead of filling memory.
use std::io::{Cursor, Read, Write};
use chrono::{DateTime, SecondsFormat, Utc};
use rusqlite::{params, Connection};
use serde::Serialize;
use serde_json::{json, Map, Value};
use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipArchive, ZipWriter};
use super::models::{Note, NoteCreateInput};
use super::{derive, recur, store};
use crate::sync::blobs::BlobStore;
/// The `app` an export's notes.json carries. "thoughtsync" is what every export
/// written before the rename says, and those are people's backups.
const APP_MARKERS: [&str; 2] = ["inkwell", "thoughtsync"];
const MAX_ENTRIES: usize = 10_000;
const MAX_ENTRY_BYTES: u64 = 64 * 1024 * 1024;
const MAX_TOTAL_BYTES: u64 = 512 * 1024 * 1024;
/// What an import did, in the server's words.
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ImportSummary {
/// "inkwell" or "keep".
pub source: String,
pub imported: usize,
pub skipped: usize,
}
/// One note as either importer reads it, before anything is written. Mirrors the
/// server's "common import spec".
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ImportSpec {
pub title: Option<String>,
pub body: String,
pub pinned: bool,
pub archived: bool,
pub trashed: bool,
pub remind_at: Option<String>,
pub recurrence: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub labels: Vec<String>,
pub items: Vec<(String, bool)>,
/// (path inside the zip, mime if the source said)
pub attachments: Vec<(String, Option<String>)>,
}
// ---- export -----------------------------------------------------------------
/// The export's file name without `.zip`, dated like the server's:
/// `inkwell-export-YYYYMMDD`.
pub fn export_stem() -> String {
format!("inkwell-export-{}", Utc::now().format("%Y%m%d"))
}
/// Every live note (not trashed) as an export archive.
pub fn export_zip(conn: &Connection, blobs: &BlobStore) -> Result<Vec<u8>, String> {
let ids: Vec<String> = {
let mut stmt = conn
.prepare("SELECT id FROM notes WHERE trashed = 0 ORDER BY created_at")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |r| r.get(0))
.map_err(|e| e.to_string())?;
rows.collect::<rusqlite::Result<_>>()
.map_err(|e| e.to_string())?
};
let labels: Vec<Value> = store::list_labels(conn)
.map_err(|e| e.to_string())?
.into_iter()
.map(|l| json!({ "name": l.name, "color": l.color }))
.collect();
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
let opts = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
let mut notes = Vec::with_capacity(ids.len());
for id in &ids {
let note = store::get_note(conn, id).map_err(|e| e.to_string())?;
let short: String = note.id.chars().take(8).collect();
let mut files = Vec::new();
for (i, att) in note.attachments.iter().enumerate() {
// A file this device hasn't downloaded yet is left out of the list as well
// as the archive, so an importer never goes looking for it.
let Some(bytes) = att.sha256.as_deref().and_then(|sha| blobs.read(sha)) else {
continue;
};
// A folder per attachment, so two files with one name in one note don't
// collide, and the importer still gets the real name from the basename.
let name = store::safe_filename(att.filename.as_deref().unwrap_or(""));
let path = format!("attachments/{short}/{i}/{name}");
write_entry(&mut zip, &path, &bytes, opts)?;
files.push(json!({ "file": path, "mime": att.mime }));
}
let md = format!("notes/{}-{short}.md", slugify(&note.display_title));
write_entry(&mut zip, &md, note_markdown(&note).as_bytes(), opts)?;
notes.push(json!({
"id": note.id,
"display_title": note.display_title,
"body": note.body,
"pinned": note.pinned,
"archived": note.archived,
"remind_at": note.remind_at,
"recurrence": note.recurrence,
"created_at": note.created_at,
"updated_at": note.updated_at,
"labels": note.labels.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
"attachments": files,
}));
}
let doc = json!({
"app": "inkwell",
"version": 1,
"exported_at": Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
"labels": labels,
"notes": notes,
});
let text = serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())?;
write_entry(&mut zip, "notes.json", text.as_bytes(), opts)?;
zip.finish()
.map(Cursor::into_inner)
.map_err(|e| e.to_string())
}
fn write_entry(
zip: &mut ZipWriter<Cursor<Vec<u8>>>,
path: &str,
bytes: &[u8],
opts: SimpleFileOptions,
) -> Result<(), String> {
zip.start_file(path, opts).map_err(|e| e.to_string())?;
zip.write_all(bytes).map_err(|e| e.to_string())
}
/// One note as a readable Markdown file with a small frontmatter block. notes.json is
/// the machine format; this is for a person opening the archive. Mirrors
/// `_note_markdown` on the server.
pub fn note_markdown(note: &Note) -> String {
let mut out = vec![
"---".to_string(),
format!("display_name: {}", note.display_title),
];
if !note.labels.is_empty() {
let names: Vec<&str> = note.labels.iter().map(|l| l.name.as_str()).collect();
out.push(format!("labels: [{}]", names.join(", ")));
}
if note.pinned {
out.push("pinned: true".into());
}
if note.archived {
out.push("archived: true".into());
}
if let Some(at) = &note.remind_at {
out.push(format!("remind_at: {at}"));
}
out.push(format!(
"created: {}",
note.created_at.as_deref().unwrap_or("")
));
out.push(format!(
"updated: {}",
note.updated_at.as_deref().unwrap_or("")
));
out.push("---".into());
out.push(String::new());
if !note.body.is_empty() {
out.push(note.body.clone());
}
out.join("\n") + "\n"
}
/// A filesystem-safe slug from a note's name, for its .md file. Mirrors `_slugify`:
/// keep word characters, spaces and hyphens; collapse runs of space, underscore and
/// hyphen into one hyphen; at most 60 characters; "note" when nothing is left.
pub fn slugify(text: &str) -> String {
let kept: String = text
.trim()
.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-' || c.is_whitespace())
.collect();
let mut slug = String::new();
let mut gap = false;
for c in kept.chars() {
if c.is_whitespace() || c == '_' || c == '-' {
gap = true;
} else {
if gap && !slug.is_empty() {
slug.push('-');
}
gap = false;
slug.push(c);
}
}
let slug: String = slug.chars().take(60).collect();
let slug = slug.trim_end_matches('-').to_string();
if slug.is_empty() {
"note".into()
} else {
slug
}
}
// ---- import -----------------------------------------------------------------
/// Bytes pulled out of the archive, capped per entry and across the whole import.
/// Each read stops one byte past what is left, so an oversized or size-lying entry
/// is caught mid-read rather than after it has been inflated.
struct Budget {
remaining: u64,
}
impl Budget {
fn read(
&mut self,
zip: &mut ZipArchive<Cursor<&[u8]>>,
name: &str,
) -> Result<Option<Vec<u8>>, String> {
let cap = MAX_ENTRY_BYTES.min(self.remaining);
let entry = match zip.by_name(name) {
Ok(entry) => entry,
Err(zip::result::ZipError::FileNotFound) => return Ok(None),
Err(e) => return Err(e.to_string()),
};
let mut data = Vec::new();
entry
.take(cap + 1)
.read_to_end(&mut data)
.map_err(|e| e.to_string())?;
if data.len() as u64 > cap {
return Err(TOO_LARGE.into());
}
self.remaining -= data.len() as u64;
Ok(Some(data))
}
}
const TOO_LARGE: &str = "that archive is too large to import";
/// Import an Inkwell export or a Google Keep Takeout zip into this store.
///
/// All or nothing for the notes: any failure rolls the whole import back. Attachment
/// bytes already written to the blob store stay there, which is harmless — they are
/// keyed by content and nothing points at them.
pub fn import_zip(
conn: &Connection,
blobs: &BlobStore,
bytes: &[u8],
) -> Result<ImportSummary, String> {
let mut zip = ZipArchive::new(Cursor::new(bytes))
.map_err(|_| "that file isn't a valid .zip archive".to_string())?;
if zip.len() > MAX_ENTRIES {
return Err("that archive has too many files to import".into());
}
let mut budget = Budget {
remaining: MAX_TOTAL_BYTES,
};
let (specs, source) = read_specs(&mut zip, &mut budget)?;
if specs.is_empty() {
return Err(
"no importable notes found — expected an Inkwell export or a Google Keep Takeout zip"
.into(),
);
}
let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
let mut summary = ImportSummary {
source,
imported: 0,
skipped: 0,
};
for spec in &specs {
if create_imported(&tx, blobs, spec, &mut zip, &mut budget)? {
summary.imported += 1;
} else {
summary.skipped += 1;
}
}
tx.commit().map_err(|e| e.to_string())?;
Ok(summary)
}
fn read_specs(
zip: &mut ZipArchive<Cursor<&[u8]>>,
budget: &mut Budget,
) -> Result<(Vec<ImportSpec>, String), String> {
let names: Vec<String> = zip.file_names().map(str::to_string).collect();
for name in names.iter().filter(|n| basename(n) == "notes.json") {
let Some(raw) = budget.read(zip, name)? else {
continue;
};
let Ok(doc) = serde_json::from_slice::<Value>(&raw) else {
continue;
};
let marker = doc.get("app").and_then(Value::as_str).unwrap_or("");
if APP_MARKERS.contains(&marker) {
let specs = doc
.get("notes")
.and_then(Value::as_array)
.map(|notes| {
notes
.iter()
.filter_map(Value::as_object)
.map(native_spec)
.collect()
})
.unwrap_or_default();
return Ok((specs, "inkwell".into()));
}
}
const KEEP_KEYS: [&str; 6] = [
"textContent",
"listContent",
"isPinned",
"isArchived",
"isTrashed",
"userEditedTimestampUsec",
];
let mut specs = Vec::new();
for name in &names {
if !name.to_lowercase().ends_with(".json") || basename(name) == "notes.json" {
continue;
}
let Some(raw) = budget.read(zip, name)? else {
continue;
};
let Ok(Value::Object(note)) = serde_json::from_slice::<Value>(&raw) else {
continue;
};
if KEEP_KEYS.iter().any(|k| note.contains_key(*k)) {
specs.push(keep_spec(&note, dirname(name)));
}
}
let source = if specs.is_empty() { "" } else { "keep" };
Ok((specs, source.into()))
}
/// One note from an Inkwell export's notes.json. Mirrors `_native_spec`.
pub fn native_spec(n: &Map<String, Value>) -> ImportSpec {
ImportSpec {
title: str_of(n, "title"),
body: str_of(n, "body").unwrap_or_default(),
pinned: bool_of(n, "pinned"),
archived: bool_of(n, "archived"),
trashed: false, // an export carries only live notes
remind_at: str_of(n, "remind_at").and_then(|s| instant(&s)),
recurrence: recur::normalize(n.get("recurrence").and_then(Value::as_str))
.map(str::to_string),
created_at: str_of(n, "created_at").and_then(|s| instant(&s)),
updated_at: str_of(n, "updated_at").and_then(|s| instant(&s)),
labels: strings(n.get("labels")),
items: objects(n.get("items"))
.map(|it| {
(
str_of(it, "text").unwrap_or_default(),
bool_of(it, "checked"),
)
})
.collect(),
attachments: objects(n.get("attachments"))
.filter_map(|a| {
Some((
str_of(a, "file").filter(|f| !f.is_empty())?,
str_of(a, "mime"),
))
})
.collect(),
}
}
/// One Google Keep note (a Takeout `<note>.json`). `dir` is the folder the JSON sits
/// in, which its attachment paths are relative to. Mirrors `_keep_spec`.
pub fn keep_spec(k: &Map<String, Value>, dir: &str) -> ImportSpec {
// Keep stores links separately from the text; fold them in so they survive.
let mut body = str_of(k, "textContent").unwrap_or_default();
let urls: Vec<String> = objects(k.get("annotations"))
.filter_map(|a| str_of(a, "url"))
.filter(|u| !u.is_empty() && !body.contains(u.as_str()))
.collect();
if !urls.is_empty() {
let extra = urls.join("\n");
body = if body.trim().is_empty() {
extra
} else {
format!("{body}\n\n{extra}")
};
}
let attachments = objects(k.get("attachments"))
.filter_map(|a| {
let fp = str_of(a, "filePath").filter(|p| !p.is_empty())?;
let file = if dir.is_empty() {
fp.clone()
} else {
format!("{dir}/{fp}")
};
let mime = str_of(a, "mimetype").or_else(|| mime_from_name(&fp).map(str::to_string));
Some((file, mime))
})
.collect();
ImportSpec {
title: str_of(k, "title"),
body,
pinned: bool_of(k, "isPinned"),
archived: bool_of(k, "isArchived"),
trashed: bool_of(k, "isTrashed"),
remind_at: None, // Keep's reminders aren't in its Takeout JSON
recurrence: None,
created_at: k.get("createdTimestampUsec").and_then(usec_instant),
updated_at: k.get("userEditedTimestampUsec").and_then(usec_instant),
labels: objects(k.get("labels"))
.filter_map(|l| str_of(l, "name"))
.collect(),
items: objects(k.get("listContent"))
.map(|li| {
(
str_of(li, "text").unwrap_or_default(),
bool_of(li, "isChecked"),
)
})
.collect(),
attachments,
}
}
/// Write one imported note. False, with nothing written, when there is nothing in it.
fn create_imported(
conn: &Connection,
blobs: &BlobStore,
spec: &ImportSpec,
zip: &mut ZipArchive<Cursor<&[u8]>>,
budget: &mut Budget,
) -> Result<bool, String> {
// An imported title becomes the first body line — Inkwell has no title field, and
// dropping it would lose text someone wrote. Skipped when the body already opens
// with it, so re-importing an export doesn't stack duplicates.
let mut body = spec.body.clone();
let title = spec.title.as_deref().unwrap_or("").trim();
let first_line = body.trim_start().split('\n').next().unwrap_or("").trim();
if !title.is_empty() && first_line != title {
body = if body.trim().is_empty() {
title.to_string()
} else {
format!("{title}\n{body}")
};
}
let items: Vec<(&str, bool)> = spec
.items
.iter()
.map(|(t, c)| (t.trim(), *c))
.filter(|(t, _)| !t.is_empty())
.collect();
// A note that is only a photo is still a note — Keep has plenty of them.
if body.trim().is_empty() && items.is_empty() && spec.attachments.is_empty() {
return Ok(false);
}
for (text, checked) in &items {
body = derive::append_item(&body, text, *checked);
}
let err = |e: rusqlite::Error| e.to_string();
let note = store::create_note(conn, &NoteCreateInput { body, items: None }).map_err(err)?;
for name in spec
.labels
.iter()
.map(|n| n.trim())
.filter(|n| !n.is_empty())
{
let label = store::create_label(conn, name).map_err(err)?;
conn.execute(
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 0)",
params![note.id, label.id],
)
.map_err(err)?;
}
for (file, mime) in &spec.attachments {
let Some(raw) = budget.read(zip, file)? else {
continue;
};
store::add_attachment(
conn,
blobs,
&note.id,
basename(file),
mime.as_deref().unwrap_or(""),
&raw,
)?;
}
// Last, because adding an attachment touches the note: the source's own times are
// what this note should carry, not the moment it was imported.
let trashed_at = spec
.trashed
.then(|| Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true));
conn.execute(
"UPDATE notes SET pinned = ?1, archived = ?2, remind_at = ?3, recurrence = ?4,
trashed = ?5, trashed_at = ?6,
created_at = COALESCE(?7, created_at), updated_at = COALESCE(?8, updated_at)
WHERE id = ?9",
params![
spec.pinned,
spec.archived,
spec.remind_at,
spec.recurrence,
spec.trashed,
trashed_at,
spec.created_at,
spec.updated_at,
note.id
],
)
.map_err(err)?;
Ok(true)
}
// ---- small readers --------------------------------------------------------------
fn str_of(m: &Map<String, Value>, key: &str) -> Option<String> {
m.get(key).and_then(Value::as_str).map(str::to_string)
}
fn bool_of(m: &Map<String, Value>, key: &str) -> bool {
m.get(key).and_then(Value::as_bool).unwrap_or(false)
}
fn objects(v: Option<&Value>) -> impl Iterator<Item = &Map<String, Value>> {
v.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_object)
}
fn strings(v: Option<&Value>) -> Vec<String> {
v.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
}
/// Any RFC 3339 instant, in the store's own form (UTC, milliseconds, `Z`). Anything
/// else is treated as absent, as the server's `parse_dt` does.
fn instant(raw: &str) -> Option<String> {
DateTime::parse_from_rfc3339(raw).ok().map(|t| {
t.with_timezone(&Utc)
.to_rfc3339_opts(SecondsFormat::Millis, true)
})
}
/// Keep's timestamps: integer microseconds since the epoch.
fn usec_instant(v: &Value) -> Option<String> {
let usec = v.as_i64().or_else(|| v.as_str()?.parse().ok())?;
DateTime::from_timestamp_micros(usec).map(|t| t.to_rfc3339_opts(SecondsFormat::Millis, true))
}
/// The image types the server infers from a name when the source gave no mime.
fn mime_from_name(name: &str) -> Option<&'static str> {
let ext = name.rsplit_once('.')?.1.to_ascii_lowercase();
match ext.as_str() {
"png" => Some("image/png"),
"jpg" | "jpeg" => Some("image/jpeg"),
"gif" => Some("image/gif"),
"webp" => Some("image/webp"),
_ => None,
}
}
fn basename(path: &str) -> &str {
path.rsplit('/').next().unwrap_or(path)
}
fn dirname(path: &str) -> &str {
path.rsplit_once('/').map(|(d, _)| d).unwrap_or("")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::local::schema;
fn store() -> (Connection, BlobStore, std::path::PathBuf) {
let conn = Connection::open_in_memory().unwrap();
schema::migrate(&conn).unwrap();
let dir = std::env::temp_dir().join(format!("inkwell-portable-{}", uuid::Uuid::new_v4()));
let blobs = BlobStore::new(dir.clone()).unwrap();
(conn, blobs, dir)
}
fn all(conn: &Connection) -> Vec<Note> {
let q = super::super::models::ListQuery {
view: "notes".into(),
label_id: None,
facets: None,
sort: None,
};
store::list_notes(conn, &q).unwrap()
}
fn fixture() -> Value {
serde_json::from_str(include_str!("../../testdata/portable.json"))
.expect("portable.json parses")
}
/// The fixture's expected spec against ours, field by field, timestamps as instants.
fn assert_spec(got: &ImportSpec, want: &Value, about: &str) {
let s = |k: &str| want.get(k).and_then(Value::as_str).map(str::to_string);
let t = |k: &str| s(k).map(|v| DateTime::parse_from_rfc3339(&v).unwrap());
let ours = |v: &Option<String>| {
v.as_deref()
.map(|x| DateTime::parse_from_rfc3339(x).unwrap())
};
assert_eq!(got.title, s("title"), "{about}: title");
assert_eq!(Some(got.body.clone()), s("body"), "{about}: body");
assert_eq!(
Some(got.pinned),
want["pinned"].as_bool(),
"{about}: pinned"
);
assert_eq!(
Some(got.archived),
want["archived"].as_bool(),
"{about}: archived"
);
assert_eq!(
Some(got.trashed),
want["trashed"].as_bool(),
"{about}: trashed"
);
assert_eq!(got.labels, strings(want.get("labels")), "{about}: labels");
let items: Vec<(String, bool)> = objects(want.get("items"))
.map(|i| (str_of(i, "text").unwrap(), bool_of(i, "checked")))
.collect();
assert_eq!(got.items, items, "{about}: items");
let atts: Vec<(String, Option<String>)> = objects(want.get("attachments"))
.map(|a| (str_of(a, "file").unwrap(), str_of(a, "mime")))
.collect();
assert_eq!(got.attachments, atts, "{about}: attachments");
assert_eq!(
ours(&got.created_at),
t("created_at"),
"{about}: created_at"
);
assert_eq!(
ours(&got.updated_at),
t("updated_at"),
"{about}: updated_at"
);
if want.get("remind_at").is_some() {
assert_eq!(ours(&got.remind_at), t("remind_at"), "{about}: remind_at");
}
if want.get("recurrence").is_some() {
assert_eq!(got.recurrence, s("recurrence"), "{about}: recurrence");
}
}
#[test]
fn keep_notes_read_as_the_fixture_says() {
let cases = fixture()["keep"].as_array().unwrap().clone();
assert!(!cases.is_empty());
for case in &cases {
let note = case["note"].as_object().unwrap();
let got = keep_spec(note, case["dir"].as_str().unwrap());
assert_spec(&got, &case["spec"], case["about"].as_str().unwrap());
}
}
#[test]
fn native_notes_read_as_the_fixture_says() {
let cases = fixture()["native"].as_array().unwrap().clone();
assert!(!cases.is_empty());
for case in &cases {
let got = native_spec(case["note"].as_object().unwrap());
assert_spec(&got, &case["spec"], case["about"].as_str().unwrap());
}
}
fn zip_of(files: &[(&str, Vec<u8>)]) -> Vec<u8> {
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
let opts = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
for (name, bytes) in files {
write_entry(&mut zip, name, bytes, opts).unwrap();
}
zip.finish().unwrap().into_inner()
}
fn sorted_keys(v: &Value) -> Vec<String> {
let mut keys: Vec<String> = v.as_object().unwrap().keys().cloned().collect();
keys.sort();
keys
}
#[test]
fn an_export_writes_the_fixture_keys_and_imports_back_whole() {
let (conn, blobs, dir) = store();
let note = store::create_note(
&conn,
&NoteCreateInput {
body: "Trip #travel\n\n- [ ] passport".into(),
items: None,
},
)
.unwrap();
store::update_note(&conn, &note.id, &json!({ "pinned": true, "recurrence": "weekly", "remind_at": "2026-11-01T09:00:00.000Z" })).unwrap();
store::add_attachment(
&conn,
&blobs,
&note.id,
"ticket.pdf",
"application/pdf",
b"%PDF-1",
)
.unwrap();
let trashed = store::create_note(
&conn,
&NoteCreateInput {
body: "gone".into(),
items: None,
},
)
.unwrap();
store::trash(&conn, &trashed.id).unwrap();
let archive = export_zip(&conn, &blobs).unwrap();
// The shape, against the keys the server's export is held to as well.
let keys = &fixture()["export"];
let mut zip = ZipArchive::new(Cursor::new(archive.as_slice())).unwrap();
let mut raw = String::new();
zip.by_name("notes.json")
.unwrap()
.read_to_string(&mut raw)
.unwrap();
let doc: Value = serde_json::from_str(&raw).unwrap();
let want = |k: &str| {
let mut v = strings(keys.get(k));
v.sort();
v
};
assert_eq!(sorted_keys(&doc), want("document"));
assert_eq!(doc["app"], "inkwell");
let notes = doc["notes"].as_array().unwrap();
assert_eq!(notes.len(), 1, "the trashed note is not exported");
assert_eq!(sorted_keys(&notes[0]), want("note"));
assert_eq!(sorted_keys(&doc["labels"][0]), want("label"));
let att = &notes[0]["attachments"][0];
assert_eq!(sorted_keys(att), want("attachment"));
let mut bytes = Vec::new();
zip.by_name(att["file"].as_str().unwrap())
.unwrap()
.read_to_end(&mut bytes)
.unwrap();
assert_eq!(bytes, b"%PDF-1");
assert!(zip
.file_names()
.any(|n| n.starts_with("notes/trip-travel-") && n.ends_with(".md")));
// And back into an empty store, whole.
let (fresh, fresh_blobs, fresh_dir) = store();
let summary = import_zip(&fresh, &fresh_blobs, &archive).unwrap();
assert_eq!(
summary,
ImportSummary {
source: "inkwell".into(),
imported: 1,
skipped: 0
}
);
let back = all(&fresh);
assert_eq!(back.len(), 1);
let back = &back[0];
let original = store::get_note(&conn, &note.id).unwrap();
assert_eq!(back.body, original.body);
assert!(back.pinned);
assert_eq!(back.recurrence.as_deref(), Some("weekly"));
assert_eq!(back.remind_at, original.remind_at);
assert_eq!(
back.created_at, original.created_at,
"the source's own time, not the import's"
);
assert_eq!(
back.labels
.iter()
.map(|l| l.name.as_str())
.collect::<Vec<_>>(),
["travel"]
);
assert_eq!(back.attachments.len(), 1);
assert_eq!(back.attachments[0].filename.as_deref(), Some("ticket.pdf"));
let sha = back.attachments[0].sha256.clone().unwrap();
assert_eq!(fresh_blobs.read(&sha).unwrap(), b"%PDF-1");
std::fs::remove_dir_all(dir).ok();
std::fs::remove_dir_all(fresh_dir).ok();
}
#[test]
fn a_keep_takeout_imports_including_a_photo_only_note() {
let (conn, blobs, dir) = store();
let list = serde_json::to_vec(&fixture()["keep"][0]["note"]).unwrap();
let photo_only =
br#"{"textContent": "", "isPinned": false, "attachments": [{"filePath": "p.png"}]}"#;
let archive = zip_of(&[
("Takeout/Keep/Groceries.json", list),
("Takeout/Keep/photo.jpg", b"jpeg bytes".to_vec()),
("Takeout/Keep/scan.png", b"png bytes".to_vec()),
("Takeout/Keep/Photo.json", photo_only.to_vec()),
("Takeout/Keep/p.png", b"p".to_vec()),
(
"Takeout/Keep/empty.json",
br#"{"textContent": " "}"#.to_vec(),
),
(
"Takeout/Keep/unrelated.json",
br#"{"hello": "world"}"#.to_vec(),
),
]);
let summary = import_zip(&conn, &blobs, &archive).unwrap();
assert_eq!(
summary,
ImportSummary {
source: "keep".into(),
imported: 2,
skipped: 1
}
);
let notes = all(&conn);
let groceries = notes
.iter()
.find(|n| n.body.starts_with("Groceries"))
.expect("the list note");
assert!(groceries.body.contains("- [x] Eggs"));
assert!(groceries.pinned);
assert_eq!(groceries.attachments.len(), 2);
assert_eq!(
groceries.created_at.as_deref(),
Some("2020-09-13T12:26:40.000Z")
);
let photo = notes
.iter()
.find(|n| n.body.is_empty())
.expect("the photo-only note");
assert_eq!(photo.attachments[0].mime, "image/png");
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn what_is_not_an_archive_or_holds_no_notes_is_refused() {
let (conn, blobs, dir) = store();
assert!(import_zip(&conn, &blobs, b"not a zip")
.unwrap_err()
.contains("valid .zip"));
let other = zip_of(&[(
"notes.json",
br#"{"app": "someone-else", "notes": [{"body": "x"}]}"#.to_vec(),
)]);
assert!(import_zip(&conn, &blobs, &other)
.unwrap_err()
.contains("no importable notes"));
assert!(all(&conn).is_empty());
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn slugs_match_the_servers() {
assert_eq!(slugify("Trip #travel"), "trip-travel");
assert_eq!(slugify(" Hello, World! "), "hello-world");
assert_eq!(slugify("snake_case -- dashes"), "snake-case-dashes");
assert_eq!(slugify("!!!"), "note");
assert_eq!(slugify(""), "note");
assert_eq!(slugify(&"a".repeat(80)).len(), 60);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
//! Recurring-reminder math: where a reminder goes when it is marked done.
//!
//! A deliberate port of the server's `src/thoughtsync/notes/recurrence.py`, kept
//! A deliberate port of the server's `src/inkwell/notes/recurrence.py`, kept
//! behaviourally identical rather than merely similar. The same note can be
//! completed from the web (server code) or from the desktop and Android (this
//! code), and the two must land on the same instant — otherwise completing a
+2 -1
View File
@@ -37,7 +37,8 @@ pub fn sweep_expired_trash(
let mut expired: Vec<String> = Vec::new();
{
let mut stmt = conn.prepare(
"SELECT id, trashed_at FROM notes WHERE trashed = 1 AND trashed_at IS NOT NULL",
"SELECT id, trashed_at FROM notes
WHERE trashed = 1 AND trashed_at IS NOT NULL AND permission = 'owner'",
)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
+91 -1
View File
@@ -274,6 +274,57 @@ UPDATE saved_filters
AND params LIKE '%"color"%';
"#;
// v10 (#5168): an attachment can be created on THIS device.
//
// Until now every attachment row arrived on the delta feed, so every one was already on
// the server. A file attached here, offline, is not, and `uploaded = 0` is the queue
// push drains once the note has landed. The rows already here all came from the
// server, which is why the default is 1.
//
// `upload_error` holds a refusal that retrying won't fix — over the size limit, an id
// already in use, bytes that don't match their hash. A row carrying one leaves the
// queue: a file refused for its size would otherwise be re-sent in full on every
// cycle, every five minutes, for as long as the app was open. The message is what the
// editor shows on the file instead.
const SCHEMA_V10: &str = r#"
ALTER TABLE attachments ADD COLUMN uploaded INTEGER NOT NULL DEFAULT 1;
ALTER TABLE attachments ADD COLUMN upload_error TEXT;
"#;
// v11 (#5175): notes other people shared with this account.
//
// The feed now carries them, so a note says how it is held: `permission` is `owner`,
// `edit` (its text may change here) or `view`. Every note already on a device is the
// account's own, which is why the default is `owner`. `shared` is whether the owner
// has shared it with anyone, and `shared_by_*` names the owner of one shared with us.
//
// `shares_synced` is whether this device has pulled with shares since it was linked.
// The notes shared before this build sit below the cursor it already holds, so the
// first pull from a server offering `shares` starts again from zero (see
// `engine::run_cycle`). A pull applies idempotently, so starting over costs a
// download and nothing else.
const SCHEMA_V11: &str = r#"
ALTER TABLE notes ADD COLUMN permission TEXT NOT NULL DEFAULT 'owner';
ALTER TABLE notes ADD COLUMN shared INTEGER NOT NULL DEFAULT 0;
ALTER TABLE notes ADD COLUMN shared_by_id TEXT;
ALTER TABLE notes ADD COLUMN shared_by_name TEXT;
ALTER TABLE sync_state ADD COLUMN shares_synced INTEGER NOT NULL DEFAULT 0;
"#;
// v12 (#5176): a shared note's pin, archive and position are this account's own.
//
// `state_at` is when they last changed here, on a note someone else owns. It is kept
// apart from `updated_at`, which stays the time of the note's TEXT: pinning a copy
// whose text is behind the owner's must not make that text look like the newer
// edit when it is pushed. Null on our own notes, where `updated_at` covers both.
//
// `sync_state.shares_synced` now holds a level rather than a flag (see
// `state::SHARED_STATE`), and a device reaching this level pulls everything once
// more: the shared notes it holds carry their owner's pins until it does.
const SCHEMA_V12: &str = r#"
ALTER TABLE notes ADD COLUMN state_at TEXT;
"#;
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
@@ -313,6 +364,18 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(SCHEMA_V9)?;
conn.execute_batch("PRAGMA user_version = 9;")?;
}
if version < 10 {
conn.execute_batch(SCHEMA_V10)?;
conn.execute_batch("PRAGMA user_version = 10;")?;
}
if version < 11 {
conn.execute_batch(SCHEMA_V11)?;
conn.execute_batch("PRAGMA user_version = 11;")?;
}
if version < 12 {
conn.execute_batch(SCHEMA_V12)?;
conn.execute_batch("PRAGMA user_version = 12;")?;
}
Ok(())
}
@@ -427,7 +490,34 @@ mod tests {
let version: i64 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.expect("version");
assert_eq!(version, 9);
assert_eq!(version, 12);
}
/// Every attachment that predates v10 came down the feed, so it is already on the
/// server. Defaulting it to "waiting to upload" would re-send every file once.
#[test]
fn v10_counts_existing_attachments_as_already_on_the_server() {
let conn = v7_db();
migrate_v8(&conn).expect("v8");
conn.execute_batch(SCHEMA_V9).expect("v9");
conn.execute_batch("PRAGMA user_version = 9;").expect("v9");
add_note(&conn, "n", "a note");
conn.execute(
"INSERT INTO attachments (id, note_id, url) VALUES ('a', 'n', '/x')",
[],
)
.expect("seed");
migrate(&conn).expect("migrate");
let (uploaded, error): (bool, Option<String>) = conn
.query_row(
"SELECT uploaded, upload_error FROM attachments WHERE id = 'a'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.expect("row");
assert!(uploaded);
assert_eq!(error, None);
}
/// The column is gone, not merely unread. Asserted by asking SQLite rather than by
+1143 -30
View File
File diff suppressed because it is too large Load Diff
+27 -5
View File
@@ -78,6 +78,15 @@ impl BlobStore {
Ok(path)
}
/// File bytes this device produced (a file attached here) and return their hash.
/// The hash is computed from the bytes, so unlike [`store`](Self::store) there is
/// nothing to verify against.
pub fn put(&self, bytes: &[u8]) -> Result<String, String> {
let hash = digest(bytes);
self.store(&hash, bytes)?;
Ok(hash)
}
pub fn read(&self, sha256: &str) -> Option<Vec<u8>> {
fs::read(self.path(sha256)?).ok()
}
@@ -140,7 +149,9 @@ fn urlencode(value: &str) -> String {
out
}
fn urldecode(value: &str) -> String {
/// Undo percent-encoding. Also used for the filename the desktop's attach command
/// receives in a header, which can only carry ASCII.
pub fn urldecode(value: &str) -> String {
let bytes = value.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
@@ -163,12 +174,14 @@ fn urldecode(value: &str) -> String {
///
/// The mime rides in the URL and this scheme is an origin of its own, so echoing an
/// arbitrary type would let an attachment claiming `text/html` run as a document
/// there. Echoing is safe only because of the FAMILY check: nothing starting with
/// `image/` can name a scriptable type. Everything else is served as an opaque
/// download — the right treatment for an arbitrary file regardless.
/// there. Echoing is safe only for the families that can't carry script, and
/// `image/` is not quite one of them: `image/svg+xml` is a document that runs its own
/// `<script>`, so it is served as an opaque download like everything else unfamiliar.
/// The web made the same exclusion for the same reason (#1981).
fn content_type_for(mime: &str) -> String {
const RENDERABLE: &[&str] = &["image/", "audio/", "video/"];
let familiar = RENDERABLE.iter().any(|p| mime.starts_with(p)) || mime == "application/pdf";
let familiar = (RENDERABLE.iter().any(|p| mime.starts_with(p)) && mime != "image/svg+xml")
|| mime == "application/pdf";
// A header value can't carry control characters, and a mime type has no business
// being long — both would only arrive from a malformed or hostile feed.
let printable = mime.len() <= 100 && mime.bytes().all(|b| b.is_ascii_graphic());
@@ -295,6 +308,8 @@ mod tests {
let opaque = "application/octet-stream";
assert_eq!(content_type_for("text/html"), opaque);
assert_eq!(content_type_for("application/javascript"), opaque);
// An image family member that is really a document with script in it.
assert_eq!(content_type_for("image/svg+xml"), opaque);
assert_eq!(content_type_for(""), opaque);
// A control character can't reach a header value even under a safe family.
assert_eq!(content_type_for("image/png\r\nX-Evil: 1"), opaque);
@@ -309,6 +324,13 @@ mod tests {
assert!(body.is_empty());
}
#[test]
fn put_files_bytes_under_their_own_hash() {
let store = store("put");
assert_eq!(store.put(b"hello").expect("put"), HELLO);
assert_eq!(store.read(HELLO).as_deref(), Some(&b"hello"[..]));
}
#[test]
fn missing_blob_reads_as_none() {
let store = store("missing");
+202 -8
View File
@@ -1,4 +1,4 @@
//! HTTP transport to a ThoughtSync server.
//! HTTP transport to an Inkwell server.
//!
//! Covers the compatibility handshake (M10.6) and device-token auth (M10.7a). The
//! engine that moves notes — push, pull, cursor — grows on top of the same client,
@@ -27,6 +27,11 @@ const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
/// failing one at ten seconds would make a large store impossible to ever pull.
const SYNC_TIMEOUT: Duration = Duration::from_secs(120);
/// One file going up can be far larger than a page of notes, and a timeout shorter
/// than the transfer is not a failure that retrying ever fixes — the same upload
/// would time out again every cycle.
const UPLOAD_TIMEOUT: Duration = Duration::from_secs(600);
/// Shared by every call that presents a token, so a revoked one reads the same way
/// wherever it surfaces.
const TOKEN_REJECTED: &str = "This server rejected the device token — it may have been \
@@ -151,8 +156,8 @@ fn unexpected_status(base_url: &str, status: StatusCode) -> String {
/// Ask a server who it is and whether we can sync with it.
///
/// `Err` means we never got a usable answer (bad address, unreachable, not a
/// ThoughtSync server). A server that answers but is *incompatible* comes back `Ok`
/// `Err` means we never got a usable answer (bad address, unreachable, not an
/// Inkwell server). A server that answers but is *incompatible* comes back `Ok`
/// with a verdict — that distinction matters, because the two need very different
/// messages: one is "check what you typed", the other is "update something".
pub async fn probe(raw_url: &str) -> Result<ProbeResult, String> {
@@ -170,12 +175,12 @@ pub async fn probe(raw_url: &str) -> Result<ProbeResult, String> {
return Err(unexpected_status(&base_url, status));
}
// Something answered 200 that isn't a ThoughtSync server (a router login page, a
// Something answered 200 that isn't an Inkwell server (a router login page, a
// captive portal). Report the address, not the parse error, which would mean
// nothing to the person reading it.
let server: ServerInfo = response.json().await.map_err(|_| {
format!(
"{base_url} responded, but not with ThoughtSync's configuration. \
"{base_url} responded, but not with Inkwell's configuration. \
Is that the right address?"
)
})?;
@@ -260,8 +265,15 @@ pub async fn fetch_changes(
base_url: &str,
token: &str,
since: i64,
shares: bool,
) -> Result<wire::ChangesPage, String> {
let url = format!("{base_url}/api/sync/changes?since={since}");
// `shares=1` only to a server advertising `shares`: it asks for the notes shared
// with this account and the `revoked` list, which an older server would ignore.
let url = if shares {
format!("{base_url}/api/sync/changes?since={since}&shares=1")
} else {
format!("{base_url}/api/sync/changes?since={since}")
};
let request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token));
let response = request
.send()
@@ -314,6 +326,71 @@ pub async fn fetch_attachment(
.map_err(|e| format!("Couldn't download an attachment from {base_url}: {e}"))
}
/// Why an upload didn't land, split by whether trying again could help.
#[derive(Debug, PartialEq, Eq)]
pub enum UploadError {
/// Worth another attempt next cycle: the network, a server error, or a note the
/// server hasn't got yet.
Retry(String),
/// The server refused this file and will refuse it the same way every time —
/// too large, an id in use, bytes that don't match their hash.
Refused(String),
}
/// Upload one file attached on this device, under the id it was given here.
///
/// `PUT /api/sync/attachments/<id>` takes the raw bytes; idempotent, so a retry of
/// an upload whose reply was lost answers 200 and stores nothing twice.
#[allow(clippy::too_many_arguments)]
pub async fn upload_attachment(
base_url: &str,
token: &str,
note_id: &str,
attachment_id: &str,
filename: &str,
mime: &str,
sha256: &str,
bytes: Vec<u8>,
) -> Result<(), UploadError> {
let url = format!("{base_url}/api/sync/attachments/{attachment_id}");
let client = http_with(UPLOAD_TIMEOUT).map_err(UploadError::Retry)?;
let request = prepare(client.put(url), Some(token))
.query(&[
("note_id", note_id),
("filename", filename),
("sha256", sha256),
])
.header(reqwest::header::CONTENT_TYPE, mime)
.body(bytes);
let response = request
.send()
.await
.map_err(|e| UploadError::Retry(describe_transport_error(base_url, &e)))?;
let status = response.status();
if status.is_success() {
return Ok(());
}
if status == StatusCode::UNAUTHORIZED {
return Err(UploadError::Retry(TOKEN_REJECTED.to_string()));
}
// The server's own words, when it gave some: "file is too large (max 25 MB)" is
// what a person can act on, and a bare status code is not.
let reason = response
.json::<serde_json::Value>()
.await
.ok()
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
if status.is_client_error() && status != StatusCode::NOT_FOUND {
Err(UploadError::Refused(reason))
} else {
// 404: the note isn't on the server yet (its push was rejected, say). 5xx:
// the server's problem, and likely a passing one.
Err(UploadError::Retry(reason))
}
}
/// Send a batch of changes and hand back the raw reply.
///
/// Returns text rather than parsed results so this module stays pure transport —
@@ -346,6 +423,123 @@ pub async fn push_changes<T: Serialize>(
.map_err(|e| format!("Couldn't read the push reply from {base_url}: {e}"))
}
// --- sharing (#5175) -----------------------------------------------------------
//
// The Share dialog on a linked device asks the server directly, over the device
// token: who is on the instance, and who a note is shared with. Shares are the
// server's, so there is nothing to keep offline and nothing to queue.
/// Someone on the instance a note can be shared with.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct Member {
pub id: String,
#[serde(default)]
pub display_name: String,
#[serde(default)]
pub email: String,
}
/// One person a note is shared with, and at what level (`view` or `edit`).
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct NoteShare {
pub id: String,
pub member: Member,
pub permission: String,
#[serde(default)]
pub created_at: Option<String>,
}
#[derive(Deserialize)]
struct MembersReply {
members: Vec<Member>,
}
#[derive(Deserialize)]
struct SharesReply {
shares: Vec<NoteShare>,
}
/// A note made on this device that hasn't been pushed yet is a 404 to the server,
/// and so is one this account doesn't own.
const SHARE_NOT_FOUND: &str = "The server doesn't have this note yet. Sync, then share it.";
async fn read_reply<T: serde::de::DeserializeOwned>(
base_url: &str,
request: RequestBuilder,
) -> Result<T, String> {
let response = request
.send()
.await
.map_err(|e| describe_transport_error(base_url, &e))?;
let status = response.status();
if status == StatusCode::UNAUTHORIZED {
return Err(TOKEN_REJECTED.to_string());
}
if status == StatusCode::NOT_FOUND {
return Err(SHARE_NOT_FOUND.to_string());
}
if !status.is_success() {
// The server's own words when it gave some ("choose someone to share with").
let reason = response
.json::<serde_json::Value>()
.await
.ok()
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from));
return Err(reason.unwrap_or_else(|| unexpected_status(base_url, status)));
}
response
.json()
.await
.map_err(|e| format!("Couldn't read the reply from {base_url}: {e}"))
}
/// Everyone on the instance but this account.
pub async fn directory(base_url: &str, token: &str) -> Result<Vec<Member>, String> {
let url = format!("{base_url}/api/users/directory");
let reply: MembersReply = read_reply(base_url, prepare(http()?.get(url), Some(token))).await?;
Ok(reply.members)
}
pub async fn list_shares(
base_url: &str,
token: &str,
note_id: &str,
) -> Result<Vec<NoteShare>, String> {
let url = format!("{base_url}/api/notes/{note_id}/shares");
let reply: SharesReply = read_reply(base_url, prepare(http()?.get(url), Some(token))).await?;
Ok(reply.shares)
}
/// Share with one member, or change their permission. Answers the note's shares.
pub async fn share_note(
base_url: &str,
token: &str,
note_id: &str,
user_id: &str,
permission: &str,
) -> Result<Vec<NoteShare>, String> {
let url = format!("{base_url}/api/notes/{note_id}/shares");
let body = serde_json::json!({ "user_id": user_id, "permission": permission });
let reply: SharesReply = read_reply(
base_url,
prepare(http()?.post(url), Some(token)).json(&body),
)
.await?;
Ok(reply.shares)
}
pub async fn unshare_note(
base_url: &str,
token: &str,
note_id: &str,
share_id: &str,
) -> Result<Vec<NoteShare>, String> {
let url = format!("{base_url}/api/notes/{note_id}/shares/{share_id}");
let reply: SharesReply =
read_reply(base_url, prepare(http()?.delete(url), Some(token))).await?;
Ok(reply.shares)
}
/// The public, unauthenticated endpoint carrying the handshake.
fn config_url(base_url: &str) -> String {
format!("{base_url}/api/config")
@@ -427,8 +621,8 @@ mod tests {
#[test]
fn urls_preserve_a_port_and_subpath() {
assert_eq!(
config_url("http://192.168.1.10:8000/thoughtsync"),
"http://192.168.1.10:8000/thoughtsync/api/config"
config_url("http://192.168.1.10:8000/inkwell"),
"http://192.168.1.10:8000/inkwell/api/config"
);
}
}
+79 -17
View File
@@ -17,12 +17,18 @@
//! `docs/sync.md` for the policy that governs when those numbers move.
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
/// The sync wire protocol this client speaks.
///
/// v4 (M315): `color` left the note. NOT a floor raise on either side — see the note
/// on [`MIN_SERVER_PROTOCOL_VERSION`].
pub const CLIENT_PROTOCOL_VERSION: u32 = 4;
/// v5 (#5168): files attached here upload, and removed attachments and previews are
/// pushed. Additive: both go only to a server advertising `attachment_sync`.
/// v6 (#5175): notes shared with this account, asked for with `shares`.
/// v7 (#5176): this account's own pin, archive and position on a shared note, sent
/// only to a server advertising `shared_state`.
pub const CLIENT_PROTOCOL_VERSION: u32 = 7;
/// The oldest server protocol this client can drive — the symmetric half of the
/// server's `min_client_protocol_version`.
@@ -46,7 +52,14 @@ pub const REQUIRED_FEATURES: &[&str] = &["notes", "labels"];
/// Capabilities whose absence costs a feature but not the link. Listing these
/// explicitly (rather than diffing against whatever the server happens to send) is
/// what lets the UI name exactly what the user will be missing.
pub const OPTIONAL_FEATURES: &[&str] = &["attachments", "tombstones", "revisions"];
pub const OPTIONAL_FEATURES: &[&str] = &[
"attachments",
"tombstones",
"revisions",
"attachment_sync",
"shares",
"shared_state",
];
/// The handshake fields of `GET /api/config`.
///
@@ -74,7 +87,7 @@ pub struct ServerInfo {
}
impl ServerInfo {
fn has_feature(&self, name: &str) -> bool {
pub fn has_feature(&self, name: &str) -> bool {
self.sync_features.iter().any(|f| f.as_str() == name)
}
@@ -145,7 +158,7 @@ pub fn evaluate(info: &ServerInfo) -> Compatibility {
return incompatible(
&format!(
"This server requires client protocol v{floor} or newer; this app \
speaks v{CLIENT_PROTOCOL_VERSION}. Update ThoughtSync."
speaks v{CLIENT_PROTOCOL_VERSION}. Update Inkwell."
),
true,
);
@@ -172,16 +185,44 @@ pub fn evaluate(info: &ServerInfo) -> Compatibility {
}
}
/// Who this client says it is, set once by the host application at startup.
///
/// THE CORE CANNOT KNOW THIS, and the value it used to invent was wrong twice. It
/// was `inkwell-desktop/{CARGO_PKG_VERSION}`, and this crate is compiled into
/// the desktop app AND the Android app — so every phone in the field announced
/// itself as a desktop. The version was worse: `CARGO_PKG_VERSION` here is the
/// version of the CORE crate, a number no build stamps and no user has ever seen,
/// while the thing a reader of that header wants is the app's own build (note 3127
/// §5 — with no version tags, the artifact's self-report is the only answer to
/// "which build is this?").
///
/// So the host names itself. `OnceLock` because identity is fixed for the life of
/// the process and a second caller should be ignored rather than race the first.
static CLIENT_AGENT: OnceLock<String> = OnceLock::new();
/// Name this client for the servers it talks to — `("inkwell-android", "2026.08.31.1204")`.
///
/// Call once at startup, before any sync. Calling twice is not an error and the
/// first name wins; not calling it at all is visible in the header rather than
/// silently plausible.
pub fn set_client_agent(name: &str, version: &str) {
let _ = CLIENT_AGENT.set(format!("{name}/{version}"));
}
/// Headers this client puts on every request to a linked server, so the server can
/// log or gate on client identity without a separate handshake round-trip.
pub fn client_headers() -> [(&'static str, String); 2] {
let agent = format!("thoughtsync-desktop/{}", env!("CARGO_PKG_VERSION"));
// `unidentified/unknown`, never a plausible default. Nothing reads this header
// today, which is exactly why a wrong value could sit in it for months: the
// first person to look at a server log is the first person who could catch it,
// and only if what they see is obviously a host that never introduced itself.
let agent = CLIENT_AGENT
.get()
.cloned()
.unwrap_or_else(|| "inkwell-unidentified/unknown".to_string());
[
("X-ThoughtSync-Client", agent),
(
"X-ThoughtSync-Protocol",
CLIENT_PROTOCOL_VERSION.to_string(),
),
("X-Inkwell-Client", agent),
("X-Inkwell-Protocol", CLIENT_PROTOCOL_VERSION.to_string()),
]
}
@@ -202,7 +243,7 @@ pub fn normalize_base_url(raw: &str) -> Option<String> {
let with_scheme = match trimmed.split_once("://") {
Some((scheme, rest)) => {
// Anything that isn't HTTP(S) (ftp://, file://, a stray "foo://") can't
// be a ThoughtSync server; reject rather than fail confusingly later.
// be an Inkwell server; reject rather than fail confusingly later.
let scheme = scheme.to_ascii_lowercase();
if scheme != "http" && scheme != "https" {
return None;
@@ -227,7 +268,7 @@ mod tests {
/// A server matching this client exactly, which each test then degrades.
fn current_server() -> ServerInfo {
ServerInfo {
site_name: Some("ThoughtSync".into()),
site_name: Some("Inkwell".into()),
version: Some("0.1.0".into()),
sync_protocol_version: Some(CLIENT_PROTOCOL_VERSION),
min_client_protocol_version: Some(CLIENT_PROTOCOL_VERSION),
@@ -250,7 +291,7 @@ mod tests {
fn server_without_protocol_fields_is_too_old() {
// A pre-M10.6 server: /api/config parses, but carries no protocol block.
let info = ServerInfo {
site_name: Some("ThoughtSync".into()),
site_name: Some("Inkwell".into()),
version: Some("0.0.9".into()),
..Default::default()
};
@@ -360,13 +401,20 @@ mod tests {
// must not break the handshake.
// Versions come from the constants, not literals: this test is about unknown
// FIELDS, and pinning the numbers made it fail the moment the protocol moved
// to v2 — for a reason that has nothing to do with what it checks.
// to v2 — for a reason that has nothing to do with what it checks. The feature
// list likewise: a literal one went stale when `attachment_sync` was added.
let features: Vec<&str> = REQUIRED_FEATURES
.iter()
.chain(OPTIONAL_FEATURES.iter())
.copied()
.collect();
let body = format!(
r#"{{"site_name":"S","sync_protocol_version":{v},
"min_client_protocol_version":{v},
"sync_features":["notes","labels","attachments","tombstones","revisions"],
"sync_features":{f},
"some_future_field":{{"nested":true}}}}"#,
v = CLIENT_PROTOCOL_VERSION,
f = serde_json::to_string(&features).expect("features"),
);
let info: ServerInfo = serde_json::from_str(&body).expect("unknown fields are ignored");
assert_eq!(evaluate(&info), Compatibility::Ok);
@@ -374,10 +422,24 @@ mod tests {
#[test]
fn client_headers_identify_app_and_protocol() {
// Sets the process-wide agent, which is why this test also owns the
// assertion about it: a second test calling `set_client_agent` would race
// this one for the OnceLock, and whichever lost would see the other's name.
// One test, both branches, in order.
assert!(
client_headers()[0].1.starts_with("inkwell-unidentified/"),
"a host that never introduced itself must say so"
);
set_client_agent("inkwell-test", "2026.08.31.1204");
let headers = client_headers();
assert_eq!(headers[0].0, "X-ThoughtSync-Client");
assert!(headers[0].1.starts_with("thoughtsync-desktop/"));
assert_eq!(headers[0].0, "X-Inkwell-Client");
assert_eq!(headers[0].1, "inkwell-test/2026.08.31.1204");
assert_eq!(headers[1].1, CLIENT_PROTOCOL_VERSION.to_string());
// First name wins — a second host cannot rename a running process.
set_client_agent("inkwell-impostor", "0");
assert_eq!(client_headers()[0].1, "inkwell-test/2026.08.31.1204");
}
#[test]
+41 -12
View File
@@ -13,7 +13,7 @@ use super::push;
use super::state;
use crate::local::Db;
#[derive(Debug, Serialize)]
#[derive(Debug, Clone, Serialize)]
pub struct SyncOutcome {
pub push: push::PushSummary,
pub pull: pull::PullSummary,
@@ -38,8 +38,41 @@ pub async fn run_cycle(
base_url: &str,
token: &str,
) -> Result<SyncOutcome, String> {
let push = push::run(db, base_url, token).await?;
let pull = pull::run(db, blobs, base_url, token).await?;
// What this server can do, asked before anything is sent: files attached here,
// and removed attachments and previews, go only to a server advertising
// `attachment_sync`. An older one keeps them queued on this device until it is
// updated, rather than refusing each one on every cycle. Best-effort — an
// unanswered probe reads as "not advertised", and the cycle carries on.
let server = super::client::probe(base_url).await.ok().map(|p| p.server);
let attachment_sync = server
.as_ref()
.is_some_and(|s| s.has_feature("attachment_sync"));
// Notes shared with this account come only from a server offering `shares`, and
// an unanswered probe reads as "not offered", like `attachment_sync` above.
let shares = server.as_ref().is_some_and(|s| s.has_feature("shares"));
// A recipient's own pin, archive and order on a shared note go only to a server
// that keeps them per person; an older one would apply them to nobody.
let accepts = push::Accepts {
attachment_sync,
shared_state: server
.as_ref()
.is_some_and(|s| s.has_feature("shared_state")),
};
let push = push::run(db, blobs, base_url, token, accepts).await?;
if shares {
// After the push, so nothing unsent is waiting when the full pull lands.
let level = if accepts.shared_state {
state::SHARED_STATE
} else {
state::SHARES
};
let conn = db.0.lock().map_err(|e| e.to_string())?;
if state::begin_shares(&conn, level).map_err(|e| e.to_string())? {
log::info!("the server shares more of notes now; pulling everything once");
}
}
let pull = pull::run(db, blobs, base_url, token, shares).await?;
if pull.clobbered_dirty > 0 {
// Push ran first and reported success, so nothing should still have been
@@ -51,15 +84,11 @@ pub async fn run_cycle(
);
}
// While we're already talking to this server, re-read what it says about itself.
// Today that's the trash-retention window the Trash view counts down against, and
// it can change under us whenever an admin edits the setting. Best-effort on
// purpose: a config blip must not fail a cycle whose actual work already
// succeeded, and the stored value simply stays as it was.
let retention = super::client::probe(base_url)
.await
.ok()
.and_then(|p| p.server.trash_retention_days);
// The same answer carries the trash-retention window the Trash view counts down
// against, which can change whenever an admin edits the setting. Best-effort on
// purpose: a config blip must not fail a cycle whose actual work succeeded, and
// the stored value simply stays as it was.
let retention = server.and_then(|s| s.trash_retention_days);
let status = {
let conn = db.0.lock().map_err(|e| e.to_string())?;
+3 -1
View File
@@ -1,4 +1,4 @@
//! Talking to a ThoughtSync server — entirely opt-in.
//! Talking to an Inkwell server — entirely opt-in.
//!
//! The app is local-first: `local` is the source of truth and everything works
//! unlinked. Nothing in here runs until the user links a server.
@@ -8,6 +8,7 @@
//! - `client` — HTTP transport: the handshake call and device-token auth.
//! - `state` — the persisted link record (server, token, change-feed cursor).
//! - `engine` — one full cycle: push local changes, then pull the server's.
//! - `sharing` — the Share dialog's calls, straight to the server (#5175).
//!
//! The UI surface that drives this lives in whichever client is wrapping the crate,
//! not here.
@@ -18,5 +19,6 @@ pub mod compat;
pub mod engine;
pub mod pull;
pub mod push;
pub mod sharing;
pub mod state;
pub mod wire;
+236 -16
View File
@@ -149,6 +149,18 @@ pub fn apply_page(conn: &Connection, page: &wire::ChangesPage) -> rusqlite::Resu
summary.notes_applied += 1;
}
// After the notes, never before: a page can carry a note AND its revocation only
// when the revocation is the newer of the two (sharing again deletes an older
// one on the server), so the revocation is the one that has to win. Only a note
// someone else owns can be revoked; this account's own are never touched.
for id in &page.revoked {
let removed = tx.execute(
"DELETE FROM notes WHERE id = ?1 AND permission <> 'owner'",
params![id],
)?;
summary.notes_deleted += removed;
}
state::set_cursor(&tx, page.cursor)?;
tx.commit()?;
Ok(summary)
@@ -239,23 +251,38 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
};
// `created_at` is deliberately absent from the UPDATE clause: a note's birth time
// never changes, and the server's copy is the same value anyway.
// A server without `shares` sends no permission, and every note it sends is ours.
let permission = match note.permission.as_deref() {
Some("edit") => "edit",
Some("view") => "view",
_ => "owner",
};
let (shared_by_id, shared_by_name) = match &note.shared_by {
Some(by) => (Some(by.id.as_str()), Some(by.display_name.as_str())),
None => (None, None),
};
conn.execute(
"INSERT INTO notes (id, body, position, pinned, archived,
trashed, remind_at, recurrence, created_at, updated_at,
sync_revision, trashed_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 0)
sync_revision, trashed_at, dirty,
permission, shared, shared_by_id, shared_by_name)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 0, ?13, ?14, ?15, ?16)
ON CONFLICT(id) DO UPDATE SET
body = excluded.body,
position = excluded.position,
pinned = excluded.pinned,
archived = excluded.archived,
trashed = excluded.trashed,
remind_at = excluded.remind_at,
recurrence = excluded.recurrence,
updated_at = excluded.updated_at,
sync_revision = excluded.sync_revision,
trashed_at = excluded.trashed_at,
dirty = 0",
body = excluded.body,
position = excluded.position,
pinned = excluded.pinned,
archived = excluded.archived,
trashed = excluded.trashed,
remind_at = excluded.remind_at,
recurrence = excluded.recurrence,
updated_at = excluded.updated_at,
sync_revision = excluded.sync_revision,
trashed_at = excluded.trashed_at,
dirty = 0,
permission = excluded.permission,
shared = excluded.shared,
shared_by_id = excluded.shared_by_id,
shared_by_name = excluded.shared_by_name",
params![
note.id,
note.body,
@@ -269,6 +296,10 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
updated,
note.sync_revision,
trashed_at,
permission,
note.shared,
shared_by_id,
shared_by_name,
],
)?;
@@ -281,14 +312,41 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
Ok(())
}
/// Whether this device removed the row and the server hasn't acknowledged it yet.
/// Such a row is still on the server, so it is still in the feed — and putting it
/// back would undo a removal that is only waiting for the next push.
fn removed_here(conn: &Connection, entity: &str, id: &str) -> rusqlite::Result<bool> {
let found: Option<i64> = conn
.query_row(
"SELECT 1 FROM pending_deletes WHERE entity = ?1 AND id = ?2",
params![entity, id],
|r| r.get(0),
)
.optional()?;
Ok(found.is_some())
}
fn replace_attachments(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
// Only rows the server already had are replaced. A file attached here and still
// waiting to upload exists nowhere else yet, and deleting it would lose it; once
// it has gone up, the server's copy arrives under the same id and takes its place.
conn.execute(
"DELETE FROM attachments WHERE note_id = ?1",
"DELETE FROM attachments WHERE note_id = ?1 AND uploaded = 1",
params![note.id],
)?;
for (index, att) in note.attachments.iter().enumerate() {
if removed_here(conn, "attachment", &att.id)? {
continue;
}
// The server listing an id this device is still waiting to upload means the
// upload landed and only its reply was lost. The server's row replaces it.
conn.execute(
"DELETE FROM attachments WHERE id = ?1 AND uploaded = 0",
params![att.id],
)?;
// The feed carries no explicit position for attachments — they arrive in
// creation order, so the index preserves it.
// creation order, so the index preserves it. A plain INSERT, so an id listed
// twice fails the page rather than being quietly merged.
conn.execute(
"INSERT INTO attachments (id, note_id, url, filename, mime, size, sha256, position)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
@@ -313,6 +371,9 @@ fn replace_previews(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()
params![note.id],
)?;
for (index, preview) in note.previews.iter().enumerate() {
if removed_here(conn, "preview", &preview.id)? {
continue;
}
conn.execute(
"INSERT INTO link_previews (id, note_id, url, title, description, image_url,
site_name, position)
@@ -379,6 +440,7 @@ pub async fn run(
blobs: &BlobStore,
base_url: &str,
token: &str,
shares: bool,
) -> Result<PullSummary, String> {
let mut total = PullSummary::default();
@@ -388,7 +450,7 @@ pub async fn run(
state::read(&conn).map_err(|e| e.to_string())?.last_cursor
};
let page = client::fetch_changes(base_url, token, since).await?;
let page = client::fetch_changes(base_url, token, since, shares).await?;
// Trust the data over the flag: a server that claims more pages without
// advancing the cursor would spin this loop forever.
@@ -475,6 +537,9 @@ mod tests {
labels: vec![],
attachments: vec![],
previews: vec![],
permission: None,
shared: false,
shared_by: None,
}
}
@@ -495,6 +560,7 @@ mod tests {
labels,
cursor,
has_more: false,
revoked: vec![],
}
}
@@ -535,6 +601,76 @@ mod tests {
assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 0);
}
fn shared_note(id: &str, revision: i64, permission: &str) -> wire::Note {
let mut n = note(id, revision);
n.permission = Some(permission.into());
n.shared = true;
n.shared_by = Some(wire::SharedBy {
id: "u-owner".into(),
display_name: "Robin".into(),
});
n
}
#[test]
fn a_shared_note_lands_saying_who_shared_it_and_how() {
let conn = db();
apply_page(&conn, &page(vec![shared_note("n1", 1, "edit")], vec![], 1)).expect("apply");
let held: (String, i64, String) = conn
.query_row(
"SELECT permission, shared, shared_by_name FROM notes WHERE id = 'n1'",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.expect("row");
assert_eq!(held, ("edit".to_string(), 1, "Robin".to_string()));
// A server without shares sends no permission: the note is this account's own.
apply_page(&conn, &page(vec![note("n2", 2)], vec![], 2)).expect("apply");
assert_eq!(
count(
&conn,
"SELECT COUNT(*) FROM notes WHERE id = 'n2' AND permission = 'owner'"
),
1
);
}
#[test]
fn a_revoked_note_leaves_and_an_owned_one_never_does() {
let conn = db();
apply_page(
&conn,
&page(
vec![shared_note("theirs", 1, "view"), note("mine", 2)],
vec![],
2,
),
)
.expect("apply");
let mut revoked = page(vec![], vec![], 3);
revoked.revoked = vec!["theirs".into(), "mine".into(), "unknown".into()];
let summary = apply_page(&conn, &revoked).expect("apply");
assert_eq!(summary.notes_deleted, 1);
let left: Vec<String> = {
let mut stmt = conn.prepare("SELECT id FROM notes").unwrap();
let rows = stmt.query_map([], |r| r.get(0)).unwrap();
rows.collect::<rusqlite::Result<_>>().unwrap()
};
assert_eq!(left, ["mine"]);
}
#[test]
fn a_revocation_beside_its_note_in_one_page_wins() {
// The server deletes an older revocation when it shares again, so a page holds
// both only when the revocation is the newer: the note must not survive it.
let conn = db();
let mut both = page(vec![shared_note("n1", 4, "view")], vec![], 5);
both.revoked = vec!["n1".into()];
apply_page(&conn, &both).expect("apply");
assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 0);
}
#[test]
fn trashed_is_not_a_tombstone() {
// `trashed` is ordinary state that keeps syncing; only `purged_at` deletes.
@@ -778,4 +914,88 @@ mod tests {
assert_eq!(state::read(&conn).expect("state").last_cursor, 0);
assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 0);
}
fn preview(id: &str) -> wire::Preview {
wire::Preview {
id: id.to_string(),
url: "https://example.com/".into(),
title: None,
description: None,
image_url: None,
site_name: None,
}
}
fn queued_upload(conn: &Connection, id: &str, note_id: &str) {
conn.execute(
"INSERT INTO attachments (id, note_id, url, uploaded) VALUES (?1, ?2, '/x', 0)",
params![id, note_id],
)
.expect("queued upload");
}
fn attachment_ids(conn: &Connection) -> Vec<String> {
let mut stmt = conn
.prepare("SELECT id FROM attachments ORDER BY id")
.expect("prepare");
let rows = stmt.query_map([], |r| r.get(0)).expect("query");
rows.collect::<rusqlite::Result<_>>().expect("rows")
}
#[test]
fn a_pull_keeps_a_file_still_waiting_to_upload() {
// It exists only on this device until push sends it; a pull that replaced the
// note's attachments wholesale would delete the only copy.
let conn = db();
apply_page(&conn, &page(vec![note("n1", 1)], vec![], 1)).expect("apply");
queued_upload(&conn, "mine", "n1");
let mut changed = note("n1", 2);
changed.attachments = vec![attachment("theirs")];
apply_page(&conn, &page(vec![changed], vec![], 2)).expect("apply");
assert_eq!(attachment_ids(&conn), vec!["mine", "theirs"]);
}
#[test]
fn the_servers_copy_takes_over_from_an_upload_whose_reply_was_lost() {
let conn = db();
apply_page(&conn, &page(vec![note("n1", 1)], vec![], 1)).expect("apply");
queued_upload(&conn, "a1", "n1");
let mut listed = note("n1", 2);
listed.attachments = vec![attachment("a1")];
apply_page(&conn, &page(vec![listed], vec![], 2)).expect("apply");
assert_eq!(attachment_ids(&conn), vec!["a1"]);
let uploaded: bool = conn
.query_row(
"SELECT uploaded FROM attachments WHERE id = 'a1'",
[],
|r| r.get(0),
)
.expect("row");
assert!(uploaded, "not sent a second time");
}
#[test]
fn a_removal_waiting_to_be_pushed_is_not_undone_by_a_pull() {
let conn = db();
let mut first = note("n1", 1);
first.attachments = vec![attachment("a1")];
first.previews = vec![preview("p1")];
apply_page(&conn, &page(vec![first], vec![], 1)).expect("apply");
crate::local::store::delete_attachment(&conn, "n1", "a1").expect("remove");
crate::local::store::delete_preview(&conn, "n1", "p1").expect("dismiss");
// The server hasn't heard yet, so its copy of the note still lists both.
let mut stale = note("n1", 2);
stale.attachments = vec![attachment("a1")];
stale.previews = vec![preview("p1")];
apply_page(&conn, &page(vec![stale], vec![], 2)).expect("apply");
assert_eq!(count(&conn, "SELECT COUNT(*) FROM attachments"), 0);
assert_eq!(count(&conn, "SELECT COUNT(*) FROM link_previews"), 0);
}
}

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