90 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 b03c9cf81a desktop: in-app updates follow the server you installed from
CI & Build / Build & push image (push) Blocked by required conditions
Desktop (Tauri) / Tauri desktop (Linux) (push) Blocked by required conditions
Desktop (Tauri) / Windows installer (cross-compiled) (push) Blocked by required conditions
Desktop (Tauri) / Update manifest (push) Blocked by required conditions
CI & Build / integration (push) In progress
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
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (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) In progress
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 11s
Milestone 325 step 6 (Scribe #3254).

The server publishes its AppImage in the updater's own format at
/api/client/linux-appimage/update.json: the ordering key as `version`, the
signature, and an absolute download URL built on the host that was asked,
so the token the updater attaches goes nowhere else. Unsigned platforms and
a server with no AppImage 404.

The desktop's update source is now Fabled-Git (and its channel) or one
server:
- `read_source` is the one reader. The installer's `install-server` marker
  feeds the `update_server` pref once per new value, exactly as the channel
  marker feeds its pref; tauri.conf.json's endpoint is never consulted.
- From a server, the check and the download carry the sync link's token
  when the app is linked to that same server. Without one the update shows
  and says to link rather than offering a button that 401s.
- A server with no build says so. A 404 is "up to date" only on the forge,
  where it means an unpublished channel.
- Sync → App updates offers the source once there is a server to offer (the
  chosen one, or the linked one), and only shows the channel for the forge.

The trust anchor does not move: whatever the source, the updater verifies
the AppImage against the public key built into the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:56:36 -04:00
bvandeusenandClaude Opus 5.5 871878de41 install.sh installs from your own server, not just from the forge
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 / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (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 10s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 1m15s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m5s
CI & Build / Build & push image (push) Successful in 55s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m15s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 2m57s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Milestone 325 step 5 (Scribe #3253).

    curl -fsSL https://notes.example.com/install.sh | sh

The server serves the installer at /install.sh with its own address written
into it (installer.py). Settings → Public address when set, the request's own
address otherwise. The substitution is one variable, given a value that has
passed a strict shape check, and the script checks it again; an address that
cannot pass makes the route refuse rather than serve a script pointed
elsewhere. `public_url` joins the live settings cache so the route needs no
database.

From a server, the script:
- resolves each Linux bundle from the public /api/client/<platform>, and
  builds the download URL from the platform id rather than reading it from
  the reply;
- asks for a device token (from the terminal, since stdin is the script),
  or takes TS_TOKEN, and sends it from a file rather than the command line;
- checks the sha256 the server published before anything installs;
- revokes a prompted token once the download is done;
- records `install-server` for the updater (step 6) instead of the channel.

The forge path is unchanged, and stays the default for the copy the forge
serves. The Account page's downloads card shows the one-line command
whenever the server holds a Linux client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:44:39 -04:00
bvandeusenandClaude Opus 5.5 802eab4ef9 android: bring BoardScreen and NoteCard back under detekt's complexity limit
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) 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 / Python tests (push) Successful in 12s
Android / Core and FFI clippy and tests (push) Successful in 26s
CI & Build / integration (push) Successful in 1m7s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 9m31s
Android / Build the server image (push) Successful in 1s
Run 8693's detekt failed both on CyclomaticComplexMethod (17 and 15 against
15) after the filters and drag-to-reorder landed.

- BoardState now carries `filterable` and `reorderable`, so the board's
  rules for when the filter row shows and when a card can be carried live
  with the state they read instead of as boolean chains in the screen.
- NoteCard's contents (tags, body, links, attachments, reminder, sharing)
  move to their own CardContents composable, leaving NoteCard the gestures,
  the frame and the menu.

No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:38:37 -04:00
bvandeusenandClaude Opus 5.5 3b4fe310b8 android: the board's Filters and drag-to-reorder, as on the web
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 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
Android / Core and FFI clippy and tests (push) Successful in 58s
CI & Build / integration (push) Successful in 1m38s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m26s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m11s
Android / Kotlin + Rust (APK) (push) Failing after 5m6s
Android / Build the server image (push) Successful in 1s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m1s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Filters: a chip under the search bar opens a sheet with Has attachment, Shared
with me and tags (a note must carry all of them), applied as they are tapped,
with a count on the chip and a Clear beside it. Only the main board filters and
search spans everything, as on the web; opening another view starts it
unfiltered, a deleted tag drops out of the filters as it does from the lens, and
an empty filtered board says so.

Reorder: hold a card, then move it. The hold is the long press that opens the
card's menu, which closes as the card starts to move; lifting without moving
leaves the menu as before, and moving before the hold is a scroll. Cards trade
places live and the drop writes the order through the core's reorder, newly
exposed over the ffi as reorder_notes. Only on the plain main board, and only on
the same side of the pinned line, since the store sorts pinned first.

#5313.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:28:22 -04:00
bvandeusenandClaude Opus 5.5 4149229988 web: component tests for the Share dialog
@vue/test-utils and jsdom as dev dependencies, jsdom chosen per file with the
vitest environment comment so the existing unit tests stay on node. The dialog's
repo.shares is faked; the tests cover offering everyone, sharing with a person
and a group, changing and removing a share (and the board's shared flag that
follows), a refused share keeping the choice, the load retry, and the
single-person instance. #5313.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:28:22 -04:00
bvandeusenandClaude Opus 5.5 3829d52e4d ci: find our own container from mountinfo, the way Steward does
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 / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 15s
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 / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 1m20s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build & push image (push) Successful in 25s
The integration lookup read the job's id from /etc/hostname, which holds only
while the runner leaves the hostname as the container id. Steward's fix (#5104)
reads it from the /etc/hostname bind mount's path in /proc/self/mountinfo and
falls back to the hostname, so one recipe now serves every repo (#5313).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:12:52 -04:00
bvandeusenandClaude Opus 5.5 c614e6859a Revert the deliberately failing core test
CI & Build / Python lint (push) Successful in 2s
Android / Core and FFI clippy and tests (push) Successful in 57s
CI & Build / integration (push) Successful in 1m17s
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
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m9s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m10s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m56s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 10m1s
Android / Build the server image (push) Successful in 1s
It proved the APK gate (#5237): run 8667 failed at the core's tests, skipped
the APK job and still dispatched the server image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:46:39 -04:00
bvandeusenandClaude Opus 5.5 d682ae026d ci: the core checks run in their own job on ci-tauri, and the APK needs it
CI & Build / Python lint (push) Successful in 2s
Android / Core and FFI clippy and tests (push) Failing after 48s
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 / Web typecheck and unit tests (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / integration (push) Successful in 1m18s
CI & Build / Build & push image (push) Skipped
Android / Build the server image (push) Successful in 44s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 2m4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
The step added in 42db4cd ran cargo on the Android image, which has OpenSSL
only for the Android targets; the host build died at openssl-sys (run 8663)
before reaching a test. The core's clippy and tests are now a 'rust' job on
ci-tauri, the image desktop's verify runs them on, and the APK job needs it.

The server-image dispatch moves to its own job: it was a step inside the APK
job, and a skipped job runs no steps, so failing core checks would have
silently stopped the server image too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:42:17 -04:00
bvandeusenandClaude Opus 5.5 a682d6d225 core: a deliberately failing test, to prove the APK gate (reverted next)
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
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 12s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 2m31s
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 8s
CI & Build / integration (push) Successful in 1m22s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 3m39s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:35:06 -04:00
bvandeusenandClaude Opus 5.5 42db4cde6b ci: the APK waits for the core's clippy and tests
android.yml never ran cargo, so a core test that failed in desktop.yml's
verify job stopped the desktop installers and not the APK, which links the
same core through android/ffi (#5237). The Kotlin + Rust job now runs clippy
and the tests for inkwell-core and inkwell-ffi before anything is assembled
or published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:35:06 -04:00
bvandeusenandClaude Opus 5.5 82aa5ba7b6 android: wrap the board's column choice the way ktlint wants
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
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 / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 1m10s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m43s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:48:15 -04:00
bvandeusenandClaude Opus 5.5 d9f755b128 ci: the integration lane finds its own Postgres, not another job's
CI & Build / Python lint (push) Successful in 2s
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
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / integration (push) Successful in 1m6s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 3m27s
Run 8653 failed at 'alembic upgrade head' with a password error: two
integration jobs were on the runner at once, and the name=integration
filter took the other one's database (#5312). The lookup is now scoped to
this job's GITEA-ACTIONS-TASK-<id>- prefix, read from the job container's
own name, and requires exactly one match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:40:36 -04:00
bvandeusenandClaude Opus 5.5 d6757fc0fc android: the board takes three and four columns on a wide window, as the web does
The board was Fixed(2) at every width, so a tablet showed two wide columns
where the web shows three or four (#5311). The column count now follows the
web's NoteGrid breakpoints on the window's width: three from 1024dp, four
from 1280dp. A phone keeps two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:36:41 -04:00
bvandeusenandClaude Opus 5.5 b019172d47 all: remove saved views, the Has-reminder filter and the Created range
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 / Web typecheck and unit tests (push) Successful in 13s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 1m29s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 3m34s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m5s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m49s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 9m29s
Step 18 of the audit follow-through (#5180), on the operator's decisions.
Inkwell is for capture and recall (note 2897), and these three duplicated a
surface that does the job already:

- Saved views. They lived only on the web; the desktop kept its own set that
  never synced, and Android had none. Tags in the drawer already give
  one-click recall. Gone from the server (routes, model, migration 0038 drops
  the table), the core (store functions, schema v13 drops its table), the
  desktop commands, the web adapters, the drawer's Views list and the
  "Save view" link.
- The "Has reminder" facet. The Reminders page lists them, sorted by due.
- The FilterBar's "Created" range. Timeline is the date lens and keeps the
  created_after/created_before query it builds from local days, which also
  retires the UTC/local-day disagreement between the two (B3).

An old link that still carries the removed keys opens the plain board; a
web test pins that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:29:28 -04:00
bvandeusenandClaude Opus 5.5 888c6410f0 all: trim the history essays out of the longest comments
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 2s
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 2m0s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 3m11s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m51s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m39s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m4s
From the audit (#5179). Ten comment blocks narrated how the code got here:
milestone numbers, earlier values, the operator's verdict on an old design.
Each now says what the code does and why, and the history stays in git,
Scribe and docs/sync.md. The protocol-version comment in sync.py points at
docs/sync.md's policy section, which already lists every bump.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:03:44 -04:00
bvandeusenandClaude Opus 5.5 e75e3d37d0 web: shared note actions, reminder chips, page header, formatters and settings seam
From the audit (#5179, web half).

- NoteActions: share, pin, archive and trash (restore and delete forever
  when trashed), as both the card and the editor offer them. The editor's
  history toggle goes in its slot, and it closes on `acted`.
- ReminderActions: the Done / 1h / 1d chips on the card and in the editor,
  which are now the same chips.
- PageHeader: the back-to-board header that Settings, Sync and Linked
  devices each wrote out, now with a `back` icon from the shared set.
- notes/datetime: formatShortDateTime (was formatReminder and the editor's
  revLabel) and formatDateTime (the two `fmt` copies).
- notes/colors: labelDotClasses (the sidebar's and the tag manager's
  labelDot).
- Drawer links use exact-active-class instead of route.name ternaries.
- BoardView binds its three grids from one gridBinds object.
- Settings goes through repo.settings (rest, plus a local adapter that
  answers "needs a server") and shows load failures through AsyncState.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:02:18 -04:00
bvandeusenandClaude Opus 5.5 05c82c2362 core, desktop: Db::conn everywhere, Change::default, notes_where, and shared row mappings
Android / Kotlin + Rust (APK) (push) Canceled after 12m20s
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 / Python tests (push) Successful in 15s
CI & Build / Web typecheck and unit tests (push) Successful in 16s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / integration (push) Successful in 1m22s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 4m6s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 4m20s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m21s
Desktop (Tauri) / Update manifest (push) Successful in 5s
From the audit (#5179, core and desktop half).

- Every `db.0.lock().map_err(|e| e.to_string())?` (about 50 sites in core and
  the desktop) is now `db.conn()?`. The few sites that deliberately handle
  a poisoned lock differently, and the tests, keep their own spelling.
- push::Change derives Default, so its four constructors name only the
  fields they set.
- store: list_notes, reminders, titles and search share notes_where (ids
  from a query, each loaded through load_note). Labels share
  LABEL_SELECT/label_row, and saved filters share
  SAVED_FILTER_SELECT/saved_filter_row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:57:24 -04:00
bvandeusenandClaude Opus 5.5 646a115701 server: one credential check, one coerce_bool, one top-position query, and the shared response helpers
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 / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 1m11s
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) / 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 / Build & push image (push) Successful in 49s
From the audit (#5179, server half).

- auth: login and device-login share _check_credentials (dummy hash for a
  missing account, throttle bookkeeping, the failure log line) and
  _bad_credentials.
- settings uses common.coerce_bool. Its private copy differed only in
  treating a non-string as its truthiness, which the shared one now does.
- notes: create and import share helpers.top_position.
- auth, settings_api, sync and client_dist return errors through
  responses.json_error / not_found, and parse ids with parse_uuid.
- sync: push replies are built by _result(id, entity, status, **extra).

Already merged by earlier steps, so nothing to do here: attachment storage
(store_attachment), the preview upsert (only unfurl_queue writes one now),
and _serialize_note (delegates to _serialize_notes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:55:01 -04:00
bvandeusenandClaude Opus 5.5 b8f13cfc4a tests: the share ACL test's final body no longer expects the removed add-item route's eggs
Android / Kotlin + Rust (APK) (push) Skipped
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 1m5s
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 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 8s
CI & Build / Build & push image (push) Successful in 47s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:13:44 -04:00
bvandeusenandClaude Opus 5.5 e2e0740b06 tests: stop posting to the removed add-item route in the share ACL test
CI & Build / Python lint (push) Successful in 4s
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
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 13s
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) / Update manifest (push) Skipped
CI & Build / integration (push) Failing after 1m7s
CI & Build / Build & push image (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:06:42 -04:00
bvandeusenandClaude Opus 5.5 7eacd0569c all: delete the code nothing calls
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 8s
CI & Build / Python tests (push) Successful in 10s
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 / integration (push) Failing after 1m21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m47s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m48s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m39s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 8m37s
From the audit (#5178). Each was unreachable from every client:

- Checklist add-item and delete-item: REST POST /items and DELETE /items/<id>,
  the Tauri commands, the store, rest and local adapters, the core's
  add_item/delete_item, set_item_text and remove_item, and the FFI exports.
  Adding, rewording and removing an item are body edits in every editor. The
  checked toggle stays, and its rewriter is simpler without the drop branch.
- Manual unfurl: POST /unfurl and its adapters. Previews arrive in the
  background after a save (unfurl_queue).
- The /api/config `android_client` key, android_release() and the
  APK_NAME/MANIFEST_NAME aliases. Phones poll /api/client/android.
- users.email_verified and users.avatar_path (migration 0037). Nothing set
  the first or read the second; the SMTP reset never checked verification.
- derive::extract_tags (only tests used it; the shared fixture now runs
  through extract_tag_spans), the unused check and link icons, and the
  unused editor_add_item string.
- The blob scheme is renamed tsblob -> inkblob. URLs are built as notes are
  read, so nothing stored carries the old one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:00:44 -04:00
bvandeusenandClaude Opus 5.5 fd1d50662f android: share a note with a group
CI & Build / Python lint (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m39s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m37s
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 / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 1m22s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m26s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 8m16s
The Share sheet lists groups after people, shows a group share as its name and
how many are in it, and shares through the FFI's ShareTarget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:25:16 -04:00
bvandeusenandClaude Opus 5.5 442cca4398 web, core, desktop: share a note with a group, and Settings → Groups
The Share dialog lists people and groups in one picker and shows a group share
as its name and member count. Settings gains a Groups section for the admin:
create, rename, delete, and add or remove people.

The core client reads the directory's groups and group shares (ShareTarget:
a member or a group); the desktop command takes user_id or group_id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:25:16 -04:00
bvandeusenandClaude Opus 5.5 5f3cfe8bb7 groups: admin-managed groups, sharing a note with one, and membership in the feed
/api/groups (admin) creates, renames and deletes groups and adds or removes
members. The member directory lists every group, and a share may name a
group_id instead of a user_id; a note's shares answer with `member` or `group`.

A note shared with a group reaches whoever is in it now, so membership is what
the feed follows: joining grants each of the group's notes to the new member's
devices, and leaving (or the group being deleted) revokes them unless a direct
share or another group still reaches that person. recipients() never counts the
note's own owner, who may sit in a group it is shared with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:25:16 -04:00
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
322 changed files with 22054 additions and 4416 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 # cp .env.example .env
# #
@@ -29,15 +29,15 @@ POSTGRES_PASSWORD=
# #
# NOTE: `main` can sit well behind `dev`. If a feature you expect is missing, # 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. # 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. # 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 # 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 # 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. # 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 — # 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 # 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 # How much the app says. Credential events (sign-ins, failures, throttles, new
# accounts, device tokens issued) are logged at INFO and read with # accounts, device tokens issued) are logged at INFO and read with
# `docker compose logs app`. # `docker compose logs app`.
#THOUGHTSYNC_LOG_LEVEL=INFO #INKWELL_LOG_LEVEL=INFO
# Database identity. Changing these AFTER the first start does not rename anything # Database identity. Changing these AFTER the first start does not rename anything
# that already exists — the volume keeps whatever the first run created. # 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_USER=thoughtsync
#POSTGRES_DB=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 -------------------------------------------------------- # --- a note on HTTPS --------------------------------------------------------
# #
+70 -35
View File
@@ -4,7 +4,7 @@ name: Android
# #
# Replaces the Tauri-mobile lane deleted in step 2. What changed is what this # 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 # 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. # uniffi.
# #
# CI can only prove this BUILDS. A Linux runner cannot execute an APK, so anything # CI can only prove this BUILDS. A Linux runner cannot execute an APK, so anything
@@ -42,7 +42,7 @@ jobs:
# #
# The guard runs here so it covers the skip path too (§6.3). # The guard runs here so it covers the skip path too (§6.3).
# #
# NOTE THE COUPLING WITH ci.yml: when this lane builds, its last step dispatches # NOTE THE COUPLING WITH ci.yml: when this lane builds, its server-image job dispatches
# ci.yml so the image bakes in the APK just published. When it SKIPS, no dispatch # ci.yml so the image bakes in the APK just published. When it SKIPS, no dispatch
# happens — and that is correct, because ci.yml's `gate` stands down only when the # happens — and that is correct, because ci.yml's `gate` stands down only when the
# push touched Android's files, which is the same condition that makes this build. # push touched Android's files, which is the same condition that makes this build.
@@ -72,9 +72,33 @@ jobs:
sh packaging/guard-forward.sh android "$channel" sh packaging/guard-forward.sh android "$channel"
echo "build=$(sh packaging/should-build.sh android "$channel")" >> $GITHUB_OUTPUT echo "build=$(sh packaging/should-build.sh android "$channel")" >> $GITHUB_OUTPUT
# The core ships inside the APK (through android/ffi), so its checks have to gate
# the APK HERE, in this workflow's graph. desktop.yml runs them too, but a red run
# there cannot stop this lane publishing (rule 177, #5237).
#
# On ci-tauri, not ci-rust-android: these are host-target tests, and on Linux the
# core's native-tls is OpenSSL, whose headers only ci-tauri carries. The Android
# image has OpenSSL vendored for the Android targets alone (run 8663 failed at
# `openssl-sys` trying this there). Same Rust pin, same Cargo.lock.
rust:
name: Core and FFI clippy and tests
needs: [decide]
if: needs.decide.outputs.build == 'true'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
steps:
- uses: actions/checkout@v6
- name: Clippy
run: cargo clippy --locked -p inkwell-core -p inkwell-ffi --all-targets -- -D warnings
- name: Test
run: cargo test --locked -p inkwell-core -p inkwell-ffi
build: build:
name: Kotlin + Rust (APK) name: Kotlin + Rust (APK)
needs: [decide] needs: [decide, rust]
if: needs.decide.outputs.build == 'true' if: needs.decide.outputs.build == 'true'
# runs-on is only a scheduling label (Label Model B). flutter-ci is the # runs-on is only a scheduling label (Label Model B). flutter-ci is the
# proven-working label that can pull our container images. # proven-working label that can pull our container images.
@@ -86,8 +110,6 @@ jobs:
permissions: permissions:
contents: write contents: write
# For the dispatch at the end: this lane starts the server image build.
actions: write
defaults: defaults:
run: run:
@@ -134,7 +156,7 @@ jobs:
echo "code=$code" >> $GITHUB_OUTPUT echo "code=$code" >> $GITHUB_OUTPUT
if [ -n "${ANDROID_KEYSTORE_BASE64:-}" ]; then 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 "variant=Release" >> $GITHUB_OUTPUT
echo "label=release" >> $GITHUB_OUTPUT echo "label=release" >> $GITHUB_OUTPUT
# DEBUG profile, in a release APK, deliberately — see the note above # DEBUG profile, in a release APK, deliberately — see the note above
@@ -143,7 +165,7 @@ jobs:
# outright (run 4077). Unpicking that is worth doing and is not worth # outright (run 4077). Unpicking that is worth doing and is not worth
# blocking signed builds on. # blocking signed builds on.
echo "profile=debug" >> $GITHUB_OUTPUT 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 "apk=android/app/build/outputs/apk/release/app-release.apk" >> $GITHUB_OUTPUT
echo "Signed release build — $version (versionCode $code)" echo "Signed release build — $version (versionCode $code)"
else else
@@ -167,7 +189,7 @@ jobs:
# from the built .so. Run as its own step so a Rust failure is legible as a # 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. # Rust failure instead of arriving inside a Gradle stack trace.
- name: Build the native library and bindings - 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 # 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 # (M12 step 3) precisely so this lane needs no second image, and going
@@ -185,15 +207,15 @@ jobs:
- name: Unit tests - name: Unit tests
# Host-JVM tests only. Anything touching the core needs an Android # Host-JVM tests only. Anything touching the core needs an Android
# runtime to load the .so, so those are instrumented tests and belong on # runtime to load the .so, so those are instrumented tests and belong on
# an emulator, not here — the Rust side is covered by the workspace # an emulator, not here — the Rust side is the `rust` job, which this
# tests in the desktop lane. # one needs.
# #
# DEBUG regardless of what is being packaged: AGP creates unit-test tasks # DEBUG regardless of what is being packaged: AGP creates unit-test tasks
# only for `testBuildType`, which is debug, so `testReleaseUnitTest` does # only for `testBuildType`, which is debug, so `testReleaseUnitTest` does
# not exist (run 4082). It costs one extra Kotlin compile and buys the # 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 # type-check on the debug variant, which is the one an emulator build
# would use. # 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 - name: Assemble the APK
env: env:
@@ -203,9 +225,9 @@ jobs:
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
run: | run: |
./gradlew assemble${{ steps.build.outputs.variant }} \ ./gradlew assemble${{ steps.build.outputs.variant }} \
-PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }} \ -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }} \
-PTHOUGHTSYNC_VERSION_NAME=${{ steps.build.outputs.name }} \ -PINKWELL_VERSION_NAME=${{ steps.build.outputs.name }} \
-PTHOUGHTSYNC_VERSION_CODE=${{ steps.build.outputs.code }} -PINKWELL_VERSION_CODE=${{ steps.build.outputs.code }}
# Prints the certificate the APK was actually signed with, so the operator # Prints the certificate the APK was actually signed with, so the operator
# can compare it against the fingerprint recorded when the key was # can compare it against the fingerprint recorded when the key was
@@ -226,10 +248,10 @@ jobs:
if: steps.build.outputs.keystore != '' if: steps.build.outputs.keystore != ''
run: | run: |
mkdir -p dist mkdir -p dist
cp "app/build/outputs/apk/release/app-release.apk" dist/thoughtsync.apk cp "app/build/outputs/apk/release/app-release.apk" dist/inkwell.apk
size="$(wc -c < dist/thoughtsync.apk | tr -d ' ')" size="$(wc -c < dist/inkwell.apk | tr -d ' ')"
sha="$(sha256sum dist/thoughtsync.apk | cut -d' ' -f1)" sha="$(sha256sum dist/inkwell.apk | cut -d' ' -f1)"
cat > dist/thoughtsync-android.json <<JSON cat > dist/inkwell-android.json <<JSON
{ {
"version_name": "${{ steps.build.outputs.name }}", "version_name": "${{ steps.build.outputs.name }}",
"version_code": ${{ steps.build.outputs.code }}, "version_code": ${{ steps.build.outputs.code }},
@@ -237,7 +259,7 @@ jobs:
"sha256": "$sha" "sha256": "$sha"
} }
JSON JSON
cat dist/thoughtsync-android.json cat dist/inkwell-android.json
# The rolling channel for this branch, the same fixed-tag releases the desktop # 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 # bundles use. CI artifacts are per-run and auth-gated, so they are no use as a
@@ -256,43 +278,56 @@ jobs:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
run: | run: |
case "$GITHUB_REF_NAME" in case "$GITHUB_REF_NAME" in
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;; main) channel=stable; RELEASE_PRERELEASE=false ;;
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;; *) channel=dev; RELEASE_PRERELEASE=true ;;
esac 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 export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing the APK to the $RELEASE_TAG channel." echo "Publishing the APK to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh bash desktop/packaging/publish-release.sh
- name: Upload the APK - name: Upload the APK
# Mirrored action, never actions/upload-artifact. @v4+ throws # Stock action: it works on this forge since the runner moved to
# GHESNotSupportedError client-side on this hostname, and @v3 is worse — # gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
# it reports success while Gitea serves artifacts back only through the # out of the action bundle (Scribe snippet #2271). Never @v3 — it reports
# v4 API, so the upload is stored and invisible. Pinned by SHA because # success while Gitea serves artifacts back only through the v4 API, so the
# the mirror auto-syncs; full URL because DEFAULT_ACTIONS_URL sends bare # upload is stored and invisible (Scribe 2270).
# owner/repo to github.com. See Scribe issues 2255 / 2270. uses: actions/upload-artifact@v7
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
with: with:
# The APK's variant, NOT the Cargo profile — those are the same word # The APK's variant, NOT the Cargo profile — those are the same word
# for different things and the profile is pinned to debug (#2810). # 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 }} path: ${{ steps.build.outputs.apk }}
if-no-files-found: error if-no-files-found: error
server-image:
name: Build the server image
needs: [decide, rust, build]
if: always() && needs.decide.outputs.build == 'true' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
runs-on: flutter-ci
container:
# The image the dispatch has always run in, so its curl is a known quantity.
image: git.fabledsword.com/bvandeusen/ci-rust-android:1.97
permissions:
actions: write
steps:
# The server image bakes in whatever client the dev release holds, so it has # The server image bakes in whatever client the dev release holds, so it has
# to be built AFTER this lane, not alongside it. `ci.yml` stands down on any # to be built AFTER this lane, not alongside it. `ci.yml` stands down on any
# push that touches the Android app (its `gate` job) and waits to be called # push that touches the Android app (its `gate` job) and waits to be called
# from here — that is the other half of this. # from here — that is the other half of this.
# #
# `always()`: a FAILED Android build must still let the server image through. # `always()`: a FAILED Android build — or failed core checks, which skip the
# There is no new client in that case, so it bakes in the previous one, which # build job entirely — must still let the server image through. There is no
# is exactly right — the alternative is a broken Android lane silently # new client in that case, so it bakes in the previous one, which is exactly
# blocking server delivery. # right — the alternative is a broken Android lane silently blocking server
# delivery. Its own job for that reason: a step inside `build` never runs
# when `build` is skipped.
# #
# Not `if: success()` and not skipped on tags either: every ref that builds an # Not `if: success()` and not skipped on tags either: every ref that builds an
# image needs the call, or nothing builds one at all. # image needs the call, or nothing builds one at all.
- name: Build the server image now the client is published - name: Build the server image now the client is published
if: always() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
working-directory: .
env: env:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
run: | run: |
+53 -45
View File
@@ -54,7 +54,7 @@ permissions:
env: env:
REGISTRY: git.fabledsword.com REGISTRY: git.fabledsword.com
IMAGE: git.fabledsword.com/bvandeusen/thoughtsync IMAGE: git.fabledsword.com/bvandeusen/inkwell
jobs: jobs:
# Should this push build an image now, or is the Android lane about to publish a # 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 echo "build=true" >> $GITHUB_OUTPUT
fi 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: typecheck:
name: TypeScript typecheck name: Web typecheck and unit tests
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci runs-on: python-ci
container: container:
@@ -156,8 +160,14 @@ jobs:
run: npm ci run: npm ci
working-directory: frontend 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 - 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 working-directory: frontend
lint: lint:
@@ -215,11 +225,11 @@ jobs:
# Postgres it will actually meet. # Postgres it will actually meet.
image: postgres:16-alpine image: postgres:16-alpine
env: env:
POSTGRES_USER: thoughtsync POSTGRES_USER: inkwell
POSTGRES_PASSWORD: ci_integration POSTGRES_PASSWORD: ci_integration
POSTGRES_DB: thoughtsync_test POSTGRES_DB: inkwell_test
options: >- options: >-
--health-cmd "pg_isready -U thoughtsync" --health-cmd "pg_isready -U inkwell"
--health-interval 10s --health-interval 10s
--health-timeout 5s --health-timeout 5s
--health-retries 10 --health-retries 10
@@ -239,11 +249,21 @@ jobs:
set -eux set -eux
echo "=== container landscape (diagnostic for the name filter) ===" echo "=== container landscape (diagnostic for the name filter) ==="
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}' docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
PG=$(docker ps --filter "name=integration" --filter "ancestor=postgres:16-alpine" -q | head -n1) # Only THIS job's services. The runner's docker daemon is shared, so another
test -n "$PG" # repo's job can be running beside this one, and a job-name filter matches its
# containers too (Steward run 8358 and Inkwell run 8653 each took another repo's
# Postgres). act names every container of a task GITEA-ACTIONS-TASK-<n>-...: read
# <n> from our own container (its id is in the /etc/hostname bind mount) and
# require exactly one match.
SELF=$(grep -o '/containers/[0-9a-f]\{64\}/' /proc/self/mountinfo | head -n1 | cut -d/ -f3 || true)
SELF=${SELF:-$(hostname)}
TASK=$(docker inspect -f '{{.Name}}' "$SELF" | grep -o 'GITEA-ACTIONS-TASK-[0-9]*-')
test -n "$TASK"
PG=$(docker ps --filter "name=$TASK" --filter "ancestor=postgres:16-alpine" -q)
test "$(printf '%s\n' "$PG" | grep -c .)" -eq 1
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG") PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
test -n "$PG_IP" 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) — # 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. # no bash /dev/tcp — so use the Python that is always present here.
/opt/venv/bin/python - "$PG_IP" <<'PY' /opt/venv/bin/python - "$PG_IP" <<'PY'
@@ -323,53 +343,41 @@ jobs:
docker system prune -af || true docker system prune -af || true
docker builder prune --keep-storage 5g -f || true docker builder prune --keep-storage 5g -f || true
# Bake the Android client in, on EVERY image build, so :dev, :latest and # Bake EVERY client in, on every image build, so a self-hoster gets a working
# :<version> all carry one and a `docker compose pull` delivers a new client # app for their machine from the server holding their notes — without an
# along with the new server. # 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 # ~104 MB on top of the ~85 MB image, almost all of it the AppImage. That is
# image therefore carries the newest client rather than one pinned to that # the price of the product being complete (rule 23), and the AppImage is not
# version; the two negotiate a sync protocol version before linking, so # optional within it: it is the ONLY bundle that can replace itself in place,
# "newest" is safe in a way "matching" would not buy anything over. # 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. # 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 # NEVER fails the build — see the script. A platform with nothing published
# hides the download — a supported state, and the only one available before # means the server advertises nothing for it and the UI hides that download,
# the first Android build has ever published. # which is a supported state and the only one available before that platform's
- name: Fetch the Android client to bake in # first build has ever published.
- name: Fetch the clients to bake in
env: env:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: | run: |
mkdir -p client # THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves dev clients;
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves the dev # :latest serves stable ones. This read `download/dev` unconditionally
# client; :latest serves the stable one. This read `download/dev` # until M314 step 3, on every branch — so every stable server shipped a
# unconditionally until M314 step 3, on every branch — so every stable # dev-channel APK to anyone who downloaded the client from it. Not a
# server shipped a dev-channel APK to anyone who downloaded the client # versioning gap; a plain defect, and the reason the channel is chosen here
# from it. Not a versioning gap; a plain defect, fixed here because this # rather than inside the script: the caller is what knows which image it is
# is the step that gave `stable` an APK to point at. # building.
case "${{ github.ref_name }}" in case "${{ github.ref_name }}" in
main) channel=stable ;; main) channel=stable ;;
*) channel=dev ;; *) channel=dev ;;
esac esac
echo "Baking in the $channel client." sh packaging/fetch-clients.sh "$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
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
+116 -45
View File
@@ -75,41 +75,47 @@ jobs:
sh packaging/guard-forward.sh desktop "$channel" sh packaging/guard-forward.sh desktop "$channel"
echo "build=$(sh packaging/should-build.sh desktop "$channel")" >> $GITHUB_OUTPUT echo "build=$(sh packaging/should-build.sh desktop "$channel")" >> $GITHUB_OUTPUT
build: # The workspace checks, in their OWN job, so that BOTH publishing jobs can need
name: Tauri desktop (Linux) # 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] needs: [decide]
if: needs.decide.outputs.build == 'true' if: needs.decide.outputs.build == 'true'
runs-on: python-ci runs-on: python-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97 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: steps:
# No version is derived here, so the default shallow checkout is enough.
- uses: actions/checkout@v6 - 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 # 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 # frontend must exist before clippy or the tests can compile the desktop crate.
# at bundle time.
- name: Build the shared frontend - name: Build the shared frontend
run: npm ci && npm run build run: npm ci && npm run build
working-directory: frontend working-directory: frontend
# --locked on the FIRST cargo invocation of the job is the lockfile gate: it # The web's unit tests, HERE as well as in ci.yml. The installers embed this
# fails the run if Cargo.toml and the committed Cargo.lock disagree, instead # frontend, and ci.yml's verdict is invisible to this workflow — run there only,
# of silently re-resolving. Everything after it in this job then compiles the # a red web test would still let both installers publish (rule 177).
# exact versions recorded in the lockfile, so the flag isn't repeated on the - name: Web unit tests
# bundle build (issue 2102). 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. # 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 # 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 # 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 # 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 # means every push reports its real problems too. Still before either bundle
# bundle build, so a fmt failure doesn't burn that. # build, so a fmt failure doesn't burn one.
- name: Rust format check - name: Rust format check
run: cargo fmt --all --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. # Frontend already built above; skip the beforeBuildCommand rebuild.
# #
# createUpdaterArtifacts is applied only when a signing key exists (M10.9): # 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). # separate value and arrives with the UI that shows it (#3181).
version="$(sh ../../packaging/version.sh key desktop)" version="$(sh ../../packaging/version.sh key desktop)"
echo "Building desktop ordering key $version" 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 \ cargo tauri build \
--config '{"build":{"beforeBuildCommand":""}}' \ --config '{"build":{"beforeBuildCommand":""}}' \
--config "{\"version\":\"$version\"}" \ --config "{\"version\":\"$version\"}" \
@@ -222,18 +265,17 @@ jobs:
run: bash desktop/packaging/arch/package-prebuilt.sh run: bash desktop/packaging/arch/package-prebuilt.sh
# Make the built .deb + .AppImage downloadable from the run (for hand-testing). # Make the built .deb + .AppImage downloadable from the run (for hand-testing).
# Mirrored action, never actions/upload-artifact: @v4+ throws # Stock action: it works on this forge since the runner moved to
# GHESNotSupportedError on the hostname before it connects, and @v3 uploads # 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 # something Gitea stores but will never serve back (it returns artifacts only
# through the v4 API, which filters on content_encoding='application/zip'). # 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 # No continue-on-error: a swallowed upload failure is exactly how 110
# unreachable artifacts accumulated here unnoticed. Fail loudly instead. # unreachable artifacts accumulated here unnoticed. Fail loudly instead.
- name: Upload bundles - name: Upload bundles
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245 uses: actions/upload-artifact@v7
with: with:
name: thoughtsync-linux name: inkwell-linux
path: | path: |
target/release/bundle/appimage/*.AppImage target/release/bundle/appimage/*.AppImage
target/release/bundle/deb/*.deb target/release/bundle/deb/*.deb
@@ -271,9 +313,12 @@ jobs:
# `prerelease` is true for dev so it does not read as a supported build, # `prerelease` is true for dev so it does not read as a supported build,
# and false for stable, which is the real thing. # and false for stable, which is the real thing.
case "$GITHUB_REF_NAME" in case "$GITHUB_REF_NAME" in
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;; main) channel=stable; RELEASE_PRERELEASE=false ;;
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;; *) channel=dev; RELEASE_PRERELEASE=true ;;
esac 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 export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing to the $RELEASE_TAG channel." echo "Publishing to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh 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. # built, not that it runs. A real-machine check stays mandatory before trusting it.
windows: windows:
name: Windows installer (cross-compiled) name: Windows installer (cross-compiled)
needs: [decide] needs: [decide, verify]
if: needs.decide.outputs.build == 'true' if: needs.decide.outputs.build == 'true'
runs-on: python-ci runs-on: python-ci
container: container:
@@ -324,8 +369,8 @@ jobs:
run: cargo tauri icon app-icon.png run: cargo tauri icon app-icon.png
working-directory: desktop/src-tauri working-directory: desktop/src-tauri
# This lane's lockfile gate (the Linux job gets it from `cargo clippy # This lane's own lockfile check (`verify` has already run `cargo clippy
# --locked`). It has to be its own step here because the build is this job's # --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 # 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 # cross-compile is the expensive way to learn it. Fetching for the Windows
# target also pre-warms exactly the crates the build will want. # 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). # separate value and arrives with the UI that shows it (#3181).
version="$(sh ../../packaging/version.sh key desktop)" version="$(sh ../../packaging/version.sh key desktop)"
echo "Building desktop ordering key $version" 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='{}' updater='{}'
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
updater='{"bundle":{"createUpdaterArtifacts":true}}' updater='{"bundle":{"createUpdaterArtifacts":true}}'
@@ -360,13 +413,11 @@ jobs:
--config "$updater" --config "$updater"
working-directory: desktop/src-tauri working-directory: desktop/src-tauri
# Mirrored action, never actions/upload-artifact — see the Linux job's # Stock action — see the Linux job's Upload bundles step for why.
# Upload bundles step for the full reasoning. Pinned by SHA because the
# mirror auto-syncs.
- name: Upload installer - name: Upload installer
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245 uses: actions/upload-artifact@v7
with: with:
name: thoughtsync-windows name: inkwell-windows
path: target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe path: target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
if-no-files-found: error if-no-files-found: error
@@ -399,9 +450,12 @@ jobs:
# `prerelease` is true for dev so it does not read as a supported build, # `prerelease` is true for dev so it does not read as a supported build,
# and false for stable, which is the real thing. # and false for stable, which is the real thing.
case "$GITHUB_REF_NAME" in case "$GITHUB_REF_NAME" in
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;; main) channel=stable; RELEASE_PRERELEASE=false ;;
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;; *) channel=dev; RELEASE_PRERELEASE=true ;;
esac 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 export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing to the $RELEASE_TAG channel." echo "Publishing to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh 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 # match the binary it points at is an updater that never settles. It must
# be `key`: this value is matched against bundle filenames. # be `key`: this value is matched against bundle filenames.
version="$(sh packaging/version.sh key desktop)" 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 # 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 # 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. # 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 # 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. # triggers release.yml, which writes a changelog and builds nothing.
case "${GITHUB_REF_NAME}" in 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_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}" ;; export RELEASE_NOTES="Development build from ${GITHUB_SHA}" ;;
esac 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 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 # 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 # 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. # the Dockerfile's COPY always has a directory to copy.
client/thoughtsync.apk client/inkwell.apk
client/thoughtsync-android.json client/inkwell-android.json
Generated
+681 -59
View File
File diff suppressed because it is too large Load Diff
+21 -10
View File
@@ -20,22 +20,33 @@ RUN --mount=type=cache,target=/root/.cache/pip \
# Bake the built SPA into the package's static dir (served by app.py). PYTHONPATH # 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), # points at /app/src so the runtime imports this source tree (with static/ present),
# not the pip-installed copy. # 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.ini .
COPY alembic/ alembic/ COPY alembic/ alembic/
# The Android client this server hands out. CI fetches the newest published build # The clients this server hands out — the APK and all four desktop bundles. CI
# into ./client immediately before this runs (ci.yml), so every image tag — :dev, # fetches the newest published build of each into ./client immediately before this
# :latest and :<version> alike — ships a client, and a `docker compose pull` # runs (packaging/fetch-clients.sh), so both image tags ship a full set and a
# delivers a new one with no file copying by hand. # `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. # 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 # 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 # that step never ran. An image with no clients — or with some and not others — is
# advertises nothing and the web UI hides the download (client_dist.py). # a supported state: the server advertises what it has and the web UI hides the
COPY client/ src/thoughtsync/client/ # rest (client_dist.py).
COPY client/ src/inkwell/client/
# The desktop installer, served at /install.sh with this server's own address
# written into it (installer.py). The SAME script the forge serves raw, copied rather
# than moved so the operator's documented forge URL keeps working.
COPY desktop/packaging/install.sh src/inkwell/install.sh
ENV PYTHONPATH=/app/src ENV PYTHONPATH=/app/src
@@ -46,4 +57,4 @@ EXPOSE 5000
# Wait for the database, run migrations, then serve. The DB wait keeps a briefly # Wait for the database, run migrations, then serve. The DB wait keeps a briefly
# slow/unready database from crash-looping the container. Family convention # slow/unready database from crash-looping the container. Family convention
# (rule 82): schema is built by real migrations, never metadata.create_all. # (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 Self-hosted personal thought-capture web app in the **FabledSword** family — a
Google-Keep-style **masonry post-it board** for capturing disparate thoughts in 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 ## 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`) alembic/ async migrations (schema built via `alembic upgrade head`)
tests/ DB-free unit tests (pytest) tests/ DB-free unit tests (pytest)
frontend/ Vue 3 + Vite + TypeScript + Tailwind SPA frontend/ Vue 3 + Vite + TypeScript + Tailwind SPA
@@ -23,12 +23,12 @@ docker-compose.yml local app + Postgres stack
## Development ## Development
Backend (needs a Postgres reachable at `THOUGHTSYNC_DATABASE_URL`): Backend (needs a Postgres reachable at `INKWELL_DATABASE_URL`):
```sh ```sh
pip install -e ".[dev]" pip install -e ".[dev]"
alembic upgrade head 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`): Frontend (proxies `/api` to `:5000`):
@@ -64,40 +64,40 @@ services:
image: postgres:16-alpine image: postgres:16-alpine
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: thoughtsync POSTGRES_USER: inkwell
POSTGRES_PASSWORD: CHANGE_ME # change this POSTGRES_PASSWORD: CHANGE_ME # change this
POSTGRES_DB: thoughtsync POSTGRES_DB: inkwell
volumes: volumes:
- thoughtsync-db:/var/lib/postgresql/data - inkwell-db:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U thoughtsync"] test: ["CMD-SHELL", "pg_isready -U inkwell"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10
app: 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 restart: unless-stopped
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
environment: environment:
THOUGHTSYNC_DATABASE_URL: postgresql+asyncpg://thoughtsync:CHANGE_ME@db:5432/thoughtsync INKWELL_DATABASE_URL: postgresql+asyncpg://inkwell:CHANGE_ME@db:5432/inkwell
volumes: 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: ports:
- "5000:5000" - "5000:5000"
volumes: volumes:
thoughtsync-db: inkwell-db:
thoughtsync-data: inkwell-data:
``` ```
Then open `http://<host>:5000` and register — **the first account becomes the admin**. 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). 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. - 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` - **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev`
build) · `:<git-sha>` on `main` only (immutable, the rollback unit). There are 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 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 [docs/public-hosting.md](docs/public-hosting.md), which also lists what the app
hardens on its own and what it deliberately doesn't. 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 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 **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. 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] [alembic]
script_location = %(here)s/alembic script_location = %(here)s/alembic
prepend_sys_path = . src prepend_sys_path = . src
path_separator = os path_separator = os
# Local-dev default; overridden at runtime by THOUGHTSYNC_DATABASE_URL (see env.py). # Local-dev default; overridden at runtime by INKWELL_DATABASE_URL (see env.py).
sqlalchemy.url = postgresql+asyncpg://thoughtsync:thoughtsync@localhost:5432/thoughtsync sqlalchemy.url = postgresql+asyncpg://inkwell:inkwell@localhost:5432/inkwell
[loggers] [loggers]
+3 -3
View File
@@ -8,8 +8,8 @@ from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context from alembic import context
from thoughtsync.models import Base from inkwell.models import Base
import thoughtsync.models.all # noqa: F401 — registers every model on Base.metadata import inkwell.models.all # noqa: F401 — registers every model on Base.metadata
config = context.config config = context.config
@@ -18,7 +18,7 @@ if config.config_file_name is not None:
config.set_main_option( config.set_main_option(
"sqlalchemy.url", "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 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")
@@ -0,0 +1,38 @@
"""users: drop email_verified and avatar_path, which nothing used
Revision ID: 0037
Revises: 0036
Create Date: 2026-10-07
Both came with the foundation (0001) for features that were never built (#5178).
Nothing ever set `email_verified`, so it was false for every account on the server
while the offline desktop reported it as true; nothing read or wrote `avatar_path`.
A field whose value means nothing is worse than no field: a later feature would
trust it. If address verification is ever built, it adds its own column, with a
migration that says what the existing accounts are.
## Downgrade
Adds both columns back empty: every account unverified, no avatar. That is what
they held before.
"""
import sqlalchemy as sa
from alembic import op
revision = "0037"
down_revision = "0036"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column("users", "email_verified")
op.drop_column("users", "avatar_path")
def downgrade() -> None:
op.add_column("users", sa.Column("avatar_path", sa.Text(), nullable=True))
op.add_column(
"users",
sa.Column("email_verified", sa.Boolean(), nullable=False, server_default=sa.false()),
)
@@ -0,0 +1,41 @@
"""saved_filters: drop the table, saved views are gone
Revision ID: 0038
Revises: 0037
Create Date: 2026-10-08
Saved views were removed in #5180 (note 2897: recall first, organizing second). They
lived only on the web; the desktop kept its own set that never synced, and Android
had none. Tags in the drawer already give one-click recall.
## Downgrade
Recreates the table empty, as 0021 made it. The views that were stored are not
recoverable.
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision = "0038"
down_revision = "0037"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_index("ix_saved_filters_owner", table_name="saved_filters")
op.drop_table("saved_filters")
def downgrade() -> None:
op.create_table(
"saved_filters",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("owner_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("params", sa.Text(), nullable=False, server_default="{}"),
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_saved_filters_owner", "saved_filters", ["owner_id", "position"])
+14 -11
View File
@@ -16,7 +16,7 @@ val workspaceRoot: Directory = layout.projectDirectory.dir("../..")
val androidAbis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") 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. * `.so` into jniLibs, where AGP packages it.
* *
* `ExecOperations` injected rather than `project.exec`: the latter was REMOVED in * `ExecOperations` injected rather than `project.exec`: the latter was REMOVED in
@@ -49,7 +49,7 @@ abstract class CargoNdkBuild : DefaultTask() {
args += "-t" args += "-t"
args += abi 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 // --locked so an Android build cannot silently re-resolve the workspace
// lockfile the desktop lanes are gated on. // lockfile the desktop lanes are gated on.
args += "--locked" args += "--locked"
@@ -94,7 +94,7 @@ abstract class UniffiBindgen : DefaultTask() {
"run", "run",
"--locked", "--locked",
"-p", "-p",
"thoughtsync-uniffi-bindgen", "inkwell-uniffi-bindgen",
"--", "--",
"generate", "generate",
"--library", "--library",
@@ -141,7 +141,7 @@ val rustInputs =
* build, and neither is worth holding signed APKs up for. Scribe #2810. * build, and neither is worth holding signed APKs up for. Scribe #2810.
*/ */
val rustProfile = val rustProfile =
(project.findProperty("THOUGHTSYNC_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() } (project.findProperty("INKWELL_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() }
?: "debug" ?: "debug"
val jniLibsOut = layout.buildDirectory.dir("rustJniLibs") val jniLibsOut = layout.buildDirectory.dir("rustJniLibs")
@@ -149,7 +149,7 @@ val bindingsOut = layout.buildDirectory.dir("generated/uniffi")
val cargoNdk = val cargoNdk =
tasks.register<CargoNdkBuild>("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) rustSources.from(rustInputs)
abis.set(androidAbis) abis.set(androidAbis)
cargoProfile.set(rustProfile) cargoProfile.set(rustProfile)
@@ -163,17 +163,17 @@ val generateBindings =
dependsOn(cargoNdk) dependsOn(cargoNdk)
// arm64 is arbitrary — every ABI carries the same uniffi metadata, and // arm64 is arbitrary — every ABI carries the same uniffi metadata, and
// reading one is cheaper than reading four. // 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) workspaceDir.set(workspaceRoot)
outputDir.set(bindingsOut) outputDir.set(bindingsOut)
} }
android { android {
namespace = "com.fabledsword.thoughtsync" namespace = "com.fabledsword.inkwell"
compileSdk = 36 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "com.fabledsword.thoughtsync" applicationId = "com.fabledsword.inkwell"
// 26 (Android 8, 2017) matches Minstrel and clears the NDK's floor with // 26 (Android 8, 2017) matches Minstrel and clears the NDK's floor with
// room to spare. // room to spare.
minSdk = 26 minSdk = 26
@@ -181,9 +181,9 @@ android {
// Injected by CI from the git tag + commit count for a release; "dev" // 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. // locally so the About screen reads honestly rather than claiming 1.0.
val nameOverride = val nameOverride =
(project.findProperty("THOUGHTSYNC_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() } (project.findProperty("INKWELL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
val codeOverride = val codeOverride =
(project.findProperty("THOUGHTSYNC_VERSION_CODE") as String?)?.toIntOrNull() (project.findProperty("INKWELL_VERSION_CODE") as String?)?.toIntOrNull()
versionCode = codeOverride ?: 1 versionCode = codeOverride ?: 1
versionName = nameOverride ?: "dev" versionName = nameOverride ?: "dev"
@@ -215,7 +215,10 @@ android {
// Hardcoded, and NOT a secret: the alias is fixed for the life of // Hardcoded, and NOT a secret: the alias is fixed for the life of
// this app and is written into the certificate every install // this app and is written into the certificate every install
// already carries. Hiding it would buy nothing and stop this file // 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" keyAlias = "thoughtsync"
// PKCS12 cannot hold a key password distinct from the store // PKCS12 cannot hold a key password distinct from the store
// password — keytool refuses to set one — so this is the same // 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. # is the worst possible time to learn it.
-keep class com.sun.jna.** { *; } -keep class com.sun.jna.** { *; }
-keepclassmembers class * extends com.sun.jna.** { public *; } -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" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application <application
android:name=".ThoughtSyncApplication" android:name=".InkwellApplication"
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.ThoughtSync" android:theme="@style/Theme.Inkwell"
android:usesCleartextTraffic="true"> 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 <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.ThoughtSync"> android:theme="@style/Theme.Inkwell">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </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> </activity>
<!-- <!--
@@ -126,6 +174,22 @@
PackageInstaller. Without it a failed install would be indistinguishable PackageInstaller. Without it a failed install would be indistinguishable
from someone declining the dialog (Scribe #2438). 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 <receiver
android:name=".UpdateReceiver" android:name=".UpdateReceiver"
android:exported="false" /> android:exported="false" />
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync package com.fabledsword.inkwell
import android.content.Context import android.content.Context
import android.content.Intent 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. * [UpdateReceiver], which is why a failure can be shown rather than guessed at.
*/ */
object AppUpdate { object AppUpdate {
private const val TAG = "ThoughtSyncUpdate" private const val TAG = "InkwellUpdate"
/** This build's versionCode — what the server's is compared against. */ /** This build's versionCode — what the server's is compared against. */
fun installedVersionCode(context: Context): Long = fun installedVersionCode(context: Context): Long =
@@ -173,5 +173,5 @@ object AppUpdate {
.intentSender .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.app.Application
import android.util.Log 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. * 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 * 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. * 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 * Null only if the store could not be opened — a corrupt or unwritable
* database. The UI reports that honestly rather than crashing on first * 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 * touch, because a user whose notes won't open needs a message, not a
* stack trace. * stack trace.
*/ */
var core: ThoughtSync? = null var core: Inkwell? = null
private set private set
var openFailure: String? = null var openFailure: String? = null
@@ -30,8 +31,16 @@ class ThoughtSyncApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.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 { try {
val handle = ThoughtSync(filesDir.absolutePath) val handle = Inkwell(filesDir.absolutePath)
core = handle core = handle
Log.i(TAG, "local store ready — ${handle.summary()}") Log.i(TAG, "local store ready — ${handle.summary()}")
} catch (e: Exception) { } catch (e: Exception) {
@@ -44,6 +53,6 @@ class ThoughtSyncApplication : Application() {
} }
private companion object { 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.Manifest
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@@ -11,6 +12,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -21,21 +23,28 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.core.content.IntentCompat
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.fabledsword.thoughtsync.core.ThoughtSync import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.thoughtsync.ui.BoardScreen import com.fabledsword.inkwell.ui.AttachmentFiles
import com.fabledsword.thoughtsync.ui.BoardSync import com.fabledsword.inkwell.ui.BoardScreen
import com.fabledsword.thoughtsync.ui.BoardUpdate import com.fabledsword.inkwell.ui.BoardSync
import com.fabledsword.thoughtsync.ui.BoardViewModel import com.fabledsword.inkwell.ui.BoardUpdate
import com.fabledsword.thoughtsync.ui.ForegroundTransitions import com.fabledsword.inkwell.ui.BoardViewModel
import com.fabledsword.thoughtsync.ui.NoteEditorScreen import com.fabledsword.inkwell.ui.ForegroundTransitions
import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen import com.fabledsword.inkwell.ui.InkwellTheme
import com.fabledsword.thoughtsync.ui.SyncScreen import com.fabledsword.inkwell.ui.LocalAttachmentFiles
import com.fabledsword.thoughtsync.ui.SyncState import com.fabledsword.inkwell.ui.NoteEditorScreen
import com.fabledsword.thoughtsync.ui.SyncViewModel import com.fabledsword.inkwell.ui.ShareSheet
import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme import com.fabledsword.inkwell.ui.ShareViewModel
import com.fabledsword.thoughtsync.ui.UpdateViewModel import com.fabledsword.inkwell.ui.StoreUnavailableScreen
import com.fabledsword.thoughtsync.ui.olderThan 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.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -51,15 +60,27 @@ class MainActivity : ComponentActivity() {
*/ */
private val requestedNote = mutableStateOf<String?>(null) 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
val app = application as ThoughtSyncApplication val app = application as InkwellApplication
requestedNote.value = takeRequestedNote(intent) requestedNote.value = takeRequestedNote(intent)
shared.value = takeShared(intent)
setContent { setContent {
ThoughtSyncTheme { InkwellTheme {
val core = app.core val core = app.core
if (core == null) { if (core == null) {
// The store never opened. There is no board to show and no // 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. // than render an empty board that looks like data loss.
StoreUnavailableScreen(reason = app.openFailure) StoreUnavailableScreen(reason = app.openFailure)
} else { } 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) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
requestedNote.value = takeRequestedNote(intent) requestedNote.value = takeRequestedNote(intent)
shared.value = takeShared(intent)
} }
/** /**
@@ -92,10 +119,83 @@ class MainActivity : ComponentActivity() {
intent.removeExtra(Reminders.EXTRA_NOTE_ID) intent.removeExtra(Reminders.EXTRA_NOTE_ID)
return 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. */ /** 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. * 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 * both cover the display completely, so keeping the board's two-column grid
* measuring and recomposing underneath one would be pure waste. * 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 * 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) * state that actually matters (which note is open, whether this device is linked)
* already lives in view models. * already lives in view models.
*/ */
@Composable @Composable
private fun App( private fun App(
core: ThoughtSync, core: Inkwell,
files: AttachmentFiles,
requestedNote: MutableState<String?>, requestedNote: MutableState<String?>,
shared: MutableState<SharedIn?>,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val board: BoardViewModel = val board: BoardViewModel =
viewModel( viewModel(
factory = factory =
BoardViewModel.factory(core) { BoardViewModel.factory(core, readFile = files::read) {
// Any store write can have moved the next reminder. Called on // Any store write can have moved the next reminder. Called on
// the IO dispatcher by the view model, which is where it has to // the IO dispatcher by the view model, which is where it has to
// be — this reads every note carrying a reminder. // 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) ReminderAlarms(core)
// A pull can rewrite every note the board is holding, so a sync that changed // 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. // 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 // opens the editor on an unsaved draft, so writing a note and editing one are the
// same surface with the same toolbar. // same surface with the same toolbar.
var showingSync by rememberSaveable { mutableStateOf(false) } 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 update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context))
val settings = remember(context) { SyncSettings(context) } val settings = remember(context) { SyncSettings(context) }
@@ -161,6 +284,9 @@ private fun App(
val screen = val screen =
when { when {
showingSync -> Screen.SYNC 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 editing != null -> Screen.EDITOR
else -> Screen.BOARD else -> Screen.BOARD
} }
@@ -188,6 +314,18 @@ private fun App(
onInstallOutcome = update::consumeInstallOutcome, 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 -> Screen.EDITOR ->
NoteEditorScreen( NoteEditorScreen(
// Non-null by construction: `screen` is EDITOR only when it is. // Non-null by construction: `screen` is EDITOR only when it is.
@@ -196,6 +334,7 @@ private fun App(
labels = board.state.labels, labels = board.state.labels,
saving = board.state.saving, saving = board.state.saving,
error = board.state.error, error = board.state.error,
onShare = { share.open(editing.id) },
// The one seam between the editor and the store. Exhaustive at the // The one seam between the editor and the store. Exhaustive at the
// other end, so a new action cannot be added without being handled. // other end, so a new action cannot be added without being handled.
onAction = { board.onEditorAction(editing, it) }, onAction = { board.onEditorAction(editing, it) },
@@ -218,7 +357,10 @@ private fun App(
onDismissError = sync::dismissSyncError, onDismissError = sync::dismissSyncError,
), ),
onOpenSync = { showingSync = true }, onOpenSync = { showingSync = true },
onManageTags = { showingTags = true },
onSearch = board::search, onSearch = board::search,
onFilter = board::filter,
onReorder = board::reorder,
onCompose = board::compose, onCompose = board::compose,
onToggleItem = board::toggleItem, onToggleItem = board::toggleItem,
// The SAME seam the editor uses. `onEditorAction` is already the // The SAME seam the editor uses. `onEditorAction` is already the
@@ -244,9 +386,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 // 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. // editor keeps its own, because it has to save the open note before leaving.
BackHandler(enabled = showingSync) { showingSync = false } BackHandler(enabled = showingSync) { showingSync = false }
BackHandler(enabled = showingTags) { showingTags = false }
} }
/** /**
@@ -263,7 +416,7 @@ private fun App(
* notifications this app would send — is spending it on nothing. * notifications this app would send — is spending it on nothing.
*/ */
@Composable @Composable
private fun ReminderAlarms(core: ThoughtSync) { private fun ReminderAlarms(core: Inkwell) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Off the main thread: this reads every note that has a reminder, and a phone // 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.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
@@ -8,7 +8,7 @@ import android.content.Intent
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat 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. * What a due reminder looks like in the shade.
@@ -117,5 +117,5 @@ internal object ReminderNotification {
) )
private const val CHANNEL = "reminders" 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.BroadcastReceiver
import android.content.Context import android.content.Context
@@ -36,7 +36,7 @@ class ReminderReceiver : BroadcastReceiver() {
context: Context, context: Context,
intent: Intent, intent: Intent,
) { ) {
val core = (context.applicationContext as? ThoughtSyncApplication)?.core ?: return val core = (context.applicationContext as? InkwellApplication)?.core ?: return
val action = intent.action val action = intent.action
val noteId = intent.getStringExtra(Reminders.EXTRA_NOTE_ID) val noteId = intent.getStringExtra(Reminders.EXTRA_NOTE_ID)
val app = context.applicationContext val app = context.applicationContext
@@ -64,9 +64,9 @@ class ReminderReceiver : BroadcastReceiver() {
} }
companion object { companion object {
const val ACTION_DUE = "com.fabledsword.thoughtsync.REMINDER_DUE" const val ACTION_DUE = "com.fabledsword.inkwell.REMINDER_DUE"
const val ACTION_DONE = "com.fabledsword.thoughtsync.REMINDER_DONE" const val ACTION_DONE = "com.fabledsword.inkwell.REMINDER_DONE"
const val ACTION_SNOOZE = "com.fabledsword.thoughtsync.REMINDER_SNOOZE" const val ACTION_SNOOZE = "com.fabledsword.inkwell.REMINDER_SNOOZE"
private const val TAG = "ThoughtSyncReminders" private const val TAG = "InkwellReminders"
} }
} }
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync package com.fabledsword.inkwell
import android.app.AlarmManager import android.app.AlarmManager
import android.app.PendingIntent import android.app.PendingIntent
@@ -7,8 +7,8 @@ import android.content.Intent
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import com.fabledsword.thoughtsync.core.Note import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.thoughtsync.core.ThoughtSync import com.fabledsword.inkwell.core.Note
import java.time.OffsetDateTime import java.time.OffsetDateTime
/** /**
@@ -52,7 +52,7 @@ object Reminders {
private const val MISSED_WINDOW_MS = 24L * 60 * 60 * 1000 private const val MISSED_WINDOW_MS = 24L * 60 * 60 * 1000
private const val SNOOZE_MINUTES = 60L 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. * Announce what is due, then schedule the next one.
@@ -63,7 +63,7 @@ object Reminders {
*/ */
fun refresh( fun refresh(
context: Context, context: Context,
core: ThoughtSync, core: Inkwell,
) { ) {
ReminderNotification.ensureChannel(context) ReminderNotification.ensureChannel(context)
val notes = val notes =
@@ -111,7 +111,7 @@ object Reminders {
*/ */
fun promptToNotifyDue( fun promptToNotifyDue(
context: Context, context: Context,
core: ThoughtSync, core: Inkwell,
): Boolean = ): Boolean =
!Announced(context).askedToNotify && !Announced(context).askedToNotify &&
!NotificationManagerCompat.from(context).areNotificationsEnabled() && !NotificationManagerCompat.from(context).areNotificationsEnabled() &&
@@ -123,7 +123,7 @@ object Reminders {
/** Clear the reminder, as the notification's Done action. */ /** Clear the reminder, as the notification's Done action. */
fun complete( fun complete(
context: Context, context: Context,
core: ThoughtSync, core: Inkwell,
noteId: String, noteId: String,
) { ) {
runCatching { core.completeReminder(noteId) } runCatching { core.completeReminder(noteId) }
@@ -134,7 +134,7 @@ object Reminders {
/** Push the reminder an hour out, as the notification's Snooze action. */ /** Push the reminder an hour out, as the notification's Snooze action. */
fun snooze( fun snooze(
context: Context, context: Context,
core: ThoughtSync, core: Inkwell,
noteId: String, noteId: String,
) { ) {
runCatching { core.snoozeReminder(noteId, SNOOZE_MINUTES) } runCatching { core.snoozeReminder(noteId, SNOOZE_MINUTES) }
@@ -237,7 +237,7 @@ private class Announced(
fun markAsked() = prefs.edit().putBoolean(KEY_ASKED, true).apply() fun markAsked() = prefs.edit().putBoolean(KEY_ASKED, true).apply()
private companion object { private companion object {
const val FILE = "thoughtsync-reminders" const val FILE = "inkwell-reminders"
const val KEY_SEEN = "announced" const val KEY_SEEN = "announced"
const val KEY_PRIMED = "primed" const val KEY_PRIMED = "primed"
const val KEY_ASKED = "asked_to_notify" const val KEY_ASKED = "asked_to_notify"
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync package com.fabledsword.inkwell
import android.content.Context import android.content.Context
import androidx.work.BackoffPolicy import androidx.work.BackoffPolicy
@@ -85,8 +85,8 @@ object SyncSchedule {
.setRequiredNetworkType(NetworkType.CONNECTED) .setRequiredNetworkType(NetworkType.CONNECTED)
.build() .build()
private const val PERIODIC = "thoughtsync-periodic-sync" private const val PERIODIC = "inkwell-periodic-sync"
private const val PUSH = "thoughtsync-push-pending" private const val PUSH = "inkwell-push-pending"
/** WorkManager's own minimum for periodic work. Asking for less gets this. */ /** WorkManager's own minimum for periodic work. Asking for less gets this. */
private const val PERIOD_MINUTES = 15L private const val PERIOD_MINUTES = 15L
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync package com.fabledsword.inkwell
import android.content.Context import android.content.Context
@@ -38,7 +38,7 @@ class SyncSettings(
} }
private companion object { private companion object {
const val FILE = "thoughtsync-sync" const val FILE = "inkwell-sync"
const val KEY_AUTOMATIC = "automatic" const val KEY_AUTOMATIC = "automatic"
} }
} }
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync package com.fabledsword.inkwell
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
@@ -8,7 +8,7 @@ import androidx.work.WorkerParameters
/** /**
* One sync cycle, run by the system rather than by a person. * 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 * 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. * the UI uses, so there is never a second SQLite connection racing the first.
* *
@@ -30,7 +30,7 @@ class SyncWorker(
params: WorkerParameters, params: WorkerParameters,
) : CoroutineWorker(context, params) { ) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result { 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 // Both of these are "nothing to do", not "something went wrong", so both
// report success and let the run retire quietly: // report success and let the run retire quietly:
@@ -67,6 +67,6 @@ class SyncWorker(
} }
private companion object { 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.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync package com.fabledsword.inkwell
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
@@ -71,7 +71,7 @@ class UpdateReceiver : BroadcastReceiver() {
} }
companion object { companion object {
const val ACTION_INSTALLED = "com.fabledsword.thoughtsync.UPDATE_INSTALLED" const val ACTION_INSTALLED = "com.fabledsword.inkwell.UPDATE_INSTALLED"
private const val TAG = "ThoughtSyncUpdate" 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.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement 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.input.TextFieldValue
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp 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. * The note's body, as fields and checkboxes rather than as markup.
@@ -0,0 +1,190 @@
package com.fabledsword.inkwell.ui
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerId
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import com.fabledsword.inkwell.core.Note
// Reordering the board by dragging a card, as on the web (#5176): hold a card, then
// move it. The hold is the same long press that opens the card's menu, so the menu
// appears first and closes as the card starts to move. Lifting without moving
// leaves the menu open, as before. Moving BEFORE the hold completes is a scroll.
//
// Watched on the Initial pass, ahead of the grid's scrolling and the card's own
// click handling. Nothing is consumed until the hold has completed and the finger
// has moved, so taps, long presses and scrolls all reach the grid and the card
// untouched. After that point every change is consumed, so the grid cannot scroll
// under a card that is being carried.
/** A card being carried. Every offset is in root coordinates. */
internal data class CardDrag(
val id: String,
/** Where the finger went down. */
val from: Offset,
/** Where the finger is now. */
val at: Offset,
/** Where the card's corner was when the drag began. */
val startTopLeft: Offset,
) {
/** Where the card's corner should be drawn, so it stays under the finger. */
val topLeft: Offset get() = startTopLeft + (at - from)
}
/**
* Where each card on screen was last laid out, in root coordinates.
*
* A plain map rather than snapshot state: only the gesture reads it, so a write on
* every layout pass must not invalidate any composition.
*/
internal class CardBounds {
private val bounds = HashMap<String, Rect>()
fun record(
id: String,
rect: Rect,
) {
bounds[id] = rect
}
fun forget(id: String) {
bounds.remove(id)
}
operator fun get(id: String): Rect? = bounds[id]
/** The card under [point], other than [except]. */
fun idAt(
point: Offset,
except: String? = null,
): String? = bounds.entries.firstOrNull { (id, rect) -> id != except && rect.contains(point) }?.key
}
/** What the board does as a card is carried. Every offset is in root coordinates. */
internal class CardCarrier(
val onStart: (id: String, at: Offset) -> Unit,
val onMove: (at: Offset) -> Unit,
/** Runs however the gesture finishes, cancelled included, so no card is left held. */
val onEnd: () -> Unit,
)
/**
* Carry a card: hold, then move.
*
* [toRoot] maps a position in this element to root coordinates, which is where
* [bounds] keeps the cards.
*/
internal fun Modifier.dragToReorder(
enabled: Boolean,
bounds: CardBounds,
toRoot: (Offset) -> Offset,
carrier: CardCarrier,
): Modifier =
if (enabled) {
pointerInput(bounds) { awaitEachGesture { carry(bounds, toRoot, carrier) } }
} else {
this
}
private suspend fun AwaitPointerEventScope.carry(
bounds: CardBounds,
toRoot: (Offset) -> Offset,
carrier: CardCarrier,
) {
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
val id = bounds.idAt(toRoot(down.position))
if (id == null || !holdsStill(down) || !movesAfterHold(down)) return
carrier.onStart(id, toRoot(down.position))
try {
followUntilUp(down.id) { carrier.onMove(toRoot(it)) }
} finally {
carrier.onEnd()
}
}
/** True when the finger stayed down and inside the touch slop for a long press. */
private suspend fun AwaitPointerEventScope.holdsStill(down: PointerInputChange): Boolean {
val endedEarly =
withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
var ended = false
while (!ended) {
val change = awaitPointerEvent(PointerEventPass.Initial).changes.firstOrNull { it.id == down.id }
ended = change == null || !change.pressed || change.movedPast(down, viewConfiguration.touchSlop)
}
}
return endedEarly == null
}
/** After the hold: true once the finger moves past the slop, false if it lifts first. */
private suspend fun AwaitPointerEventScope.movesAfterHold(down: PointerInputChange): Boolean {
var moved: Boolean? = null
while (moved == null) {
val change = awaitPointerEvent(PointerEventPass.Initial).changes.firstOrNull { it.id == down.id }
if (change == null || !change.pressed) {
moved = false
} else if (change.movedPast(down, viewConfiguration.touchSlop)) {
// Consumed so the grid's scrolling, which has not claimed this gesture
// yet, never does.
change.consume()
moved = true
}
}
return moved == true
}
/** Report every position until the finger lifts, consuming each change. */
private suspend fun AwaitPointerEventScope.followUntilUp(
pointer: PointerId,
onMove: (Offset) -> Unit,
) {
var pressed = true
while (pressed) {
val change = awaitPointerEvent(PointerEventPass.Initial).changes.firstOrNull { it.id == pointer }
pressed = change?.pressed == true
change?.consume()
if (change != null && pressed) onMove(change.position)
}
}
private fun PointerInputChange.movedPast(
down: PointerInputChange,
slop: Float,
): Boolean = (position - down.position).getDistance() > slop
/** The grid's own layout, so a position in it can be taken to root coordinates. */
internal class GridOrigin {
var coords: LayoutCoordinates? = null
}
/**
* [order] with the carried card moved to the place of the card under the finger, the
* way the web's drop splices it. Unchanged when there is no card there, or when it is
* on the other side of the pinned line: the store sorts pinned notes first, so a
* card dropped across the line would only snap back.
*/
internal fun moved(
order: List<String>?,
drag: CardDrag,
bounds: CardBounds,
notes: List<Note>,
): List<String>? {
val target = bounds.idAt(drag.at, except = drag.id)
val pinned = notes.associate { it.id to it.pinned }
val from = order?.indexOf(drag.id) ?: -1
val to = if (target != null && pinned[target] == pinned[drag.id]) order?.indexOf(target) ?: -1 else -1
return if (order == null || from < 0 || to < 0) {
order
} else {
order.toMutableList().apply {
removeAt(from)
add(to, drag.id)
}
}
}
@@ -0,0 +1,172 @@
package com.fabledsword.inkwell.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.Label
import com.fabledsword.inkwell.core.NoteFacets
/**
* The board's filters: the same three the web's Filters panel offers (FilterBar.vue,
* trimmed to these in #5180). Tags a note must ALL carry, has an attachment, and
* shared with me. Reminders and dates have their own lenses, here as on the web.
*
* Only the main board filters, as on the web. Opening Archive, Trash or a tag from
* the drawer starts that view unfiltered, and search runs over everything.
*/
data class BoardFilters(
val labelIds: Set<String> = emptySet(),
val hasAttachment: Boolean = false,
val sharedWithMe: Boolean = false,
) {
/** How many are on, counted the way the web's badge counts them: each tag is one. */
val count: Int
get() = labelIds.size + (if (hasAttachment) 1 else 0) + (if (sharedWithMe) 1 else 0)
/** The core's facets for these, or null when none are on. */
fun facets(): NoteFacets? =
if (count == 0) {
null
} else {
NoteFacets(
q = null,
label = labelIds.toList().ifEmpty { null },
hasAttachment = if (hasAttachment) true else null,
createdAfter = null,
createdBefore = null,
shared = if (sharedWithMe) SHARED_WITH_ME else null,
)
}
fun toggleLabel(id: String) = copy(labelIds = if (id in labelIds) labelIds - id else labelIds + id)
}
/** The facet value the core reads as "only notes someone else shared with me". */
private const val SHARED_WITH_ME = "with_me"
/**
* The row under the search bar: a Filters chip that opens the sheet, showing how many
* are on, and a Clear beside it once any are.
*/
@Composable
internal fun FilterRow(
filters: BoardFilters,
onOpen: () -> Unit,
onClear: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = GUTTER),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = filters.count > 0,
onClick = onOpen,
label = {
Text(
if (filters.count > 0) {
stringResource(R.string.filters_count, filters.count)
} else {
stringResource(R.string.filters)
},
)
},
)
if (filters.count > 0) {
TextButton(onClick = onClear) { Text(stringResource(R.string.filters_clear)) }
}
}
}
/**
* Pick the filters. Each tap applies at once, as on the web, so the board behind the
* sheet is already showing the answer when it is dismissed.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
internal fun FilterSheet(
filters: BoardFilters,
labels: List<Label>,
onChange: (BoardFilters) -> Unit,
onDismiss: () -> Unit,
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.navigationBarsPadding()
.verticalScroll(rememberScrollState()),
) {
SheetTitle(R.string.filters)
FilterHeading(R.string.filters_show)
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
FilterChip(
selected = filters.hasAttachment,
onClick = { onChange(filters.copy(hasAttachment = !filters.hasAttachment)) },
label = { Text(stringResource(R.string.filters_has_attachment)) },
)
FilterChip(
selected = filters.sharedWithMe,
onClick = { onChange(filters.copy(sharedWithMe = !filters.sharedWithMe)) },
label = { Text(stringResource(R.string.filters_shared_with_me)) },
)
}
// No heading and no empty row when there are no tags yet: a section that
// can only ever say "none" is noise in a sheet this small.
if (labels.isNotEmpty()) {
FilterHeading(R.string.filters_tags)
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
labels.forEach { label ->
FilterChip(
selected = label.id in filters.labelIds,
onClick = { onChange(filters.toggleLabel(label.id)) },
label = { Text(label.name) },
)
}
}
}
if (filters.count > 0) {
TextButton(
onClick = { onChange(BoardFilters()) },
modifier = Modifier.align(Alignment.End),
) {
Text(stringResource(R.string.filters_clear))
}
}
}
}
}
@Composable
private fun FilterHeading(labelRes: Int) {
Text(
text = stringResource(labelRes),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp, bottom = 2.dp),
)
}
@@ -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.Arrangement
import androidx.compose.foundation.layout.Box 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.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close 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.Menu
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -52,19 +53,28 @@ import androidx.compose.material3.pulltorefresh.pullToRefresh
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R import androidx.compose.ui.zIndex
import com.fabledsword.thoughtsync.core.Label import com.fabledsword.inkwell.R
import com.fabledsword.thoughtsync.core.Note import com.fabledsword.inkwell.core.Label
import com.fabledsword.inkwell.core.Note
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
@@ -74,7 +84,10 @@ fun BoardScreen(
onOpenNote: (Note) -> Unit, onOpenNote: (Note) -> Unit,
sync: BoardSync, sync: BoardSync,
onOpenSync: () -> Unit, onOpenSync: () -> Unit,
onManageTags: () -> Unit,
onSearch: (String) -> Unit, onSearch: (String) -> Unit,
onFilter: (BoardFilters) -> Unit,
onReorder: (List<String>) -> Unit,
onCompose: () -> Unit, onCompose: () -> Unit,
onToggleItem: (Note, Int, Boolean) -> Unit, onToggleItem: (Note, Int, Boolean) -> Unit,
onNoteAction: (Note, EditorAction) -> Unit, onNoteAction: (Note, EditorAction) -> Unit,
@@ -89,6 +102,7 @@ fun BoardScreen(
// the moment it scrolls out of view, which would take its dialog down with it — // the moment it scrolls out of view, which would take its dialog down with it —
// and the board can scroll under an open dialog. // and the board can scroll under an open dialog.
var confirmingDelete by remember { mutableStateOf<Note?>(null) } var confirmingDelete by remember { mutableStateOf<Note?>(null) }
var filtering by remember { mutableStateOf(false) }
// Resolved in composition, not inside the coroutine: `stringResource` is a // Resolved in composition, not inside the coroutine: `stringResource` is a
// composable read and cannot be called from a suspend block. // composable read and cannot be called from a suspend block.
@@ -138,6 +152,10 @@ fun BoardScreen(
onOpenSync() onOpenSync()
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
}, },
onManageTags = {
onManageTags()
scope.launch { drawerState.close() }
},
) )
}, },
) { ) {
@@ -177,6 +195,14 @@ fun BoardScreen(
onMenu = { scope.launch { drawerState.open() } }, onMenu = { scope.launch { drawerState.open() } },
) )
if (state.filterable) {
FilterRow(
filters = state.filters,
onOpen = { filtering = true },
onClear = { onFilter(BoardFilters()) },
)
}
state.error?.let { message -> state.error?.let { message ->
ErrorBanner(message = message, onDismiss = onDismissError) ErrorBanner(message = message, onDismiss = onDismissError)
} }
@@ -228,6 +254,8 @@ fun BoardScreen(
onToggleItem = onToggleItem, onToggleItem = onToggleItem,
onNoteAction = onCardAction, onNoteAction = onCardAction,
onConfirmDelete = { confirmingDelete = it }, onConfirmDelete = { confirmingDelete = it },
reorderable = state.reorderable,
onReorder = onReorder,
) )
} }
// `PullToRefreshBox` would be less code, but it takes no // `PullToRefreshBox` would be less code, but it takes no
@@ -242,6 +270,15 @@ fun BoardScreen(
} }
} }
if (filtering) {
FilterSheet(
filters = state.filters,
labels = state.labels,
onChange = onFilter,
onDismiss = { filtering = false },
)
}
confirmingDelete?.let { note -> confirmingDelete?.let { note ->
ConfirmDeleteDialog( ConfirmDeleteDialog(
onConfirm = { onConfirm = {
@@ -367,6 +404,7 @@ private fun NavigationDrawer(
syncSummary: String?, syncSummary: String?,
onOpen: (Destination) -> Unit, onOpen: (Destination) -> Unit,
onOpenSync: () -> Unit, onOpenSync: () -> Unit,
onManageTags: () -> Unit,
) { ) {
ModalDrawerSheet { ModalDrawerSheet {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) { Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
@@ -380,18 +418,36 @@ private fun NavigationDrawer(
DrawerRow(destination, current, onOpen) DrawerRow(destination, current, onOpen)
} }
if (labels.isNotEmpty()) { // 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)) 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(
text = stringResource(R.string.nav_labels), text = stringResource(R.string.nav_labels),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, bottom = 4.dp), modifier = Modifier.weight(1f),
) )
IconButton(onClick = onManageTags) {
Icon(
Icons.Filled.Edit,
contentDescription = stringResource(R.string.tags_manage),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
labels.forEach { label -> labels.forEach { label ->
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen) DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
} }
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
listOf(Destination.Archive, Destination.Trash).forEach { destination -> listOf(Destination.Archive, Destination.Trash).forEach { destination ->
@@ -429,7 +485,7 @@ private fun DrawerRow(
} }
/** /**
* The board: a two-column masonry, matching the web and desktop. * The board: a masonry of two to four columns, by window width, matching the web.
* *
* Staggered rather than a uniform grid because notes are wildly different heights * Staggered rather than a uniform grid because notes are wildly different heights
* — a one-line thought beside a twelve-item checklist — and forcing them to a * — a one-line thought beside a twelve-item checklist — and forcing them to a
@@ -443,10 +499,66 @@ private fun NoteBoard(
onToggleItem: (Note, Int, Boolean) -> Unit, onToggleItem: (Note, Int, Boolean) -> Unit,
onNoteAction: (Note, EditorAction) -> Unit, onNoteAction: (Note, EditorAction) -> Unit,
onConfirmDelete: (Note) -> Unit, onConfirmDelete: (Note) -> Unit,
/** Whether cards can be dragged into a new order: the plain main board only. */
reorderable: Boolean,
onReorder: (List<String>) -> Unit,
) { ) {
// The web's breakpoints (NoteGrid.vue: three columns from `lg`, 1024px, four from
// `xl`, 1280px), on the window's width, so a tablet lays out like a browser of the
// same size. Below that it stays at two, where the web drops to one on a phone.
val windowWidth = LocalConfiguration.current.screenWidthDp
val columns =
when {
windowWidth >= 1280 -> 4
windowWidth >= 1024 -> 3
else -> 2
}
// The drag (BoardDrag.kt). `order` is the board as the drag has rearranged it, and
// it stays until the store's next list arrives, so a drop does not flash back to
// the old order while the write is in flight.
val bounds = remember { CardBounds() }
val grid = remember { GridOrigin() }
var drag by remember { mutableStateOf<CardDrag?>(null) }
var order by remember { mutableStateOf<List<String>?>(null) }
val current by rememberUpdatedState(notes)
val reorder by rememberUpdatedState(onReorder)
LaunchedEffect(notes) { order = null }
// Which card is carried, read by every item; where it is, read only when its layer
// is drawn. A move then redraws one card instead of recomposing them all.
val carriedId by remember { derivedStateOf { drag?.id } }
val byId = notes.associateBy { it.id }
val shown = order?.mapNotNull { byId[it] } ?: notes
LazyVerticalStaggeredGrid( LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(BOARD_COLUMNS), columns = StaggeredGridCells.Fixed(columns),
modifier = Modifier.fillMaxSize(), modifier =
Modifier
.fillMaxSize()
.onGloballyPositioned { grid.coords = it }
.dragToReorder(
enabled = reorderable,
bounds = bounds,
toRoot = { grid.coords?.localToRoot(it) ?: it },
carrier =
CardCarrier(
onStart = { id, at ->
val corner = bounds[id]?.topLeft ?: at
drag = CardDrag(id = id, from = at, at = at, startTopLeft = corner)
order = current.map { it.id }
},
onMove = { at ->
drag = drag?.copy(at = at)
order = drag?.let { moved(order, it, bounds, current) } ?: order
},
onEnd = {
val final = order
drag = null
if (final != null && final != current.map { it.id }) reorder(final)
},
),
),
// Bottom padding clears the FAB, so the last note is never trapped under it. // Bottom padding clears the FAB, so the last note is never trapped under it.
contentPadding = PaddingValues(start = GUTTER, end = GUTTER, top = 4.dp, bottom = 88.dp), contentPadding = PaddingValues(start = GUTTER, end = GUTTER, top = 4.dp, bottom = 88.dp),
verticalItemSpacing = 8.dp, verticalItemSpacing = 8.dp,
@@ -455,15 +567,39 @@ private fun NoteBoard(
// Keyed by id so Compose reuses cards across a refresh rather than // Keyed by id so Compose reuses cards across a refresh rather than
// rebuilding them — and so a newly captured note slides in instead of // rebuilding them — and so a newly captured note slides in instead of
// making every card below it flicker. // making every card below it flicker.
items(items = notes, key = { it.id }) { note -> items(items = shown, key = { it.id }) { note ->
val carried = carriedId == note.id
// Bounds recorded BEFORE the carried card's translation in the chain, so
// they are where the layout put it, not where the finger has taken it.
val follow =
if (carried) {
Modifier.zIndex(1f).graphicsLayer {
drag?.let { d ->
val laid = bounds[note.id]?.topLeft ?: d.startTopLeft
translationX = d.topLeft.x - laid.x
translationY = d.topLeft.y - laid.y
}
}
} else {
Modifier.animateItem()
}
Box(
modifier =
Modifier
.onGloballyPositioned { bounds.record(note.id, it.boundsInRoot()) }
.then(follow),
) {
NoteCard( NoteCard(
note = note, note = note,
onOpen = { onOpenNote(note) }, onOpen = { onOpenNote(note) },
onToggleItem = { index, checked -> onToggleItem(note, index, checked) }, onToggleItem = { index, checked -> onToggleItem(note, index, checked) },
onAction = { onNoteAction(note, it) }, onAction = { onNoteAction(note, it) },
onConfirmDelete = { onConfirmDelete(note) }, onConfirmDelete = { onConfirmDelete(note) },
dragging = carried,
) )
} }
DisposableEffect(note.id) { onDispose { bounds.forget(note.id) } }
}
} }
} }
@@ -481,6 +617,8 @@ private fun EmptyBoard(state: BoardState) {
state.searching -> state.searching ->
stringResource(R.string.empty_search_title) to stringResource(R.string.empty_search_title) to
stringResource(R.string.empty_search_body, state.query) stringResource(R.string.empty_search_body, state.query)
state.destination == Destination.Notes && state.filters.count > 0 ->
stringResource(R.string.empty_filtered_title) to stringResource(R.string.empty_filtered_body)
state.destination == Destination.Trash -> state.destination == Destination.Trash ->
stringResource(R.string.empty_trash_title) to stringResource(R.string.empty_trash_body) stringResource(R.string.empty_trash_title) to stringResource(R.string.empty_trash_body)
state.destination == Destination.Archive -> state.destination == Destination.Archive ->
@@ -552,8 +690,6 @@ fun StoreUnavailableScreen(reason: String?) {
} }
} }
private const val BOARD_COLUMNS = 2
// Not private: the reminder notice is board content and has to line up with the // Not private: the reminder notice is board content and has to line up with the
// search bar and the cards, so it shares the board's gutter rather than guessing. // search bar and the cards, so it shares the board's gutter rather than guessing.
internal val GUTTER = 12.dp internal val GUTTER = 12.dp
@@ -1,17 +1,19 @@
package com.fabledsword.thoughtsync.ui package com.fabledsword.inkwell.ui
import android.net.Uri
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.thoughtsync.core.Label import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.thoughtsync.core.Note import com.fabledsword.inkwell.core.Label
import com.fabledsword.thoughtsync.core.NoteDraft import com.fabledsword.inkwell.core.Note
import com.fabledsword.thoughtsync.core.NoteEdit import com.fabledsword.inkwell.core.NoteDraft
import com.fabledsword.thoughtsync.core.NoteQuery import com.fabledsword.inkwell.core.NoteEdit
import com.fabledsword.thoughtsync.core.ThoughtSync import com.fabledsword.inkwell.core.NoteFacets
import com.fabledsword.inkwell.core.NoteQuery
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -56,6 +58,8 @@ data class BoardState(
val notes: List<Note> = emptyList(), val notes: List<Note> = emptyList(),
val labels: List<Label> = emptyList(), val labels: List<Label> = emptyList(),
val query: String = "", val query: String = "",
/** The main board's filters. Only [Destination.Notes] applies them. */
val filters: BoardFilters = BoardFilters(),
val loading: Boolean = true, val loading: Boolean = true,
val saving: Boolean = false, val saving: Boolean = false,
val error: String? = null, val error: String? = null,
@@ -81,6 +85,18 @@ data class BoardState(
) { ) {
/** Search overrides the destination while there is a query to run. */ /** Search overrides the destination while there is a query to run. */
val searching: Boolean get() = query.isNotBlank() val searching: Boolean get() = query.isNotBlank()
/**
* The main board only, and not while searching, as on the web: a search spans
* everything, so a filter over its hits would be a control that does nothing.
*/
val filterable: Boolean get() = destination == Destination.Notes && !searching
/**
* The whole board in one order, as on the web: not a search, another view, or a
* filtered subset, whose order would be renumbered against notes it can't see.
*/
val reorderable: Boolean get() = filterable && filters.count == 0
} }
/** /**
@@ -99,7 +115,7 @@ data class BoardState(
*/ */
@Suppress("TooManyFunctions") @Suppress("TooManyFunctions")
class BoardViewModel( class BoardViewModel(
private val core: ThoughtSync, private val core: Inkwell,
/** /**
* Called after any write that could have moved a reminder. * Called after any write that could have moved a reminder.
* *
@@ -110,6 +126,11 @@ class BoardViewModel(
* view model's `onStoreChanged`. * view model's `onStoreChanged`.
*/ */
private val onRemindersChanged: () -> Unit = {}, 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() { ) : ViewModel() {
var state by mutableStateOf(BoardState()) var state by mutableStateOf(BoardState())
private set private set
@@ -123,7 +144,7 @@ class BoardViewModel(
init { init {
refresh() refresh()
loadLabels() refreshLabels()
} }
fun open(destination: Destination) { fun open(destination: Destination) {
@@ -131,7 +152,13 @@ class BoardViewModel(
// running should show the archive, not search results filtered by a box // running should show the archive, not search results filtered by a box
// the user has visually moved on from. // the user has visually moved on from.
searchJob?.cancel() searchJob?.cancel()
state = state.copy(destination = destination, query = "") state = state.copy(destination = destination, query = "", filters = BoardFilters())
refresh()
}
/** Apply the main board's filters. */
fun filter(filters: BoardFilters) {
state = state.copy(filters = filters)
refresh() refresh()
} }
@@ -140,7 +167,9 @@ class BoardViewModel(
state = state.copy(loading = true) state = state.copy(loading = true)
state = state =
try { try {
val notes = withContext(Dispatchers.IO) { load(state.destination) } val destination = state.destination
val filters = state.filters
val notes = withContext(Dispatchers.IO) { load(destination, filters) }
state.copy(notes = notes, loading = false, error = null) state.copy(notes = notes, loading = false, error = null)
} catch (e: Exception) { } catch (e: Exception) {
// Broad by intent: the board must render something for any // Broad by intent: the board must render something for any
@@ -151,9 +180,12 @@ class BoardViewModel(
} }
} }
private fun load(destination: Destination): List<Note> = private fun load(
destination: Destination,
filters: BoardFilters,
): List<Note> =
when (destination) { when (destination) {
Destination.Notes -> core.listNotes(query(VIEW_NOTES)) Destination.Notes -> core.listNotes(query(VIEW_NOTES, facets = filters.facets()))
Destination.Archive -> core.listNotes(query(VIEW_ARCHIVE)) Destination.Archive -> core.listNotes(query(VIEW_ARCHIVE))
Destination.Trash -> core.listNotes(query(VIEW_TRASH)) Destination.Trash -> core.listNotes(query(VIEW_TRASH))
// Not a board view: the core models reminders as its own query, since // Not a board view: the core models reminders as its own query, since
@@ -162,13 +194,38 @@ class BoardViewModel(
is Destination.WithLabel -> core.listNotes(query(VIEW_NOTES, labelId = destination.id)) 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 { viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { core.listLabels() } } runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
.onSuccess { state = state.copy(labels = it) } .onSuccess { labels ->
state = state.copy(labels = labels)
// A deleted tag is dropped from the filters as it is from the lens
// below: a filter on an id nothing carries matches nothing forever.
val kept = state.filters.labelIds intersect labels.map { it.id }.toSet()
if (kept != state.filters.labelIds) filter(state.filters.copy(labelIds = kept))
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 // A drawer that cannot list labels is a degraded drawer, not a
// broken board — the notes are still there. Failing quietly here // 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()) } .onFailure { state = state.copy(labels = emptyList()) }
} }
} }
@@ -235,6 +292,39 @@ class BoardViewModel(
state = state.copy(editing = blankDraft(), editingSession = state.editingSession + 1) 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 * 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 — * it. The editor flushes its text and then closes, and the flush is a coroutine —
@@ -263,6 +353,10 @@ class BoardViewModel(
} }
EditorAction.DismissError -> dismissError() EditorAction.DismissError -> dismissError()
is EditorAction.SaveText -> createFromDraft(action.body) 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 // 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 // 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. // anyone reaches the toolbar; before that there is nothing to attribute.
@@ -327,7 +421,7 @@ class BoardViewModel(
* mutation that lands between a tap and its dispatch cannot redirect the * mutation that lands between a tap and its dispatch cannot redirect the
* action at a different note. * 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 * 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 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 * it, and each of those would need an `else` — which throws away precisely the
@@ -387,7 +481,7 @@ class BoardViewModel(
} }
// The drawer lists labels with their note counts, and both // The drawer lists labels with their note counts, and both
// just changed. // just changed.
loadLabels() refreshLabels()
} }
is EditorAction.SetReminder -> edit(id, NoteEdit.RemindAt(action.at)) is EditorAction.SetReminder -> edit(id, NoteEdit.RemindAt(action.at))
@@ -399,6 +493,35 @@ class BoardViewModel(
id, id,
action.rule?.let { NoteEdit.Recurrence(it) } ?: NoteEdit.ClearRecurrence, 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 +548,20 @@ class BoardViewModel(
* correct-until-a-moment-ago content, and flashing it empty would be a worse * correct-until-a-moment-ago content, and flashing it empty would be a worse
* lie than showing it one frame stale. * lie than showing it one frame stale.
* *
* Search results are left alone — they are the answer to a query, not a live * While a search is running the QUERY is re-run rather than the board's
* view, and re-running the board query underneath them would replace the hits * destination — running `load` here would replace the hits with the whole
* with the whole board. * 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( private fun mutate(
closeEditor: Boolean = false, 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 { viewModelScope.launch {
state = state.copy(saving = true) state = state.copy(saving = true)
@@ -439,10 +569,12 @@ class BoardViewModel(
try { try {
val updated = withContext(Dispatchers.IO) { block(core) } val updated = withContext(Dispatchers.IO) { block(core) }
val notes = val notes =
withContext(Dispatchers.IO) {
if (state.searching) { if (state.searching) {
state.notes core.searchNotes(state.query)
} else { } else {
withContext(Dispatchers.IO) { load(state.destination) } load(state.destination, state.filters)
}
} }
// On IO, not here: re-deriving the alarm reads every note // On IO, not here: re-deriving the alarm reads every note
// that carries a reminder, and this line runs on the main // that carries a reminder, and this line runs on the main
@@ -457,7 +589,7 @@ class BoardViewModel(
// which is exactly what ticking a checkbox on a card did. // which is exactly what ticking a checkbox on a card did.
editing = if (closeEditor) null else state.editing?.let { updated ?: it }, editing = if (closeEditor) null else state.editing?.let { updated ?: it },
saving = false, saving = false,
error = null, error = notice(),
) )
} catch (e: Exception) { } catch (e: Exception) {
// Broad by intent, as elsewhere: the core reports every failure // Broad by intent, as elsewhere: the core reports every failure
@@ -483,6 +615,19 @@ class BoardViewModel(
checked: Boolean, checked: Boolean,
) = mutate { it.setItemChecked(note.id, index.toString(), checked) } ) = mutate { it.setItemChecked(note.id, index.toString(), checked) }
/**
* Put the main board in this order, first on top: the end of a card drag.
*
* Through [mutate] so the board is re-read afterwards, which is what settles a
* drop the store did not take as shown. A card dragged across the pinned line
* snaps back to its own side, because the store sorts by pinned first.
*/
fun reorder(orderedIds: List<String>) =
mutate {
it.reorderNotes(orderedIds)
null
}
fun dismissError() { fun dismissError() {
state = state.copy(error = null) state = state.copy(error = null)
} }
@@ -498,13 +643,14 @@ class BoardViewModel(
private const val VIEW_TRASH = "trash" private const val VIEW_TRASH = "trash"
fun factory( fun factory(
core: ThoughtSync, core: Inkwell,
readFile: (Uri) -> PickedFile,
onRemindersChanged: () -> Unit, onRemindersChanged: () -> Unit,
): ViewModelProvider.Factory = ): ViewModelProvider.Factory =
object : ViewModelProvider.Factory { object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = override fun <T : ViewModel> create(modelClass: Class<T>): T =
BoardViewModel(core, onRemindersChanged) as T BoardViewModel(core, onRemindersChanged, readFile) as T
} }
} }
} }
@@ -518,7 +664,8 @@ class BoardViewModel(
private fun query( private fun query(
view: String, view: String,
labelId: String? = null, labelId: String? = null,
) = NoteQuery(view = view, labelId = labelId, sort = null, facets = null) facets: NoteFacets? = null,
) = NoteQuery(view = view, labelId = labelId, sort = null, facets = facets)
private fun draft(content: String): NoteDraft = private fun draft(content: String): NoteDraft =
// The core names the note from the body's first line, so a captured thought is // The core names the note from the body's first line, so a captured thought is
@@ -553,4 +700,7 @@ private fun blankDraft(): Note =
previews = emptyList(), previews = emptyList(),
createdAt = null, createdAt = null,
updatedAt = 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. // 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. * Everything the editor can ask for, as one type.
@@ -93,4 +95,22 @@ sealed interface EditorAction {
data class SetRecurrence( data class SetRecurrence(
val rule: String?, val rule: String?,
) : EditorAction ) : 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.runtime.saveable.Saver
import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import com.fabledsword.thoughtsync.core.checklistItems import com.fabledsword.inkwell.core.checklistItems
import com.fabledsword.thoughtsync.core.checklistRender import com.fabledsword.inkwell.core.checklistRender
/** /**
* One piece of a note body, as the editor DRAWS it. * One piece of a note body, as the editor DRAWS it.
@@ -143,28 +143,18 @@ fun List<EditorBlock>.plusTask(): Pair<List<EditorBlock>, Long> {
} }
/** /**
* Re-read ONE prose block for `- [ ] ` lines somebody typed by hand. * Re-read ONE prose block for `- [ ] ` lines somebody typed by hand, so a marker
* typed in the editor becomes an item without closing and reopening the note.
* *
* [splitBlocks] runs once, when the editor opens. After that the blocks are the state * **On blur, and only the block being left:** re-splitting on a keystroke would move
* and nothing reads the body again — every edit travels the other way, through * the caret, and converting the moment `- [ ]` is complete would catch an item with
* [joinBlocks]. So a marker typed by hand stayed literal text on screen until the note * no text yet.
* was closed and reopened, even though it was already a real item in storage and the
* card was already drawing a checkbox for it. The editor was the only place that
* disagreed.
* *
* **On blur, and only the block being left.** There is no good moment to convert while * Returns THIS LIST when there was nothing to promote; the caller relies on that to
* someone is typing: re-splitting on a keystroke moves the caret out of the word being * leave the state alone, so a blur that changed nothing doesn't re-key every field.
* written, and converting the instant `- [ ]` is complete does it before the item has
* any text. Blur is the one moment the person has demonstrably finished with the block,
* so a re-split costs no caret and cannot catch a half-typed line.
* *
* Returns THIS LIST, not an equal copy, when there was nothing to promote — the caller * Non-canonical markers (`- [X]`, an odd bullet) come back canonical, as they would on
* leans on that to leave the state alone, and a blur that changed nothing must not * reopen.
* re-key every field below it.
*
* Non-canonical markers (`- [X]`, an odd bullet) come back canonical, exactly as they
* would have on reopen. That is the only case where this changes the body rather than
* only the way it is drawn.
*/ */
internal fun List<EditorBlock>.promotingTasks(index: Int): List<EditorBlock> { internal fun List<EditorBlock>.promotingTasks(index: Int): List<EditorBlock> {
val block = getOrNull(index) val block = getOrNull(index)
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync.ui package com.fabledsword.inkwell.ui
import android.text.format.DateUtils import android.text.format.DateUtils
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -39,10 +39,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R import com.fabledsword.inkwell.R
import com.fabledsword.thoughtsync.core.Note import com.fabledsword.inkwell.core.Note
/** /**
* The editor's action bar, along the top of the surface. * The editor's action bar, along the top of the surface.
@@ -75,12 +76,19 @@ import com.fabledsword.thoughtsync.core.Note
fun EditorTopBar( fun EditorTopBar(
note: Note, note: Note,
readOnly: Boolean, readOnly: Boolean,
access: NoteAccess,
onClose: () -> Unit, onClose: () -> Unit,
onStartChecklist: () -> Unit, onStartChecklist: () -> Unit,
onPicker: (Picker) -> Unit, onPicker: (Picker) -> Unit,
onAttach: () -> Unit,
onShare: () -> Unit,
onConfirmDelete: () -> Unit, onConfirmDelete: () -> Unit,
onAction: (EditorAction) -> 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() val dark = isSystemInDarkTheme()
TopAppBar( TopAppBar(
title = {}, title = {},
@@ -96,13 +104,15 @@ fun EditorTopBar(
} }
}, },
actions = { actions = {
if (!readOnly) { if (!readOnly && owner) {
IconButton(onClick = { onPicker(Picker.REMINDER) }) { IconButton(onClick = { onPicker(Picker.REMINDER) }) {
Icon( Icon(
Icons.Filled.Notifications, Icons.Filled.Notifications,
contentDescription = stringResource(R.string.editor_reminder), contentDescription = stringResource(R.string.editor_reminder),
) )
} }
}
if (!readOnly) {
// Inserts `- [ ] ` at the caret. Always available, and never hidden: // Inserts `- [ ] ` at the caret. Always available, and never hidden:
// a checklist is text now (M304), so there is no section to be // a checklist is text now (M304), so there is no section to be
// already-showing and no reason a second list cannot start further // already-showing and no reason a second list cannot start further
@@ -114,13 +124,26 @@ fun EditorTopBar(
) )
} }
} }
// 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( OverflowMenu(
note = note, note = note,
readOnly = readOnly, owner = owner,
onPicker = onPicker, onPicker = onPicker,
onShare = onShare,
onConfirmDelete = onConfirmDelete, onConfirmDelete = onConfirmDelete,
onAction = onAction, onAction = onAction,
) )
}
}, },
// EXPLICIT, and not optional — the same lesson the old bottom bar learned. // EXPLICIT, and not optional — the same lesson the old bottom bar learned.
// Material derives a bar's content colour from its container via // Material derives a bar's content colour from its container via
@@ -256,8 +279,9 @@ private fun savedLabel(
@Composable @Composable
private fun OverflowMenu( private fun OverflowMenu(
note: Note, note: Note,
readOnly: Boolean, owner: Boolean,
onPicker: (Picker) -> Unit, onPicker: (Picker) -> Unit,
onShare: () -> Unit,
onConfirmDelete: () -> Unit, onConfirmDelete: () -> Unit,
onAction: (EditorAction) -> Unit, onAction: (EditorAction) -> Unit,
) { ) {
@@ -268,7 +292,7 @@ private fun OverflowMenu(
Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.editor_more)) Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.editor_more))
} }
DropdownMenu(expanded = open, onDismissRequest = close) { DropdownMenu(expanded = open, onDismissRequest = close) {
if (readOnly) { if (note.trashed) {
MenuItem(R.string.editor_restore, close) { onAction(EditorAction.Restore) } MenuItem(R.string.editor_restore, close) { onAction(EditorAction.Restore) }
MenuItem(R.string.editor_delete_forever, close, onConfirmDelete) MenuItem(R.string.editor_delete_forever, close, onConfirmDelete)
} else { } else {
@@ -276,15 +300,23 @@ private fun OverflowMenu(
if (note.pinned) R.string.editor_unpin else R.string.editor_pin, if (note.pinned) R.string.editor_unpin else R.string.editor_pin,
close, close,
) { onAction(EditorAction.SetPinned(!note.pinned)) } ) { onAction(EditorAction.SetPinned(!note.pinned)) }
if (owner) {
MenuItem(R.string.editor_labels, close) { onPicker(Picker.LABELS) } 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( MenuItem(
if (note.archived) R.string.editor_unarchive else R.string.editor_archive, if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
close, close,
) { onAction(EditorAction.SetArchived(!note.archived)) } ) { onAction(EditorAction.SetArchived(!note.archived)) }
if (owner) {
MenuItem(R.string.editor_trash, close) { onAction(EditorAction.Trash) } 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.clickable
import androidx.compose.foundation.layout.Arrangement 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.res.stringResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R import com.fabledsword.inkwell.R
import com.fabledsword.thoughtsync.core.Label import com.fabledsword.inkwell.core.Label
import com.fabledsword.thoughtsync.core.Note import com.fabledsword.inkwell.core.Note
import java.time.DayOfWeek import java.time.DayOfWeek
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
@@ -338,7 +338,7 @@ private fun RecurrenceChips(
} }
@Composable @Composable
private fun SheetTitle(labelRes: Int) { internal fun SheetTitle(labelRes: Int) {
Text( Text(
text = stringResource(labelRes), text = stringResource(labelRes),
style = MaterialTheme.typography.titleMedium, 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.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -16,7 +16,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R import com.fabledsword.inkwell.R
// Shared by the board and the editor. // 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.Composable
import androidx.compose.runtime.DisposableEffect 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.Composable
import androidx.compose.runtime.DisposableEffect 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.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -18,6 +18,7 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -38,12 +39,12 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R import com.fabledsword.inkwell.R
import com.fabledsword.thoughtsync.core.BodyItem import com.fabledsword.inkwell.core.BodyItem
import com.fabledsword.thoughtsync.core.Note import com.fabledsword.inkwell.core.Note
import com.fabledsword.thoughtsync.core.NoteLabel import com.fabledsword.inkwell.core.NoteLabel
import com.fabledsword.thoughtsync.core.bodyTags import com.fabledsword.inkwell.core.bodyTags
import com.fabledsword.thoughtsync.core.checklistItems import com.fabledsword.inkwell.core.checklistItems
@Composable @Composable
fun NoteCard( fun NoteCard(
@@ -52,11 +53,17 @@ fun NoteCard(
onToggleItem: (Int, Boolean) -> Unit, onToggleItem: (Int, Boolean) -> Unit,
onAction: (EditorAction) -> Unit, onAction: (EditorAction) -> Unit,
onConfirmDelete: () -> Unit, onConfirmDelete: () -> Unit,
/** The board is carrying this card (BoardDrag.kt). */
dragging: Boolean = false,
) { ) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current
var menuOpen by remember { mutableStateOf(false) } var menuOpen by remember { mutableStateOf(false) }
// A drag starts from the same long press that opens the menu, so the menu is
// already up when the card begins to move. It goes as the card does.
LaunchedEffect(dragging) { if (dragging) menuOpen = false }
// NAMED rather than written inline in the chain below, and not for taste: ktlint's // NAMED rather than written inline in the chain below, and not for taste: ktlint's
// chain-method-continuation wants the next `.` glued to the closing paren of a // chain-method-continuation wants the next `.` glued to the closing paren of a
// multiline element — `).background(…)` — which is worse to read than a modifier // multiline element — `).background(…)` — which is worse to read than a modifier
@@ -101,50 +108,7 @@ fun NoteCard(
.border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS)) .border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS))
.padding(12.dp), .padding(12.dp),
) { ) {
// TAGS FIRST. They used to sit under everything else, which on a tall note put CardContents(note = note, onToggleItem = onToggleItem)
// the one thing that says what a note IS below the fold of a glance. A board is
// scanned, not read, and the answer to "which of these is about the thing I am
// looking for" should be the first thing the eye lands on rather than the last.
//
// Above the body rather than beside it, because the body's first line is the
// note's NAME (M13 steps 3 and 4) and a chip floated next to it would compete
// with the thing that identifies the note. A row of its own costs one line and
// only on notes that have tags at all.
//
// ONLY the labels whose text is not still in the note. `via_tag` means exactly
// "backed by body text" since M311, so a chip for one printed the same tag
// twice — once where it was typed, once up here — and the card was carrying
// furniture for information it was already showing. A tag left in prose is
// tinted in place instead; see [tintTags]. What reaches this row is what the
// body cannot say: a tag lifted off its own line, and a label added by hand.
val chips = note.labels.filterNot { it.viaTag }
if (chips.isNotEmpty()) {
LabelChips(labels = chips)
Spacer(Modifier.height(8.dp))
}
// 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)
}
// A note with nothing in it still has to occupy the board legibly — otherwise
// it reads as a rendering bug.
if (note.body.isBlank()) {
Text(
text = stringResource(R.string.board_empty_note),
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
note.remindAt?.let { at ->
Spacer(Modifier.height(8.dp))
ReminderChip(instant = at, recurrence = note.recurrence)
}
} }
NoteMenu( NoteMenu(
@@ -157,35 +121,94 @@ fun NoteCard(
} }
} }
/** What a card shows, top to bottom: tags, body, links, attachments, reminder, sharing. */
@Composable
private fun CardContents(
note: Note,
onToggleItem: (Int, Boolean) -> Unit,
) {
// TAGS FIRST, on a row of their own above the body: a board is scanned, and
// what a note is about should be the first thing the eye lands on. Not beside
// the body, whose first line is the note's name.
//
// Only labels whose text is not still in the note (`via_tag` is false). A tag
// left in prose is tinted in place instead (see [tintTags]), so this row holds
// what the body cannot say: a tag lifted off its own line, or one added by hand.
val chips = note.labels.filterNot { it.viaTag }
if (chips.isNotEmpty()) {
LabelChips(labels = chips)
Spacer(Modifier.height(8.dp))
}
// A note that is NOTHING but a URL renders as its preview and nothing
// else — printing the raw address under a card that already says where it
// goes is saying the same thing twice, badly. Until the unfurl lands, or
// if it never does, `preview` is null and the body falls through to
// NoteBody, which shows the URL. Never a blank card.
val lonePreview = remember(note.body, note.previews) { loneUrlPreview(note) }
// Body then checklist, in order — a note can carry both (M13 step 2). The
// first line of the body IS the note's name, at the same weight as the rest of
// it (M13 steps 3 and 4).
if (lonePreview != null) {
LinkPreviewCard(preview = lonePreview, compact = false)
} else if (note.body.isNotBlank()) {
// 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() && note.previews.isEmpty() && note.attachments.isEmpty()) {
Text(
text = stringResource(R.string.board_empty_note),
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
note.remindAt?.let { at ->
Spacer(Modifier.height(8.dp))
ReminderChip(instant = at, recurrence = note.recurrence)
}
SharedChip(note)
}
/** /**
* What you can do to a note without opening it. * What you can do to a note without opening it, on a long press.
* *
* The board used to have none of this, and the operator's read of that was not "the * The same items as the editor's overflow, in the same words from the same string
* actions are in the editor" — it was *"there are no long hold context menus in the * resources, dispatched through [EditorAction] so `BoardViewModel.onEditorAction`
* app I have no way to delete notes."* Trash was three interactions deep (open, ⋮, * stays the one place they are handled.
* Move to trash), and on a phone that is far enough from the gesture people reach
* for that it may as well not exist.
* *
* **The same items as the editor's overflow, in the same words, from the same string * Gated on the NOTE (`note.trashed`), not on the view, because Reminders and search
* resources.** A note has one vocabulary of things that can be done to it, and two * mix piles: a note already in Trash must not offer "Move to trash".
* surfaces that named them differently would be describing two different apps. It
* dispatches [EditorAction] for the same reason — `BoardViewModel.onEditorAction` is
* already the exhaustive dispatcher for every one of them, so the board reuses the
* seam rather than growing a parallel one that could drift.
* *
* **Gated on the NOTE, not on the destination.** `note.trashed` is what the editor * No Labels item: the picker it opens is editor state.
* gates its own read-only mode on, and it is the only reading that survives the views
* that mix piles: Reminders cuts across archived and active alike, and a search hits
* whatever matches. A menu that offered "Move to trash" on a note already in the
* trash would be offering to do something twice.
*
* **Colour is absent**, though #2946 suggested it. It was left out because `note.color`
* was already scheduled for removal; M315 removed it. There is no colour to set on a
* note any more — a card is one neutral surface and the only coloured thing on a board
* is a tag — so the row this menu never grew is a row that could not exist.
*
* Labels are absent too, for a duller reason: the picker they open is editor state,
* and hoisting it to the board is a bigger change than the friction actually reported.
*/ */
@Composable @Composable
private fun NoteMenu( private fun NoteMenu(
@@ -195,7 +218,11 @@ private fun NoteMenu(
onAction: (EditorAction) -> Unit, onAction: (EditorAction) -> Unit,
onConfirmDelete: () -> 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) { if (note.trashed) {
MenuItem(R.string.editor_restore, onDismiss) { onAction(EditorAction.Restore) } MenuItem(R.string.editor_restore, onDismiss) { onAction(EditorAction.Restore) }
MenuItem(R.string.editor_delete_forever, onDismiss, onConfirmDelete) MenuItem(R.string.editor_delete_forever, onDismiss, onConfirmDelete)
@@ -208,9 +235,11 @@ private fun NoteMenu(
if (note.archived) R.string.editor_unarchive else R.string.editor_archive, if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
onDismiss, onDismiss,
) { onAction(EditorAction.SetArchived(!note.archived)) } ) { onAction(EditorAction.SetArchived(!note.archived)) }
if (owner) {
MenuItem(R.string.editor_trash, onDismiss) { onAction(EditorAction.Trash) } MenuItem(R.string.editor_trash, onDismiss) { onAction(EditorAction.Trash) }
} }
} }
}
} }
/** /**
@@ -418,6 +447,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 private const val MAX_PREVIEW_LINES = 8
/** How far one long line of a card may wrap before it is cut. */ /** How far one long line of a card may wrap before it is cut. */
@@ -479,31 +544,13 @@ fun noteCardSurface(dark: Boolean): Color = if (dark) CARD_SURFACE_DARK else CAR
private val CARD_SURFACE_LIGHT = Color(0xFFFFFFFF) private val CARD_SURFACE_LIGHT = Color(0xFFFFFFFF)
private val CARD_SURFACE_DARK = Color(0xFF171717) private val CARD_SURFACE_DARK = Color(0xFF171717)
// THE CARD'S EDGE — one grey, every card, both themes. Since M315 it is the only thing // THE CARD'S EDGE — one neutral grey, every card, both themes. The fill barely differs
// that differs from the board by more than a hair, which makes it structure rather than // from the board, so this line is what makes a card a card. Neutral on purpose: a
// decoration: it is what a card IS. // coloured edge would carry information and turn a board into a grid of outlines.
// //
// It was already neutral before the fill was. The version that came from the palette // Opaque rather than a translucent black/white, which would composite differently over
// was a `{hue}-900` border and failed twice over: the line measured 1.56-2.09 against // whatever is under it. NoteCard.vue writes the same two hex values, which is how the
// its own fill while the fill managed only 1.03-1.05 against the board, so it was the // surfaces stay the same card. Contrast: #B8B8B8 on white 1.98, #404040 on #171717 1.73.
// loudest thing on the card — and it carried the same information the fill did, so a
// field of cards read as a grid of outlines however different the colours inside were.
// A neutral line carries no information at all, which is exactly what lets it be
// structure instead of content. The fill is that same argument one size up.
//
// MEASURED AGAINST ONE FILL NOW, and deliberately left where it was. #B8B8B8 on white
// is 1.98 and #404040 on #171717 is 1.73 — both inside the ranges these values already
// shipped at across twenty fills (light 1.57-1.98, dark 1.58-1.73), but at the top of
// them rather than the ~1.6-1.7 the pair was originally matched on. Softening the light
// edge to re-match would weaken the only boundary a white card on a #FAFAFA board has,
// and the complaint that started M315 was about fill, never about edge weight. If an
// operator pass disagrees it is one constant, in two files.
//
// NOT a translucent black/white edge, which is the tidier way to write this and was
// measured and rejected: a border composites over what is under it, so `White` at 20%
// came out #56396D on a purple card and #A3C9C1 on a teal one. With one fill that
// argument no longer bites — but an opaque grey is what NoteCard.vue must also write,
// and two surfaces stating the same hex is how they stay the same card.
private val CARD_EDGE_LIGHT = Color(0xFFB8B8B8) private val CARD_EDGE_LIGHT = Color(0xFFB8B8B8)
private val CARD_EDGE_DARK = Color(0xFF404040) private val CARD_EDGE_DARK = Color(0xFF404040)
private val CHIP_RADIUS = 6.dp private val CHIP_RADIUS = 6.dp
@@ -1,6 +1,8 @@
package com.fabledsword.thoughtsync.ui package com.fabledsword.inkwell.ui
import androidx.activity.compose.BackHandler 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.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -25,8 +27,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.core.Label import com.fabledsword.inkwell.core.Label
import com.fabledsword.thoughtsync.core.Note import com.fabledsword.inkwell.core.Note
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
/** /**
@@ -56,6 +58,7 @@ fun NoteEditorScreen(
labels: List<Label>, labels: List<Label>,
saving: Boolean, saving: Boolean,
error: String?, error: String?,
onShare: () -> Unit,
onAction: (EditorAction) -> Unit, onAction: (EditorAction) -> Unit,
) { ) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
@@ -88,7 +91,13 @@ fun NoteEditorScreen(
// A note in the trash is a record, not a document: editing one would silently // 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 // 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. // 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 // 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 // note, read it, back out" write nothing at all — without it every glance
@@ -132,6 +141,14 @@ fun NoteEditorScreen(
BackHandler(onBack = leave) 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 // 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 // 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. // is exactly the failure that makes someone stop trusting a notes app.
@@ -167,6 +184,7 @@ fun NoteEditorScreen(
EditorTopBar( EditorTopBar(
note = note, note = note,
readOnly = readOnly, readOnly = readOnly,
access = access,
onClose = leave, onClose = leave,
onStartChecklist = { onStartChecklist = {
val (next, id) = blocks.plusTask() val (next, id) = blocks.plusTask()
@@ -174,6 +192,11 @@ fun NoteEditorScreen(
focus = id focus = id
}, },
onPicker = { picker = it }, onPicker = { picker = it },
onAttach = { pickFiles.launch(ANY_TYPE) },
onShare = {
flush()
onShare()
},
onConfirmDelete = { confirmingDelete = true }, onConfirmDelete = { confirmingDelete = true },
onAction = onAction, 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 // 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 // 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 // the line beneath it (M13 steps 3 and 4). What 2992 changed is only
@@ -232,17 +258,29 @@ fun NoteEditorScreen(
// list on screen twice. // list on screen twice.
if (note.labels.isNotEmpty()) { if (note.labels.isNotEmpty()) {
EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction) EditorLabelRow(note = note, readOnly = !ownerControls, onAction = onAction)
} }
note.remindAt?.let { at -> note.remindAt?.let { at ->
EditorReminderRow( EditorReminderRow(
at = at, at = at,
recurrence = note.recurrence, recurrence = note.recurrence,
readOnly = readOnly, readOnly = !ownerControls,
onAction = onAction, 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 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 * 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. * 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.Composable
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.ReadOnlyComposable
@@ -44,22 +44,9 @@ data class NoteTint(
val lightChipForeground: Color, val lightChipForeground: Color,
val darkChipForeground: Color, val darkChipForeground: Color,
/** /**
* THE INK A TAG IS DRAWN IN — inline in the prose AND as a chip's text — see * THE INK A TAG IS DRAWN IN — inline in the prose and as a chip's text — see
* [tagInk]. * [tagInk]. One value serves both: `-800` light / `-300` dark clears body-text
* * contrast on the card and on a chip's own fill.
* These were two columns until M315, and the split was real while it lasted: a chip
* brought its own `-100` fill and could afford `-700`, while inline text sat on
* whatever the card was, which included a gray-tagged card at `neutral-200` where
* `-700` measured 3.98 (green), 4.11 (orange) and 4.34 (teal), all under the 4.5
* body text needs. One step deeper cleared every fill at once.
*
* The twenty card fills that split was solving for are gone, so both jobs take this
* one value. The direction is deliberate: since M311 a tag whose text is in the body
* is drawn where it was typed and NOT repeated as a chip, so the inline token is the
* common case and collapsing onto ITS column leaves what is seen most exactly as it
* was. The chip is strictly better for the move — on its own fill it goes from
* 4.52-8.23 to 6.37-12.01 in light. Dark needed no decision: the two columns already
* held the same value for all ten hues.
*/ */
val lightTagInk: Color, val lightTagInk: Color,
val darkTagInk: Color, val darkTagInk: Color,
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync.ui package com.fabledsword.inkwell.ui
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.background 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.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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. * 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.annotation.StringRes
import androidx.compose.foundation.layout.fillMaxWidth 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.app.AlarmManager
import android.content.Context import android.content.Context
@@ -17,8 +17,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import com.fabledsword.thoughtsync.R import com.fabledsword.inkwell.R
import com.fabledsword.thoughtsync.Reminders import com.fabledsword.inkwell.Reminders
/** /**
* Says so when a reminder would not actually reach anyone. * Says so when a reminder would not actually reach anyone.
@@ -0,0 +1,252 @@
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.pluralStringResource
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
import com.fabledsword.inkwell.core.ShareGroup
import com.fabledsword.inkwell.core.ShareTarget
/** "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 or a group.
*
* A peer of the web's `ShareDialog.vue`. Each share'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: (target: ShareTarget, 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: (target: ShareTarget, 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.target, 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) {
TargetName(share.member, share.group, 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: (target: ShareTarget, permission: String) -> Unit,
) {
var pick by remember(state.noteId) { mutableStateOf<ShareCandidate?>(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.key }) { candidate ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().clickable { pick = candidate },
) {
RadioButton(selected = pick?.key == candidate.key, onClick = { pick = candidate })
TargetName(candidate.member, candidate.group, 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.target, 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)) },
)
}
}
/** A person (name over email) or a group (name over how many are in it). */
@Composable
private fun TargetName(
member: Member?,
group: ShareGroup?,
modifier: Modifier = Modifier,
) {
val title = group?.name ?: member?.let { it.displayName.ifBlank { it.email } }.orEmpty()
val detail =
when {
group != null ->
pluralStringResource(
R.plurals.share_group_members,
group.memberCount.toInt(),
group.memberCount.toInt(),
)
member != null && member.displayName.isNotBlank() -> member.email
else -> null
}
Column(modifier = modifier) {
Text(text = title, style = MaterialTheme.typography.bodyLarge)
detail?.let {
Text(
text = it,
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,147 @@
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 com.fabledsword.inkwell.core.ShareGroup
import com.fabledsword.inkwell.core.ShareTarget
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(),
/** The groups the admin made (#5177); a note shared with one reaches its members. */
val groups: List<ShareGroup> = 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 and groups it isn't shared with yet, people first. */
val available: List<ShareCandidate>
get() {
val people = shares.mapNotNull { it.member?.id }.toSet()
val grouped = shares.mapNotNull { it.group?.id }.toSet()
return members.filterNot { it.id in people }.map { ShareCandidate(member = it) } +
groups.filterNot { it.id in grouped }.map { ShareCandidate(group = it) }
}
}
/** Someone, or a group, the sheet offers to share with. Exactly one is set. */
data class ShareCandidate(
val member: Member? = null,
val group: ShareGroup? = null,
) {
val key: String
get() = group?.let { "g:${it.id}" } ?: "u:${member?.id}"
val target: ShareTarget
get() = targetOf(member, group)
}
/** Who an existing share goes to, to change its permission. */
val NoteShare.target: ShareTarget
get() = targetOf(member, group)
private fun targetOf(
member: Member?,
group: ShareGroup?,
): ShareTarget = group?.let { ShareTarget.Group(it.id) } ?: ShareTarget.Member(member?.id.orEmpty())
/**
* 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 directory = core.shareDirectory()
val shares = core.noteShares(noteId)
state.copy(
loading = false,
members = directory.members,
groups = directory.groups,
shares = shares,
)
}
} catch (e: CoreException) {
state.copy(loading = false, loadError = e.describeShareFailure())
}
}
}
fun close() {
state = ShareState()
}
/** Share with someone or a group, or change what they may do. */
fun share(
target: ShareTarget,
permission: String,
) = write { noteId -> core.shareNote(noteId, target, 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 android.os.Build
import androidx.compose.foundation.layout.Arrangement 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.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R import com.fabledsword.inkwell.R
import com.fabledsword.thoughtsync.core.Compatibility import com.fabledsword.inkwell.core.Compatibility
import com.fabledsword.thoughtsync.core.RevokeOutcome import com.fabledsword.inkwell.core.RevokeOutcome
// Becoming linked: the probe-then-sign-in flow, and the notices around it. // 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -31,18 +31,20 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R import com.fabledsword.inkwell.R
import com.fabledsword.thoughtsync.UpdateOutcome import com.fabledsword.inkwell.UpdateOutcome
import com.fabledsword.inkwell.installedVersionName
/** /**
* Opt-in server pairing. * Opt-in server pairing.
* *
* The whole screen is written around one idea: **being unlinked is not a * 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" * 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 * and explains what connecting would ADD, rather than presenting an empty form as
* unfinished setup. * unfinished setup.
@@ -120,10 +122,38 @@ fun SyncScreen(
onDismissRevokeNotice = onDismissRevokeNotice, 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 ───────────────────────────────── // ───────────────────────────────── linked ─────────────────────────────────
@Composable @Composable
@@ -1,11 +1,11 @@
package com.fabledsword.thoughtsync.ui package com.fabledsword.inkwell.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import com.fabledsword.thoughtsync.R import com.fabledsword.inkwell.R
import com.fabledsword.thoughtsync.core.Compatibility import com.fabledsword.inkwell.core.Compatibility
import com.fabledsword.thoughtsync.core.SyncOutcome import com.fabledsword.inkwell.core.SyncOutcome
// Turning sync results into sentences. // 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 (sent > 0) parts += stringResource(R.string.sync_summary_sent, sent)
if (received > 0) parts += stringResource(R.string.sync_summary_received, received) if (received > 0) parts += stringResource(R.string.sync_summary_received, received)
if (blobs > 0) parts += pluralStringResource(R.plurals.sync_summary_attachments, blobs, blobs) 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 = val line =
if (parts.isEmpty()) { if (parts.isEmpty()) {
@@ -44,11 +46,13 @@ fun syncSummary(outcome: SyncOutcome): String {
// rather than an error — but saying nothing would leave a missing image // rather than an error — but saying nothing would leave a missing image
// looking like data loss. // looking like data loss.
val failed = outcome.pull.blobsFailed.toInt() val failed = outcome.pull.blobsFailed.toInt()
return if (failed > 0) { val notUploaded = outcome.push.uploadFailed.toInt()
line + " " + pluralStringResource(R.plurals.sync_summary_attachments_failed, failed, failed) val notes = mutableListOf(line)
} else { if (failed > 0) notes += pluralStringResource(R.plurals.sync_summary_attachments_failed, failed, failed)
line // 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.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -6,12 +6,12 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.thoughtsync.core.Compatibility import com.fabledsword.inkwell.core.Compatibility
import com.fabledsword.thoughtsync.core.ProbeResult import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.thoughtsync.core.RevokeOutcome import com.fabledsword.inkwell.core.ProbeResult
import com.fabledsword.thoughtsync.core.SyncOutcome import com.fabledsword.inkwell.core.RevokeOutcome
import com.fabledsword.thoughtsync.core.SyncStatus import com.fabledsword.inkwell.core.SyncOutcome
import com.fabledsword.thoughtsync.core.ThoughtSync import com.fabledsword.inkwell.core.SyncStatus
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -113,7 +113,7 @@ data class SyncState(
* stored cursor next time (Scribe #2736). * stored cursor next time (Scribe #2736).
*/ */
class SyncViewModel( class SyncViewModel(
private val core: ThoughtSync, private val core: Inkwell,
/** /**
* Called after a sync that changed the store. * Called after a sync that changed the store.
* *
@@ -316,7 +316,7 @@ class SyncViewModel(
companion object { companion object {
fun factory( fun factory(
core: ThoughtSync, core: Inkwell,
onStoreChanged: () -> Unit, onStoreChanged: () -> Unit,
): ViewModelProvider.Factory = ): ViewModelProvider.Factory =
object : ViewModelProvider.Factory { object : ViewModelProvider.Factory {
@@ -344,7 +344,7 @@ private fun SyncOutcome.changedTheStore(): Boolean =
* The message to show for a failure. * The message to show for a failure.
* *
* The core writes these for people to read — "notes.example.com responded, but not * 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. * replaced with a generic string that would throw away the only useful part.
*/ */
private fun Exception.describe(): String = message ?: "Something went wrong." 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.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme 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 * 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 * 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. * If dynamic colour is ever wanted it belongs behind a setting, not as the default.
*/ */
@Composable @Composable
fun ThoughtSyncTheme( fun InkwellTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync.ui package com.fabledsword.inkwell.ui
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.OffsetDateTime 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.background
import androidx.compose.foundation.border 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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.AppUpdate import com.fabledsword.inkwell.AppUpdate
import com.fabledsword.thoughtsync.R import com.fabledsword.inkwell.R
import com.fabledsword.thoughtsync.UpdateOutcome import com.fabledsword.inkwell.UpdateOutcome
/** /**
* Updating the app from the server it is linked to. * 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 android.content.Context
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -7,10 +7,10 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.thoughtsync.AppUpdate import com.fabledsword.inkwell.AppUpdate
import com.fabledsword.thoughtsync.UpdateOutcome import com.fabledsword.inkwell.UpdateOutcome
import com.fabledsword.thoughtsync.core.ClientUpdate import com.fabledsword.inkwell.core.ClientUpdate
import com.fabledsword.thoughtsync.core.ThoughtSync import com.fabledsword.inkwell.core.Inkwell
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -61,7 +61,7 @@ data class UpdateState(
* secret this app holds across two languages for no gain. * secret this app holds across two languages for no gain.
*/ */
class UpdateViewModel( class UpdateViewModel(
private val core: ThoughtSync, private val core: Inkwell,
/** /**
* MUST be the application context — it outlives this view model, and holding an * MUST be the application context — it outlives this view model, and holding an
* Activity here is the textbook way to leak a window. * Activity here is the textbook way to leak a window.
@@ -207,7 +207,7 @@ class UpdateViewModel(
companion object { companion object {
fun factory( fun factory(
core: ThoughtSync, core: Inkwell,
context: Context, context: Context,
): ViewModelProvider.Factory = ): ViewModelProvider.Factory =
object : 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 — Adaptive icon. minSdk is 26, so this is the ONLY icon Android will ask for —
no legacy raster fallback is needed. no legacy raster fallback is needed.
The foreground is the shared maskable asset the web app already ships The foreground is the inkwell mark alone on transparency, inside the 66dp safe
(frontend/public/icon-maskable-512.png), which is drawn with the safe-zone circle; the yellow is the background colour. It is rendered by
padding adaptive icons require. Reusing it means the phone, the web app and the packaging/icons.py from the same drawing as the web and desktop icons, so all
desktop all wear the same face rather than three near-misses. 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"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" /> <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> <resources>
<!-- The product's brand colour, same value the web app's manifest and <!-- 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 <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> <color name="ic_launcher_background">#F5C518</color>
</resources> </resources>
+121 -13
View File
@@ -1,12 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">ThoughtSync</string> <string name="app_name">Inkwell</string>
<!-- Search bar --> <!-- Search bar -->
<string name="search_hint">Search your notes</string> <string name="search_hint">Search your notes</string>
<string name="search_clear">Clear search</string> <string name="search_clear">Clear search</string>
<string name="nav_open">Open navigation</string> <string name="nav_open">Open navigation</string>
<string name="nav_labels">Labels</string> <string name="nav_labels">Tags</string>
<!-- Compose sheet --> <!-- Compose sheet -->
<string name="compose_open">New note</string> <string name="compose_open">New note</string>
@@ -28,6 +28,15 @@
<string name="board_empty_body">Tap + to start a note or a list. Everything stays on this device until you connect a server.</string> <string name="board_empty_body">Tap + to start a note or a list. Everything stays on this device until you connect a server.</string>
<string name="empty_search_title">No matches</string> <string name="empty_search_title">No matches</string>
<string name="empty_search_body">Nothing matched “%1$s”.</string> <string name="empty_search_body">Nothing matched “%1$s”.</string>
<string name="empty_filtered_title">No matches</string>
<string name="empty_filtered_body">No notes match these filters.</string>
<string name="filters">Filters</string>
<string name="filters_count">Filters · %1$d</string>
<string name="filters_clear">Clear</string>
<string name="filters_show">Show only</string>
<string name="filters_has_attachment">Has attachment</string>
<string name="filters_shared_with_me">Shared with me</string>
<string name="filters_tags">Tagged with all of</string>
<string name="empty_trash_title">Trash is empty</string> <string name="empty_trash_title">Trash is empty</string>
<string name="empty_trash_body">Deleted notes wait here before they are removed for good.</string> <string name="empty_trash_body">Deleted notes wait here before they are removed for good.</string>
<string name="empty_archive_title">Nothing archived</string> <string name="empty_archive_title">Nothing archived</string>
@@ -40,9 +49,8 @@
<string name="editor_back">Back to notes</string> <string name="editor_back">Back to notes</string>
<string name="editor_add_checklist">Add a checklist</string> <string name="editor_add_checklist">Add a checklist</string>
<string name="editor_body_hint">Take a note…</string> <string name="editor_body_hint">Take a note…</string>
<string name="editor_add_item">Add item</string>
<string name="editor_remove_item">Remove item</string> <string name="editor_remove_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_reminder">Set a reminder</string>
<string name="editor_more">More actions</string> <string name="editor_more">More actions</string>
<string name="editor_saving">Saving…</string> <string name="editor_saving">Saving…</string>
@@ -52,12 +60,49 @@
<string name="editor_done">Done</string> <string name="editor_done">Done</string>
<string name="editor_pin">Pin</string> <string name="editor_pin">Pin</string>
<string name="editor_unpin">Unpin</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_archive">Archive</string>
<string name="editor_unarchive">Unarchive</string> <string name="editor_unarchive">Unarchive</string>
<string name="editor_trash">Move to trash</string> <string name="editor_trash">Move to trash</string>
<string name="editor_restore">Restore</string> <string name="editor_restore">Restore</string>
<string name="editor_cancel">Cancel</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 or a group</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>
<plurals name="share_group_members">
<item quantity="one">Group · %d person</item>
<item quantity="other">Group · %d people</item>
</plurals>
<!-- 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 <!-- 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?". --> the copy says exactly that rather than asking "Are you sure?". -->
@@ -66,11 +111,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_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> <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 --> <!-- Pickers -->
<string name="label_picker_title">Labels</string> <string name="label_picker_title">Tags</string>
<string name="label_new_hint">Type a label and press enter</string> <string name="label_new_hint">Type a tag and press enter</string>
<string name="label_from_tag">from #tag</string> <string name="label_from_tag">from the text</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_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_next">Next</string>
<string name="picker_set">Set</string> <string name="picker_set">Set</string>
<string name="picker_time_title">Pick a time</string> <string name="picker_time_title">Pick a time</string>
@@ -114,7 +209,7 @@
<string name="reminder_channel">Reminders</string> <string name="reminder_channel">Reminders</string>
<string name="reminder_channel_description">Notifies you when a note\'s reminder is due.</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_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_open_settings">Open settings</string>
<string name="reminder_inexact_title">Reminders may arrive late</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> <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 +226,7 @@
<string name="update_later">Later</string> <string name="update_later">Later</string>
<string name="update_failed_title">The update didn\'t install</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_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_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="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> <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 +241,14 @@
<!-- Unlinked --> <!-- Unlinked -->
<string name="sync_offline_title">Working offline on this device</string> <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_label">Server address</string>
<string name="sync_address_hint">notes.example.com</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_address_help">Uses https unless you type http:// yourself.</string>
<string name="sync_check">Check</string> <string name="sync_check">Check</string>
<string name="sync_probe_failed">Couldn\'t reach that server</string> <string name="sync_probe_failed">Couldn\'t reach that server</string>
<string name="sync_link_failed">Couldn\'t connect</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_server_version">v%1$s</string>
<string name="sync_compat_ok">Fully compatible.</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> <string name="sync_compat_degraded">Compatible, but these features aren\'t available on this server: %1$s.</string>
@@ -197,7 +292,20 @@
<item quantity="one">%d attachment didn\'t download — it\'ll retry on the next sync.</item> <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> <item quantity="other">%d attachments didn\'t download — they\'ll retry on the next sync.</item>
</plurals> </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 --> <!-- Errors -->
<string name="error_dismiss">Dismiss</string> <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> </resources>
+1 -1
View File
@@ -6,5 +6,5 @@
truth in XML — the same reason the desktop reads its live theme rather truth in XML — the same reason the desktop reads its live theme rather
than hardcoding a window colour. than hardcoding a window colour.
--> -->
<style name="Theme.ThoughtSync" parent="android:Theme.Material.NoActionBar" /> <style name="Theme.Inkwell" parent="android:Theme.Material.NoActionBar" />
</resources> </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))
}
}
@@ -0,0 +1,42 @@
package com.fabledsword.inkwell.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class BoardFiltersTest {
@Test
fun `no filters asks the core for no facets at all`() {
assertNull(BoardFilters().facets())
assertEquals(0, BoardFilters().count)
}
@Test
fun `each tag counts as one, as on the web's badge`() {
val filters = BoardFilters(labelIds = setOf("a", "b"), hasAttachment = true, sharedWithMe = true)
assertEquals(4, filters.count)
val facets = requireNotNull(filters.facets())
assertEquals(setOf("a", "b"), facets.label?.toSet())
assertEquals(true, facets.hasAttachment)
assertEquals("with_me", facets.shared)
// Dates are Timeline's and text is the search bar's; the filters never set them.
assertNull(facets.q)
assertNull(facets.createdAfter)
assertNull(facets.createdBefore)
}
@Test
fun `a switch that is off is left out rather than sent as false`() {
val facets = requireNotNull(BoardFilters(labelIds = setOf("a")).facets())
assertNull(facets.hasAttachment)
assertNull(facets.shared)
}
@Test
fun `tapping a tag twice takes it back off`() {
val once = BoardFilters().toggleLabel("a")
assertEquals(setOf("a"), once.labelIds)
assertEquals(BoardFilters(), once.toggleLabel("a"))
}
}
@@ -1,4 +1,4 @@
package com.fabledsword.thoughtsync.ui package com.fabledsword.inkwell.ui
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotEquals
@@ -7,12 +7,10 @@ import org.junit.Test
/** /**
* Pins the derived-colour rule against `frontend/src/notes/colors.ts`. * 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 * These are not tests of Kotlin — they pin the pair. The web runs the same values
* has. The web side is TypeScript with no test runner (its CI lane is `vue-tsc * from `core/testdata/grammar.json` (`tint`) in `notes/grammar.test.ts`; this file
* --noEmit` and nothing else), so if these values drift, nothing on that surface will * writes them out by hand, so changing the hash or the key order means changing the
* say so and a tag will simply be a different colour on the phone than in the browser. * fixture and this file together, or one of the two suites goes red.
* The same names and hashes are written into colors.ts as a comment; changing either
* side means changing both and re-checking here.
* *
* SMALLER SINCE M315. Half of what this file used to pin — the generated card fill, and * 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 * 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] [package]
name = "thoughtsync-uniffi-bindgen" name = "inkwell-uniffi-bindgen"
version = "0.1.0" version = "0.1.0"
description = "Generates the Kotlin bindings for thoughtsync-ffi" description = "Generates the Kotlin bindings for inkwell-ffi"
authors = ["bvandeusen"] authors = ["bvandeusen"]
edition = "2021" edition = "2021"
# A crate whose ONLY dependency is uniffi itself. # 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 # 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 # 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 # 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: //! Invoked by Gradle (see android/app/build.gradle.kts) as:
//! //!
//! ```text //! ```text
//! cargo run --locked -p thoughtsync-uniffi-bindgen -- \ //! cargo run --locked -p inkwell-uniffi-bindgen -- \
//! generate --library <path/to/libthoughtsync_ffi.so> \ //! generate --library <path/to/libinkwell_ffi.so> \
//! --language kotlin --out-dir <build/generated/uniffi> //! --language kotlin --out-dir <build/generated/uniffi>
//! ``` //! ```
//! //!
+1 -1
View File
@@ -74,7 +74,7 @@ exceptions:
# right and still applies. # right and still applies.
excludes: excludes:
- "**/ui/**" - "**/ui/**"
- "**/ThoughtSyncApplication.kt" - "**/InkwellApplication.kt"
- "**/SyncWorker.kt" - "**/SyncWorker.kt"
- "**/ReminderReceiver.kt" - "**/ReminderReceiver.kt"
- "**/AppUpdate.kt" - "**/AppUpdate.kt"
+4 -4
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "thoughtsync-ffi" name = "inkwell-ffi"
version = "0.1.0" 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"] authors = ["bvandeusen"]
edition = "2021" edition = "2021"
@@ -10,10 +10,10 @@ edition = "2021"
# bindgen binary below — and this crate's own tests — can use the crate normally; # bindgen binary below — and this crate's own tests — can use the crate normally;
# a cdylib-only crate is unusable from Rust. # a cdylib-only crate is unusable from Rust.
crate-type = ["cdylib", "lib"] crate-type = ["cdylib", "lib"]
name = "thoughtsync_ffi" name = "inkwell_ffi"
[dependencies] [dependencies]
thoughtsync-core = { path = "../../core" } inkwell-core = { path = "../../core" }
serde_json = { workspace = true } serde_json = { workspace = true }
log = { workspace = true } log = { workspace = true }
+355 -110
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 //! 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 //! — a thin shim over the shared core, holding no logic of its own. If something here
@@ -7,7 +7,7 @@
//! //!
//! ## Shape //! ## 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 //! Tauri manages them as app state. Kotlin constructs it once, keeps it for the
//! process lifetime, and calls methods on it. //! process lifetime, and calls methods on it.
//! //!
@@ -38,13 +38,14 @@ pub mod models;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use thoughtsync_core::local::{self, Db}; use inkwell_core::local::{self, Db};
use thoughtsync_core::sync::blobs::BlobStore; use inkwell_core::sync::blobs::BlobStore;
use thoughtsync_core::sync::{client, compat, engine, push, state}; use inkwell_core::sync::{client, compat, engine, push, sharing, state};
use models::{ use models::{
patch_from, BodyItem, BodyTag, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, patch_from, BodyItem, BodyTag, ClientUpdate, Directory, Identity, Label, Note, NoteDraft,
NoteQuery, ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus, NoteEdit, NoteQuery, NoteShare, ProbeResult, RevokeOutcome, ShareTarget, SyncOutcome,
SyncStatus,
}; };
uniffi::setup_scaffolding!(); uniffi::setup_scaffolding!();
@@ -108,30 +109,30 @@ impl CoreError {
/// behind its mutex, the blob store being a path — which is what lets uniffi share /// behind its mutex, the blob store being a path — which is what lets uniffi share
/// one instance across coroutines. /// one instance across coroutines.
#[derive(uniffi::Object)] #[derive(uniffi::Object)]
pub struct ThoughtSync { pub struct Inkwell {
db: Db, db: Db,
blobs: BlobStore, blobs: BlobStore,
} }
#[uniffi::export] #[uniffi::export]
impl ThoughtSync { impl Inkwell {
/// Open (creating on first run) the store under `data_dir`, and the attachment /// Open (creating on first run) the store under `data_dir`, and the attachment
/// directory beside it. /// directory beside it.
/// ///
/// `data_dir` comes from Kotlin because only Android knows where its app-private /// `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 /// 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. /// `blobs/` — so a store is readable by any client that opens it.
#[uniffi::constructor] #[uniffi::constructor]
pub fn new(data_dir: String) -> Result<Arc<Self>, CoreError> { pub fn new(data_dir: String) -> Result<Arc<Self>, CoreError> {
let dir = PathBuf::from(data_dir); let dir = PathBuf::from(data_dir);
std::fs::create_dir_all(&dir).map_err(CoreError::store)?; 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)); log::info!("local store ready — {}", local::summary(&db));
let blobs = BlobStore::new(dir.join("blobs")).map_err(CoreError::store)?; 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. /// A one-line count summary, for the boot log.
@@ -222,52 +223,38 @@ impl ThoughtSync {
local::store::delete_forever(&conn, &id).map_err(CoreError::store) local::store::delete_forever(&conn, &id).map_err(CoreError::store)
} }
// ──────────────────────────── checklist items ──────────────────────────── /// Put the board in this order, first id on top: the desktop's `notes_reorder`.
//
// Every one of these returns the whole reloaded note rather than the item it
// touched. That is the core's shape, and it is the right one for a UI: ticking
// a box changes `updated_at` and can change what the board shows, so handing
// back only the item would leave Kotlin to guess at the rest.
pub fn add_item(&self, note_id: String, text: String) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::add_item(&conn, &note_id, &text)
.map(Note::from)
.map_err(CoreError::store)
}
/// Retitle one item.
/// ///
/// Split from `set_item_checked` rather than exposing the core's /// Returns nothing, as the board reloads its list afterwards anyway. The core marks
/// `{text?, checked?}` patch, for the same reason `NoteEdit` exists: an /// each note dirty so the new positions sync, and a note shared with this account
/// optional-field struct cannot say "leave this alone" in Kotlin without /// moves on this board only (#5176).
/// colliding with "set it to null", and two unambiguous calls beat one pub fn reorder_notes(&self, ordered_ids: Vec<String>) -> Result<(), CoreError> {
/// ambiguous one when each is three lines. let conn = self.db.conn().map_err(CoreError::store)?;
pub fn set_item_text( local::store::reorder(&conn, &ordered_ids).map_err(CoreError::store)
&self,
note_id: String,
item_id: String,
text: String,
) -> Result<Note, CoreError> {
self.patch_item(&note_id, &item_id, serde_json::json!({ "text": text }))
} }
// ──────────────────────────── checklist items ────────────────────────────
/// Tick or untick one item. Adding, rewording and removing items are edits to
/// the body, which the editor makes like any other.
///
/// Returns the whole reloaded note rather than the item it touched. That is the
/// core's shape, and it is the right one for a UI: ticking a box changes
/// `updated_at` and can change what the board shows, so handing back only the
/// item would leave Kotlin to guess at the rest.
pub fn set_item_checked( pub fn set_item_checked(
&self, &self,
note_id: String, note_id: String,
item_id: String, item_id: String,
checked: bool, checked: bool,
) -> Result<Note, CoreError> { ) -> Result<Note, CoreError> {
self.patch_item( let conn = self.db.conn().map_err(CoreError::store)?;
local::store::update_item(
&conn,
&note_id, &note_id,
&item_id, &item_id,
serde_json::json!({ "checked": checked }), &serde_json::json!({ "checked": checked }),
) )
}
pub fn delete_item(&self, note_id: String, item_id: String) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::delete_item(&conn, &note_id, &item_id)
.map(Note::from) .map(Note::from)
.map_err(CoreError::store) .map_err(CoreError::store)
} }
@@ -333,6 +320,115 @@ impl ThoughtSync {
.map_err(CoreError::store) .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 ──────────────────────────────── // ─────────────────────────────── sync ────────────────────────────────
pub fn sync_status(&self) -> Result<SyncStatus, CoreError> { pub fn sync_status(&self) -> Result<SyncStatus, CoreError> {
@@ -354,7 +450,7 @@ impl ThoughtSync {
/// functions. Split into its own impl block so the runtime attribute — and the fact /// 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. /// that everything in here touches the network — is visible at a glance.
#[uniffi::export(async_runtime = "tokio")] #[uniffi::export(async_runtime = "tokio")]
impl ThoughtSync { impl Inkwell {
/// Ask a server who it is, without committing to anything. Called as the user /// 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 /// finishes typing an address, so they see what answered before handing over
/// credentials. /// credentials.
@@ -466,7 +562,7 @@ impl ThoughtSync {
/// ///
/// Takes the destination rather than choosing one: only Android knows a /// Takes the destination rather than choosing one: only Android knows a
/// directory its own package installer can read from, and the core has no /// 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. /// takes a data dir.
pub async fn download_client_update(&self, dest_path: String) -> Result<(), CoreError> { pub async fn download_client_update(&self, dest_path: String) -> Result<(), CoreError> {
let (base_url, token) = self.credentials()?; let (base_url, token) = self.credentials()?;
@@ -497,6 +593,55 @@ impl ThoughtSync {
.map(SyncOutcome::from) .map(SyncOutcome::from)
.map_err(CoreError::network) .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, and every group.
pub async fn share_directory(&self) -> Result<Directory, CoreError> {
self.credentials()?;
let directory = sharing::directory(&self.db)
.await
.map_err(CoreError::network)?;
Ok(directory.into())
}
/// 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 a person or a group at "view" or "edit", or change their permission.
pub async fn share_note(
&self,
note_id: String,
target: ShareTarget,
permission: String,
) -> Result<Vec<NoteShare>, CoreError> {
self.credentials()?;
let shares = sharing::share(&self.db, &note_id, &target.into(), &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 ─────────────────────────────────────── // ── checklist text, as pure functions ───────────────────────────────────────
@@ -520,6 +665,20 @@ pub fn checklist_render(text: String, checked: bool) -> String {
local::derive::render_item(&text, checked) 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 /// 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. /// walking the body line by line knows which lines are boxes and what is in them.
#[uniffi::export] #[uniffi::export]
@@ -547,23 +706,7 @@ pub fn body_tags(body: String) -> Vec<BodyTag> {
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]` /// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
/// block names, so these stay Rust-side. /// 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 +
/// convert + map-error dance around it is identical, so it lives once here.
fn patch_item(
&self,
note_id: &str,
item_id: &str,
changes: serde_json::Value,
) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::update_item(&conn, note_id, item_id, &changes)
.map(Note::from)
.map_err(CoreError::store)
}
/// The server URL + token, or the `NotLinked` state. Every networked call needs /// The server URL + token, or the `NotLinked` state. Every networked call needs
/// exactly this, and none of them may hold the lock past it. /// exactly this, and none of them may hold the lock past it.
fn credentials(&self) -> Result<(String, String), CoreError> { fn credentials(&self) -> Result<(String, String), CoreError> {
@@ -607,7 +750,7 @@ mod tests {
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
static NEXT: AtomicU32 = AtomicU32::new(0); static NEXT: AtomicU32 = AtomicU32::new(0);
let dir = std::env::temp_dir().join(format!( let dir = std::env::temp_dir().join(format!(
"thoughtsync-ffi-{}-{}", "inkwell-ffi-{}-{}",
std::process::id(), std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed) NEXT.fetch_add(1, Ordering::Relaxed)
)); ));
@@ -628,7 +771,7 @@ mod tests {
#[test] #[test]
fn creates_a_store_and_round_trips_a_note() { fn creates_a_store_and_round_trips_a_note() {
let dir = scratch_dir(); 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 let created = app
.create_note(draft("Groceries\nmilk")) .create_note(draft("Groceries\nmilk"))
@@ -650,7 +793,7 @@ mod tests {
#[test] #[test]
fn a_note_is_named_by_its_first_line() { fn a_note_is_named_by_its_first_line() {
let dir = scratch_dir(); 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 let created = app
.create_note(draft("just a thought")) .create_note(draft("just a thought"))
@@ -665,7 +808,7 @@ mod tests {
#[test] #[test]
fn a_note_with_only_items_is_named_by_its_first_item() { fn a_note_with_only_items_is_named_by_its_first_item() {
let dir = scratch_dir(); 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 let created = app
.create_note(NoteDraft { .create_note(NoteDraft {
@@ -684,7 +827,7 @@ mod tests {
#[test] #[test]
fn syncing_unlinked_reports_not_linked() { fn syncing_unlinked_reports_not_linked() {
let dir = scratch_dir(); 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"); let status = app.sync_status().expect("status should read");
assert!(!status.linked); assert!(!status.linked);
@@ -695,55 +838,32 @@ mod tests {
std::fs::remove_dir_all(&dir).ok(); std::fs::remove_dir_all(&dir).ok();
} }
/// The editor's whole checklist loop, in one pass: add a row, tick it, retitle /// Ticking a box returns the reloaded note, which is what the UI splices back
/// it, drop it. Each call returns the reloaded note, which is what the UI /// into the board rather than re-querying, and leaves the item's text alone.
/// splices back into the board rather than re-querying.
#[test] #[test]
fn checklist_items_can_be_added_ticked_retitled_and_removed() { fn ticking_an_item_rewrites_only_its_box() {
let dir = scratch_dir(); 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 let note = app
.create_note(NoteDraft { .create_note(NoteDraft {
body: "Packing".to_string(), body: "Packing".to_string(),
items: Some(vec!["socks".to_string()]), items: Some(vec!["socks".to_string(), "charger".to_string()]),
}) })
.expect("create"); .expect("create");
assert_eq!(note.items.len(), 1); assert_eq!(note.items.len(), 2);
let with_two = app let item_id = note.items[1].id.clone();
.add_item(note.id.clone(), "charger".to_string())
.expect("add");
assert_eq!(with_two.items.len(), 2);
// Appended, not prepended — a new row belongs at the bottom of the list the
// user is looking at.
assert_eq!(with_two.items[1].text, "charger");
let item_id = with_two.items[1].id.clone();
let ticked = app let ticked = app
.set_item_checked(note.id.clone(), item_id.clone(), true) .set_item_checked(note.id.clone(), item_id.clone(), true)
.expect("tick"); .expect("tick");
assert!(ticked.items[1].checked); assert!(ticked.items[1].checked);
assert_eq!( assert!(!ticked.items[0].checked);
ticked.items[1].text, "charger", assert_eq!(ticked.items[1].text, "charger");
"ticking a box must not disturb its text — both setters rewrite the \
same line of the body now, so one clobbering the other is a live risk \
rather than a theoretical one"
);
let renamed = app let unticked = app
.set_item_text(note.id.clone(), item_id.clone(), "usb-c cable".to_string()) .set_item_checked(note.id.clone(), item_id, false)
.expect("rename"); .expect("untick");
assert_eq!(renamed.items[1].text, "usb-c cable"); assert_eq!(unticked.body, note.body);
assert!(
renamed.items[1].checked,
"and the same in the other direction"
);
let trimmed = app
.delete_item(note.id.clone(), item_id)
.expect("delete item");
assert_eq!(trimmed.items.len(), 1);
assert_eq!(trimmed.items[0].text, "socks");
std::fs::remove_dir_all(&dir).ok(); std::fs::remove_dir_all(&dir).ok();
} }
@@ -755,7 +875,7 @@ mod tests {
#[test] #[test]
fn setting_labels_leaves_tag_derived_ones_alone() { fn setting_labels_leaves_tag_derived_ones_alone() {
let dir = scratch_dir(); 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 let note = app
.create_note(draft("Trip\nbook the ferry #travel")) .create_note(draft("Trip\nbook the ferry #travel"))
@@ -796,7 +916,7 @@ mod tests {
#[test] #[test]
fn deleting_forever_removes_the_note() { fn deleting_forever_removes_the_note() {
let dir = scratch_dir(); 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"); let note = app.create_note(draft("Ephemeral\nbody")).expect("create");
app.delete_note_forever(note.id.clone()) app.delete_note_forever(note.id.clone())
@@ -809,11 +929,61 @@ mod tests {
std::fs::remove_dir_all(&dir).ok(); 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. /// Snooze writes a future instant from the CORE's clock; complete clears it.
#[test] #[test]
fn reminders_can_be_snoozed_and_completed() { fn reminders_can_be_snoozed_and_completed() {
let dir = scratch_dir(); 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"); let note = app.create_note(draft("Call back")).expect("create");
assert_eq!(note.remind_at, None); assert_eq!(note.remind_at, None);
@@ -839,7 +1009,7 @@ mod tests {
#[test] #[test]
fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() { fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() {
let dir = scratch_dir(); 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 note = app.create_note(draft("Water the plants")).expect("create");
let armed = app let armed = app
@@ -892,6 +1062,81 @@ mod tests {
std::fs::remove_dir_all(&dir).ok(); 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 /// 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. /// crate's dev-dependencies to assert one field is well-formed.
fn chrono_free_parse(raw: &str) -> usize { fn chrono_free_parse(raw: &str) -> usize {
+163 -20
View File
@@ -1,6 +1,6 @@
//! The types that cross into Kotlin. //! 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 //! 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 //! 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 //! 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` //! what to do with it. That is the entire reason for the `let Core { .. } = value`
//! style here; please keep it. //! style here; please keep it.
use thoughtsync_core::local::models as core_models; use inkwell_core::local::models as core_models;
use thoughtsync_core::sync::client as core_client; use inkwell_core::sync::client as core_client;
use thoughtsync_core::sync::compat as core_compat; use inkwell_core::sync::compat as core_compat;
use thoughtsync_core::sync::engine as core_engine; use inkwell_core::sync::engine as core_engine;
use thoughtsync_core::sync::pull as core_pull; use inkwell_core::sync::pull as core_pull;
use thoughtsync_core::sync::push as core_push; use inkwell_core::sync::push as core_push;
use thoughtsync_core::sync::state as core_state; use inkwell_core::sync::state as core_state;
/// A note, with everything needed to render a card or open the editor. /// 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 previews: Vec<LinkPreview>,
pub created_at: Option<String>, pub created_at: Option<String>,
pub updated_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. /// A checklist item as it sits in a note's body.
@@ -60,9 +74,9 @@ pub struct BodyItem {
pub checked: bool, pub checked: bool,
} }
impl From<thoughtsync_core::local::derive::DerivedItem> for BodyItem { impl From<inkwell_core::local::derive::DerivedItem> for BodyItem {
fn from(i: thoughtsync_core::local::derive::DerivedItem) -> Self { fn from(i: inkwell_core::local::derive::DerivedItem) -> Self {
let thoughtsync_core::local::derive::DerivedItem { let inkwell_core::local::derive::DerivedItem {
text, text,
checked, checked,
line, line,
@@ -88,9 +102,9 @@ pub struct BodyTag {
pub name: String, pub name: String,
} }
impl From<thoughtsync_core::local::derive::DerivedTag> for BodyTag { impl From<inkwell_core::local::derive::DerivedTag> for BodyTag {
fn from(t: thoughtsync_core::local::derive::DerivedTag) -> Self { fn from(t: inkwell_core::local::derive::DerivedTag) -> Self {
let thoughtsync_core::local::derive::DerivedTag { let inkwell_core::local::derive::DerivedTag {
line, line,
start, start,
end, end,
@@ -121,12 +135,12 @@ pub struct ClientUpdate {
pub size: i64, pub size: i64,
} }
impl From<thoughtsync_core::sync::client::ClientRelease> for ClientUpdate { impl From<inkwell_core::sync::client::ClientRelease> for ClientUpdate {
fn from(r: thoughtsync_core::sync::client::ClientRelease) -> Self { fn from(r: inkwell_core::sync::client::ClientRelease) -> Self {
// Destructured exhaustively, like every other conversion in this file: a // Destructured exhaustively, like every other conversion in this file: a
// field added upstream stops this compiling until Android is told what to // field added upstream stops this compiling until Android is told what to
// do with it, which turns silent drift into a build error. // 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,
version_code, version_code,
size, size,
@@ -167,6 +181,8 @@ pub struct Attachment {
pub mime: String, pub mime: String,
pub size: Option<i64>, pub size: Option<i64>,
pub sha256: Option<String>, 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)] #[derive(Debug, Clone, uniffi::Record)]
@@ -199,6 +215,9 @@ impl From<core_models::Note> for Note {
previews, previews,
created_at, created_at,
updated_at, updated_at,
permission,
shared,
shared_by,
} = value; } = value;
Note { Note {
id, id,
@@ -217,6 +236,12 @@ impl From<core_models::Note> for Note {
previews: previews.into_iter().map(LinkPreview::from).collect(), previews: previews.into_iter().map(LinkPreview::from).collect(),
created_at, created_at,
updated_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, mime,
size, size,
sha256, sha256,
upload_error,
} = value; } = value;
Attachment { Attachment {
id, id,
@@ -272,6 +298,7 @@ impl From<core_models::Attachment> for Attachment {
mime, mime,
size, size,
sha256, sha256,
upload_error,
} }
} }
} }
@@ -342,10 +369,11 @@ pub struct NoteQuery {
pub struct NoteFacets { pub struct NoteFacets {
pub q: Option<String>, pub q: Option<String>,
pub label: Option<Vec<String>>, pub label: Option<Vec<String>>,
pub has_reminder: Option<bool>,
pub has_attachment: Option<bool>, pub has_attachment: Option<bool>,
pub created_after: Option<String>, pub created_after: Option<String>,
pub created_before: 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 { impl From<NoteQuery> for core_models::ListQuery {
@@ -370,18 +398,18 @@ impl From<NoteFacets> for core_models::Facets {
let NoteFacets { let NoteFacets {
q, q,
label, label,
has_reminder,
has_attachment, has_attachment,
created_after, created_after,
created_before, created_before,
shared,
} = value; } = value;
core_models::Facets { core_models::Facets {
q, q,
label, label,
has_reminder,
has_attachment, has_attachment,
created_after, created_after,
created_before, created_before,
shared,
} }
} }
} }
@@ -582,6 +610,113 @@ 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,
}
}
}
/// A group the admin made, which a note can be shared with (#5177).
#[derive(Debug, Clone, uniffi::Record)]
pub struct ShareGroup {
pub id: String,
pub name: String,
pub member_count: u32,
}
impl From<core_client::ShareGroup> for ShareGroup {
fn from(value: core_client::ShareGroup) -> Self {
let core_client::ShareGroup {
id,
name,
member_count,
} = value;
ShareGroup {
id,
name,
member_count,
}
}
}
/// Everyone a note can be shared with: the other people, and every group.
#[derive(Debug, Clone, uniffi::Record)]
pub struct Directory {
pub members: Vec<Member>,
pub groups: Vec<ShareGroup>,
}
impl From<core_client::Directory> for Directory {
fn from(value: core_client::Directory) -> Self {
let core_client::Directory { members, groups } = value;
Directory {
members: members.into_iter().map(Member::from).collect(),
groups: groups.into_iter().map(ShareGroup::from).collect(),
}
}
}
/// Who a new share goes to: one person, or a group.
#[derive(Debug, Clone, uniffi::Enum)]
pub enum ShareTarget {
Member { id: String },
Group { id: String },
}
impl From<ShareTarget> for core_client::ShareTarget {
fn from(value: ShareTarget) -> Self {
match value {
ShareTarget::Member { id } => core_client::ShareTarget::Member(id),
ShareTarget::Group { id } => core_client::ShareTarget::Group(id),
}
}
}
/// One share of a note, at "view" or "edit": to a person or to a group, never both.
#[derive(Debug, Clone, uniffi::Record)]
pub struct NoteShare {
pub id: String,
pub member: Option<Member>,
pub group: Option<ShareGroup>,
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,
group,
permission,
created_at: _,
} = value;
NoteShare {
id,
member: member.map(Member::from),
group: group.map(ShareGroup::from),
permission,
}
}
}
/// What became of this device's token on the server during an unlink. /// 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 /// Separate from the local result because the local half always succeeds and the
@@ -637,6 +772,10 @@ pub struct PushSummary {
/// realistic case). Silently retrying forever would be the wrong shape. /// realistic case). Silently retrying forever would be the wrong shape.
pub rejected: u64, pub rejected: u64,
pub errors: Vec<String>, 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)] #[derive(Debug, Clone, uniffi::Record)]
@@ -668,6 +807,8 @@ impl From<core_push::PushSummary> for PushSummary {
noop, noop,
rejected, rejected,
errors, errors,
uploaded,
upload_failed,
} = value; } = value;
PushSummary { PushSummary {
batches: batches as u64, batches: batches as u64,
@@ -678,6 +819,8 @@ impl From<core_push::PushSummary> for PushSummary {
noop: noop as u64, noop: noop as u64,
rejected: rejected as u64, rejected: rejected as u64,
errors, 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 # 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] [bindings.kotlin]
package_name = "com.fabledsword.thoughtsync.core" package_name = "com.fabledsword.inkwell.core"
cdylib_name = "thoughtsync_ffi" cdylib_name = "inkwell_ffi"
+1 -1
View File
@@ -18,7 +18,7 @@ dependencyResolutionManagement {
mavenCentral() mavenCentral()
} }
} }
rootProject.name = "ThoughtSync" rootProject.name = "Inkwell"
// `ffi/` sits beside `app/` but is deliberately NOT a Gradle module: it is a Rust // `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 // 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) > Spec lives in [`docs/process.md`](https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md)
> in the CI-Runner repo. > in the CI-Runner repo.
@@ -39,28 +39,26 @@ entirely on `ci-python:3.14`.
install` cold cost is a non-blocker. install` cold cost is a non-blocker.
- Build gates on `typecheck` + `lint` only. The `test` job runs in parallel for - 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 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). Postgres service lane in CI yet).
- `dev` push -> `:dev` + `:<sha>`; `v*` tag -> `:latest` + `:<version>` + `:<sha>` - `dev` push -> `:dev` + `:<sha>`; `v*` tag -> `:latest` + `:<version>` + `:<sha>`
(family rule 46). (family rule 46).
- The production runtime `Dockerfile` tracks python:3.12 so test results stay - The production runtime `Dockerfile` tracks python:3.12 so test results stay
representative of the deployed image. representative of the deployed image.
- **Artifacts — use the mirrored upload action, never `actions/upload-artifact`.** - **Artifacts — stock `actions/upload-artifact@v7`, never `@v3`.**
```yaml ```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 Stock works on this forge since the runner moved to gitea/runner 3.x, which
no server-side change will help: its `isGhes()` rejects any hostname that isn't edits the action's client-side `isGhes()` refusal out of its bundle. Proven on
`github.com` / `*.ghe.com` / `*.localhost` and throws before it opens a 2026-09-10 for upload-artifact v4–v7 and download-artifact v4–v8 (Scribe spike
connection, so the server is never asked what it supports. `@v3` is worse — it #3843). Until then this repo pinned a SHA mirror of the Forgejo project's
reports success, and Gitea then serves artifacts back only through the v4 API fork, because upstream threw on the hostname before it opened a connection.
(`content_encoding = application/zip`), so a v3 upload is stored but invisible
to every retrieval path. A green job producing nothing retrievable.
`bvandeusen/upload-artifact` is our pull mirror of `forgejo/upload-artifact` `@v3` is still broken: it reports success, and Gitea serves artifacts back only
(the Forgejo project's fork, one commit on upstream v5.0.0 disabling that through the v4 API (`content_encoding = application/zip`), so a v3 upload is
check). Mirrored so CI depends on a commit we hold; pinned by SHA because the stored but invisible to every retrieval path. A green job producing nothing
mirror auto-syncs and a moved upstream tag would otherwise change what runs. retrievable.
Both desktop upload steps also set `if-no-files-found: error` and carry **no** 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 `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. `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 - **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 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 - **`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 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. 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) ## Android lane — being rebuilt (M12)
The Tauri-mobile Android lane is gone. Android is a native Kotlin/Compose client 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. decision and milestone M12 for the arc.
The image it will run on already exists: **`ci-rust-android:1.97`**, repurposed 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 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 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 mismatched Rust minor across the lanes would mean divergent resolution for no
reason. Bump the three together or not at all. 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 **This reproduces CI exactly, not approximately.** On the 2026-08-18 run the
local test binary hashes (`thoughtsync_core-bbaae79723888ad1`, local test binary hashes (`thoughtsync_core-bbaae79723888ad1`,
`thoughtsync_desktop_lib-9d162263f8d0aca3`, `thoughtsync_ffi-fc557b96dc795e27`) `thoughtsync_desktop_lib-9d162263f8d0aca3`, `thoughtsync_ffi-fc557b96dc795e27`,
matched CI run 3931's byte for byte. Same image, same lockfile, same units. 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 `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 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 chosen. Commit the result in the same change as the `Cargo.toml` edit — a
manifest change pushed without it fails the gate. 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.
``` The tag used to be `dev`, which shadowed the branch of the same name: once a clone
error: src refspec dev matches more than one 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 **Transitional, from 2026-09-10:** the old `dev` release still exists so desktop
never moves — Fabled-Git has no `/releases/latest/download/<asset>` route, so the apps installed from it can update across — the manifest job writes `latest.json`
updater needs a permanent URL). Once that tag is fetched locally, the short name to it too (`BRIDGE_TAG=dev` in `desktop.yml`). Until that release and its tag are
`dev` resolves to both `refs/heads/dev` and `refs/tags/dev`. Fully qualify it: deleted, fully qualify pushes:
``` ```
git push origin refs/heads/dev:refs/heads/dev git push origin refs/heads/dev:refs/heads/dev
+11 -2
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "thoughtsync-core" name = "inkwell-core"
version = "0.1.0" 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"] authors = ["bvandeusen"]
edition = "2021" 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"] } reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] }
# Verifying downloaded attachment bytes against the sha256 the server advertised. # Verifying downloaded attachment bytes against the sha256 the server advertised.
sha2 = "0.10" 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 # 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. # 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; //! 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 //! the Android client binds it through uniffi. Neither owns it, and a change to
+118 -111
View File
@@ -2,8 +2,10 @@
//! computes on save. Pure string scanning (no regex dependency), kept in lockstep //! computes on save. Pure string scanning (no regex dependency), kept in lockstep
//! with the frontend's inline rules (see frontend notes/markdown.ts): //! with the frontend's inline rules (see frontend notes/markdown.ts):
//! //!
//! - `#tag`: `#` at a word boundary followed by tag characters (letter first). //! - `#tag`: `#` at the start of a line or after whitespace, then a letter, then
//! On save these become labels attached with `via_tag = true`. //! 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 //! - `- [ ] 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 //! table of items beside it, so a list can sit between two paragraphs instead of
//! only after them. //! only after them.
@@ -29,7 +31,11 @@ fn line_tags(chars: &[char]) -> Vec<(usize, usize, String)> {
let mut i = 0; let mut i = 0;
while i < chars.len() { while i < chars.len() {
if chars[i] == '#' { 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). // 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() { if boundary && i + 1 < chars.len() && chars[i + 1].is_alphabetic() {
let mut j = i + 1; let mut j = i + 1;
@@ -46,22 +52,6 @@ fn line_tags(chars: &[char]) -> Vec<(usize, usize, String)> {
out out
} }
/// Extract every `#tag` name (without the leading `#`) from `body`.
///
/// Line by line, which changes nothing: a line start and a `\n` are both boundaries,
/// so the same tags come out. It means there is ONE scanner rather than two — this and
/// [`lift_standalone_tags`] cannot disagree about what a tag is.
pub fn extract_tags(body: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for line in body.split('\n') {
let chars: Vec<char> = line.chars().collect();
for (_, _, name) in line_tags(&chars) {
push_unique(&mut out, &name);
}
}
out
}
/// One `#tag` and exactly where it sits, for a renderer drawing the body itself. /// One `#tag` and exactly where it sits, for a renderer drawing the body itself.
/// ///
/// The card no longer prints a chip for a tag whose text is still in the note — it /// The card no longer prints a chip for a tag whose text is still in the note — it
@@ -83,13 +73,13 @@ pub struct DerivedTag {
pub name: String, pub name: String,
} }
/// Every `#tag` in `body` with its position — the same scan [`extract_tags`] does, /// Every `#tag` in `body` with its position — the scan [`lift_standalone_tags`] does,
/// keeping the spans instead of throwing them away. /// keeping the spans instead of throwing them away.
/// ///
/// Not deduped, unlike `extract_tags`: two mentions of `#todo` are two pieces of text /// Not deduped: two mentions of `#todo` are two pieces of text to colour. Fences are
/// to colour. Fences are not skipped either, and that is deliberate — `extract_tags` /// not skipped either, and that is deliberate — a `#tag` inside a code block stays an
/// does not skip them, so a `#tag` inside a code block IS a label on the note, and a /// inline label on the note, and a renderer that left it plain would be the only
/// renderer that left it plain would be the only surface disagreeing. /// surface disagreeing.
pub fn extract_tag_spans(body: &str) -> Vec<DerivedTag> { pub fn extract_tag_spans(body: &str) -> Vec<DerivedTag> {
let mut out = Vec::new(); let mut out = Vec::new();
for (n, line) in body.split('\n').enumerate() { for (n, line) in body.split('\n').enumerate() {
@@ -377,65 +367,29 @@ pub fn extract_items(body: &str) -> Vec<DerivedItem> {
out out
} }
/// Rewrite the `index`-th task line, or drop it when `f` returns None. /// Tick or untick the `index`-th item, keeping its indent, bullet and text.
///
/// The only edit to an item that isn't typing in the body: adding, rewording and
/// deleting one are all text edits, which every client makes in its editor.
/// ///
/// A body with fewer task lines than that is returned UNCHANGED rather than /// A body with fewer task lines than that is returned UNCHANGED rather than
/// panicking: the index comes from a UI that may be a moment behind the store, and /// panicking: the index comes from a UI that may be a moment behind the store, and
/// a stale tap should do nothing rather than take the app down. /// a stale tap should do nothing rather than take the app down.
fn map_task_line<F>(body: &str, index: usize, f: F) -> String
where
F: FnOnce(&TaskLine<'_>) -> Option<String>,
{
let lines: Vec<&str> = body.split('\n').collect();
let mut target: Option<usize> = None;
let mut seen = 0usize;
for (n, line) in lines.iter().enumerate() {
if parse_task_line(line).is_some() {
if seen == index {
target = Some(n);
break;
}
seen += 1;
}
}
let target = match target {
Some(n) => n,
None => return body.to_string(),
};
let replacement = match parse_task_line(lines[target]) {
Some(parsed) => f(&parsed),
None => return body.to_string(),
};
let mut out: Vec<String> = Vec::with_capacity(lines.len());
for (n, line) in lines.iter().enumerate() {
if n != target {
out.push((*line).to_string());
} else if let Some(new_line) = &replacement {
out.push(new_line.clone());
}
// None at the target line drops it, which is `remove_item`.
}
out.join("\n")
}
/// Tick or untick the `index`-th item.
pub fn set_item_checked(body: &str, index: usize, checked: bool) -> String { pub fn set_item_checked(body: &str, index: usize, checked: bool) -> String {
map_task_line(body, index, |t| { let mut lines: Vec<String> = body.split('\n').map(str::to_string).collect();
Some(render_task_line(t.indent, t.bullet, checked, t.text)) let Some(line) = lines
}) .iter_mut()
} .filter(|l| parse_task_line(l.as_str()).is_some())
.nth(index)
/// Replace the text of the `index`-th item, keeping its state and its bullet. else {
pub fn set_item_text(body: &str, index: usize, text: &str) -> String { return body.to_string();
map_task_line(body, index, |t| { };
Some(render_task_line(t.indent, t.bullet, t.checked, text.trim())) let Some(t) = parse_task_line(line.as_str()) else {
}) return body.to_string();
} };
let ticked = render_task_line(t.indent, t.bullet, checked, t.text);
/// Delete the `index`-th item, line and all. *line = ticked;
pub fn remove_item(body: &str, index: usize) -> String { lines.join("\n")
map_task_line(body, index, |_| None)
} }
/// Add an item at the end of the body. /// Add an item at the end of the body.
@@ -470,10 +424,19 @@ pub fn append_item(body: &str, text: &str, checked: bool) -> String {
mod tests { mod tests {
use super::*; use super::*;
/// The tag names in `body`, once each — what the shared fixture lists.
fn tag_names(body: &str) -> Vec<String> {
let mut out = Vec::new();
for t in extract_tag_spans(body) {
push_unique(&mut out, &t.name);
}
out
}
#[test] #[test]
fn tags_basic() { fn tags_basic() {
assert_eq!( assert_eq!(
extract_tags("a #todo and #Work-item_2 here"), tag_names("a #todo and #Work-item_2 here"),
vec!["todo", "Work-item_2"] vec!["todo", "Work-item_2"]
); );
} }
@@ -481,17 +444,12 @@ mod tests {
#[test] #[test]
fn tags_require_letter_start_and_boundary() { fn tags_require_letter_start_and_boundary() {
// "#1" (digit) and an in-word "#" (email-ish) are not tags. // "#1" (digit) and an in-word "#" (email-ish) are not tags.
assert_eq!(extract_tags("#1 nope a#b no but #Yes"), vec!["Yes"]); assert_eq!(tag_names("#1 nope a#b no but #Yes ##no"), vec!["Yes"]);
}
#[test]
fn tags_dedupe_case_insensitive() {
assert_eq!(extract_tags("#Home #home #HOME"), vec!["Home"]);
} }
#[test] #[test]
fn empty_body() { fn empty_body() {
assert!(extract_tags("").is_empty()); assert!(extract_tag_spans("").is_empty());
} }
// ── tag spans, for the renderer that draws them in place ───────────────── // ── tag spans, for the renderer that draws them in place ─────────────────
@@ -517,16 +475,6 @@ mod tests {
assert_eq!((spans[0].start, spans[0].end), (3, 8)); assert_eq!((spans[0].start, spans[0].end), (3, 8));
} }
#[test]
fn tag_spans_agree_with_extract_tags_about_what_a_tag_is() {
let body = "#1 nope a#b no but #Yes ##no";
let names: Vec<String> = extract_tag_spans(body)
.into_iter()
.map(|t| t.name)
.collect();
assert_eq!(names, extract_tags(body));
}
// ── lifting standalone tags ────────────────────────────────────────────── // ── lifting standalone tags ──────────────────────────────────────────────
// //
// The MIRROR of `split_body_tags` in the server's notes/tags.py, case for case. // The MIRROR of `split_body_tags` in the server's notes/tags.py, case for case.
@@ -691,17 +639,6 @@ mod tests {
); );
} }
#[test]
fn set_text_keeps_state() {
assert_eq!(set_item_text("- [x] old", 0, "new"), "- [x] new");
}
#[test]
fn remove_takes_the_whole_line() {
let body = "keep\n- [ ] drop\n- [ ] stay";
assert_eq!(remove_item(body, 0), "keep\n- [ ] stay");
}
#[test] #[test]
fn append_spaces_like_the_exporter() { fn append_spaces_like_the_exporter() {
// Prose then a blank line then the list — byte-for-byte what // Prose then a blank line then the list — byte-for-byte what
@@ -749,16 +686,12 @@ mod tests {
// that arrives late should be inert, not fatal. // that arrives late should be inert, not fatal.
let body = "- [ ] only"; let body = "- [ ] only";
assert_eq!(set_item_checked(body, 7, true), body); assert_eq!(set_item_checked(body, 7, true), body);
assert_eq!(remove_item(body, 7), body);
assert_eq!(set_item_text(body, 7, "x"), body);
} }
#[test] #[test]
fn a_plain_body_is_returned_byte_identical() { fn a_plain_body_is_returned_byte_identical() {
let body = "just prose\nwith two lines"; let body = "just prose\nwith two lines";
assert_eq!(set_item_checked(body, 0, true), body); assert_eq!(set_item_checked(body, 0, true), body);
assert_eq!(set_item_text(body, 0, "x"), body);
assert_eq!(remove_item(body, 0), body);
} }
#[test] #[test]
@@ -770,4 +703,78 @@ mod tests {
assert_eq!(touched, body); assert_eq!(touched, body);
assert_eq!(extract_items(&touched), items); 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!(tag_names(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:?}"
);
}
}
} }
+4 -6
View File
@@ -6,6 +6,7 @@
pub mod derive; pub mod derive;
pub mod models; pub mod models;
pub mod portable;
pub mod recur; pub mod recur;
pub mod retention; pub mod retention;
pub mod schema; pub mod schema;
@@ -25,12 +26,9 @@ pub struct Db(pub Mutex<Connection>);
impl Db { impl Db {
/// Lock the store, reporting a poisoned lock as a message rather than a panic. /// Lock the store, reporting a poisoned lock as a message rather than a panic.
/// ///
/// Every consumer was writing `db.0.lock().map_err(|e| e.to_string())?` at each /// The one way every caller takes the lock. Returning the guard from here lets a
/// call site. Beyond the repetition, that spelling forces the caller to NAME /// caller bind it by inference without naming `rusqlite::Connection`, so layers
/// `rusqlite::Connection` in any helper that returns the guard — which would make /// above the store need not depend on what it is made of.
/// rusqlite a dependency of a layer whose whole point is not to know what the
/// store is made of. Returning it from here means callers can bind the guard by
/// inference and never name the type.
/// ///
/// A poisoned lock means some earlier call panicked while holding it. The store /// A poisoned lock means some earlier call panicked while holding it. The store
/// is not necessarily corrupt, but this connection can't be trusted blind, so it /// is not necessarily corrupt, but this connection can't be trusted blind, so it
+31 -9
View File
@@ -28,6 +28,20 @@ pub struct Note {
pub previews: Vec<LinkPreview>, pub previews: Vec<LinkPreview>,
pub created_at: Option<String>, pub created_at: Option<String>,
pub updated_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)] #[derive(Serialize)]
@@ -55,6 +69,10 @@ pub struct Attachment {
pub mime: String, pub mime: String,
pub size: Option<i64>, pub size: Option<i64>,
pub sha256: Option<String>, 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)] #[derive(Serialize)]
@@ -91,13 +109,17 @@ pub struct TitleEntry {
pub title: String, pub title: String,
} }
#[derive(Serialize)] /// A reminder that has come due, as the desktop's reminder worker needs it.
pub struct SavedFilter { #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DueReminder {
pub id: String, pub id: String,
pub name: String, pub title: String,
/// Mirrors NoteFacets — stored as a JSON blob, round-tripped opaquely. /// `remind_at` as stored. It names the occurrence: completing or snoozing a
pub params: serde_json::Value, /// reminder changes it, and that is how an announcer tells a new occurrence
pub position: i64, /// from one it has already announced.
pub remind_at: String,
/// The same instant, in milliseconds since the epoch.
pub due_ms: i64,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -116,7 +138,6 @@ pub struct User {
pub id: String, pub id: String,
pub email: String, pub email: String,
pub display_name: String, pub display_name: String,
pub email_verified: bool,
pub is_admin: bool, pub is_admin: bool,
} }
@@ -149,11 +170,12 @@ pub struct Facets {
#[serde(default)] #[serde(default)]
pub label: Option<Vec<String>>, pub label: Option<Vec<String>>,
#[serde(default)] #[serde(default)]
pub has_reminder: Option<bool>,
#[serde(default)]
pub has_attachment: Option<bool>, pub has_attachment: Option<bool>,
#[serde(default)] #[serde(default)]
pub created_after: Option<String>, pub created_after: Option<String>,
#[serde(default)] #[serde(default)]
pub created_before: Option<String>, 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. //! 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 //! behaviourally identical rather than merely similar. The same note can be
//! completed from the web (server code) or from the desktop and Android (this //! 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 //! 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 expired: Vec<String> = Vec::new();
{ {
let mut stmt = conn.prepare( 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([])?; let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? { while let Some(row) = rows.next()? {
+108 -37
View File
@@ -274,6 +274,63 @@ UPDATE saved_filters
AND params LIKE '%"color"%'; 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;
"#;
// v13 (#5180): saved views are gone. They never synced, so this device's set was its
// own and nothing else holds a copy.
const SCHEMA_V13: &str = r#"
DROP TABLE saved_filters;
"#;
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> { pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch("PRAGMA foreign_keys = ON;")?; conn.execute_batch("PRAGMA foreign_keys = ON;")?;
let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?; let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
@@ -313,6 +370,22 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(SCHEMA_V9)?; conn.execute_batch(SCHEMA_V9)?;
conn.execute_batch("PRAGMA user_version = 9;")?; 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;")?;
}
if version < 13 {
conn.execute_batch(SCHEMA_V13)?;
conn.execute_batch("PRAGMA user_version = 13;")?;
}
Ok(()) Ok(())
} }
@@ -427,7 +500,34 @@ mod tests {
let version: i64 = conn let version: i64 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0)) .query_row("PRAGMA user_version", [], |r| r.get(0))
.expect("version"); .expect("version");
assert_eq!(version, 9); assert_eq!(version, 13);
}
/// 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 /// The column is gone, not merely unread. Asserted by asking SQLite rather than by
@@ -454,46 +554,17 @@ mod tests {
assert!(label_columns.iter().any(|c| c == "color")); assert!(label_columns.iter().any(|c| c == "color"));
} }
/// A stored view that filtered on colour loses that criterion and keeps the rest.
/// The alternative — leaving the key — is a lens that silently narrows on a field
/// the app no longer has and never says why it returned nothing.
#[test] #[test]
fn v9_sweeps_colour_out_of_saved_filters() { fn v13_drops_saved_views() {
let conn = Connection::open_in_memory().expect("open"); let conn = Connection::open_in_memory().expect("open");
conn.execute_batch("PRAGMA foreign_keys = ON;").expect("fk");
for batch in [
SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4, SCHEMA_V5, SCHEMA_V6, SCHEMA_V7,
] {
conn.execute_batch(batch).expect("schema");
}
conn.execute_batch("PRAGMA user_version = 8;").expect("v8");
for (id, params) in [
("a", r#"{"color":"teal","q":"milk"}"#),
("b", r#"{"q":"eggs"}"#),
// Not JSON at all. It must come out UNCHANGED rather than NULL — a blob
// this migration cannot read is not a blob it gets to destroy.
("c", "not json"),
] {
conn.execute(
"INSERT INTO saved_filters (id, name, params, created_at)
VALUES (?1, ?1, ?2, '2026-08-28T00:00:00.000Z')",
params![id, params],
)
.expect("seed");
}
migrate(&conn).expect("migrate"); migrate(&conn).expect("migrate");
let tables: i64 = conn
let read = |id: &str| -> String { .query_row(
conn.query_row( "SELECT COUNT(*) FROM sqlite_master WHERE name = 'saved_filters'",
"SELECT params FROM saved_filters WHERE id = ?1", [],
[id],
|r| r.get(0), |r| r.get(0),
) )
.expect("read") .expect("count");
}; assert_eq!(tables, 0);
assert_eq!(read("a"), r#"{"q":"milk"}"#);
assert_eq!(read("b"), r#"{"q":"eggs"}"#);
assert_eq!(read("c"), "not json");
} }
} }
+1159 -166
View File
File diff suppressed because it is too large Load Diff
+32 -9
View File
@@ -78,6 +78,15 @@ impl BlobStore {
Ok(path) 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>> { pub fn read(&self, sha256: &str) -> Option<Vec<u8>> {
fs::read(self.path(sha256)?).ok() fs::read(self.path(sha256)?).ok()
} }
@@ -95,8 +104,9 @@ impl BlobStore {
// store. The webview then caches and range-requests them like any other resource, // store. The webview then caches and range-requests them like any other resource,
// which a `data:` URI would have thrown away. // which a `data:` URI would have thrown away.
/// The scheme the webview fetches attachment bytes over. /// The scheme the webview fetches attachment bytes over. Nothing stores a URL built
pub const BLOB_SCHEME: &str = "tsblob"; /// on it — `url_for` runs as each note is read — so renaming it costs nothing.
pub const BLOB_SCHEME: &str = "inkblob";
/// The blob directory, published once the app has resolved its data dir. /// The blob directory, published once the app has resolved its data dir.
/// ///
@@ -140,7 +150,9 @@ fn urlencode(value: &str) -> String {
out 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 bytes = value.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len()); let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0; let mut i = 0;
@@ -163,12 +175,14 @@ fn urldecode(value: &str) -> String {
/// ///
/// The mime rides in the URL and this scheme is an origin of its own, so echoing an /// 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 /// 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 /// there. Echoing is safe only for the families that can't carry script, and
/// `image/` can name a scriptable type. Everything else is served as an opaque /// `image/` is not quite one of them: `image/svg+xml` is a document that runs its own
/// download — the right treatment for an arbitrary file regardless. /// `<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 { fn content_type_for(mime: &str) -> String {
const RENDERABLE: &[&str] = &["image/", "audio/", "video/"]; 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 // 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. // 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()); let printable = mime.len() <= 100 && mime.bytes().all(|b| b.is_ascii_graphic());
@@ -266,9 +280,9 @@ mod tests {
// The platform split is the whole risk of this feature, and CI is headless, // The platform split is the whole risk of this feature, and CI is headless,
// so at least pin that the right branch was taken for THIS build. // so at least pin that the right branch was taken for THIS build.
if cfg!(any(windows, target_os = "android")) { if cfg!(any(windows, target_os = "android")) {
assert!(url.starts_with("http://tsblob.localhost/"), "{url}"); assert!(url.starts_with("http://inkblob.localhost/"), "{url}");
} else { } else {
assert!(url.starts_with("tsblob://localhost/"), "{url}"); assert!(url.starts_with("inkblob://localhost/"), "{url}");
} }
} }
@@ -295,6 +309,8 @@ mod tests {
let opaque = "application/octet-stream"; let opaque = "application/octet-stream";
assert_eq!(content_type_for("text/html"), opaque); assert_eq!(content_type_for("text/html"), opaque);
assert_eq!(content_type_for("application/javascript"), 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); assert_eq!(content_type_for(""), opaque);
// A control character can't reach a header value even under a safe family. // 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); assert_eq!(content_type_for("image/png\r\nX-Evil: 1"), opaque);
@@ -309,6 +325,13 @@ mod tests {
assert!(body.is_empty()); 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] #[test]
fn missing_blob_reads_as_none() { fn missing_blob_reads_as_none() {
let store = store("missing"); let store = store("missing");

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