149 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 f2b48069e3 The AppImage tree is readable by everyone before it is repackaged
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 3s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python lint (push) Successful in 1s
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
CI & Build / Python tests (push) Successful in 13s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m45s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m56s
Desktop (Tauri) / Update manifest (push) Successful in 4s
CI & Build / integration (push) Successful in 1m31s
CI & Build / Build & push image (push) Successful in 14s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 2m59s
appimagetool's continuous build from 2026-10-29 rejects a tree that not every
user can read and run, which failed the desktop Linux lane. Extraction keeps the
build's restrictive modes (0700 directories, a 0770 AppRun.wrapped), and an
AppImage runs as whichever user launches it, so the modes are corrected rather
than the check skipped.

Issue #6712.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:09:58 -04:00
bvandeusenandClaude Opus 5.5 0f787c6363 The board places each card in the shortest column, instead of leaving it to CSS columns
Android / Build, or is the channel already serving this? (push) Successful in 2s
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 / Web typecheck and unit tests (push) Successful in 8s
CI & Build / integration (push) Successful in 1m37s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m58s
CI & Build / Python tests (push) Successful in 13s
Android / Core and FFI clippy and tests (push) Successful in 40s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m38s
Android / Build the server image (push) Successful in 1s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m47s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 3m43s
Desktop (Tauri) / Update manifest (push) Skipped
CSS multi-column decides placement by balancing column heights, so the same
notes could land differently at the same width: an edit left two cards alone in
the fourth column with the middle two empty, and a refresh left the fourth empty
beside a stack of three in the first.

Cards now go, in board order, into the shortest column, leftmost on a tie, so the
same notes at the same width always land the same way and every column fills
before any stacks. This changes the reading order to match Android: the second
note sits top-right rather than under the first.

useMasonry measures the cards and watches them for height changes. Placement is a
pure function, tested in notes/masonry.test.ts. Cards are absolutely placed, so
TransitionGroup's move animation carries on working unchanged.

Issue #6709.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:56:58 -04:00
bvandeusenandClaude Opus 5.5 7fca1f6c7f A forwarding header shorter than the hop count is read from its leftmost entry
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
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) 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 17s
CI & Build / integration (push) Successful in 1m30s
CI & Build / Build & push image (push) Successful in 45s
At Trusted proxy hops 2 (Cloudflare → Traefik → app), a LAN request reaching
Traefik directly carries one X-Forwarded-For entry. trusted_entry returned None
for it, so every LAN visitor was filed under Traefik's overlay address in the
throttles and the audit log. Cloudflare's single X-Forwarded-Proto: https read
as plain HTTP, so the public road lost the Secure cookie and HSTS.

A short header now clamps to its leftmost entry, the one the first proxy wrote,
as family canon #5004 does in Minstrel, Librarian and Forge. A caller's own
entries can only make the header longer, so this trusts nothing more than before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 08:37:14 -04:00
bvandeusenandClaude Opus 5.5 b74dc7c1fe One app fixture for the tests, and ViewModels built where they are used
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
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 / Python tests (push) Successful in 13s
Android / Core and FFI clippy and tests (push) Successful in 25s
CI & Build / integration (push) Successful in 1m31s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m41s
Android / Build the server image (push) Successful in 1s
The tests' `app` fixture, the same two lines in eleven files, now lives once in
tests/conftest.py.

On Android each ViewModel carried a companion `factory` that only wrapped its
constructor in viewModelFactory { initializer { … } }. MainActivity now builds
each one with Compose's viewModel { … } builder, so the five factories and their
imports are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:25:05 -04:00
bvandeusenandClaude Opus 5.5 463b22bbe4 A push that loses a create race applies its change instead of failing
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
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 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 9s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 1m31s
CI & Build / Build & push image (push) Successful in 34s
Two devices pushing the same new note or tag at the same moment both found
no row and both inserted it; the slower insert hit the primary key and the
whole push answered 500. Each pushed note or label change now runs in a
savepoint. If its insert loses the race, only that change is rolled back,
and it is applied again against the row the other push committed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:34:04 -04:00
bvandeusenandClaude Opus 5.5 c6f10f157d Timeline shows archived notes too, each one labelled Archived
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
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 3s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 1m34s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m48s
CI & Build / Build & push image (push) Successful in 30s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m2s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m10s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Timeline lists everything not in Trash. The card's Archived label is now
hidden only on the Archive page, read off the route: Timeline keeps its
own list and never sets the board's view, so arriving from the Archive
used to leave the store saying "archived".

Task #6318.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:29:40 -04:00
bvandeusenandClaude Opus 5.5 66fd5707b7 tagQueryAt decides in one check, within detekt's return limit
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
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 12s
Android / Core and FFI clippy and tests (push) Successful in 25s
CI & Build / integration (push) Successful in 1m29s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m56s
Android / Build the server image (push) Successful in 1s
Task #6316.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:38:36 -04:00
bvandeusenandClaude Opus 5.5 7e96683f01 The archive-search tests sign in and keep the listed notes alive
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 8s
CI & Build / Python tests (push) Successful in 14s
Android / Core and FFI clippy and tests (push) Successful in 31s
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 1m52s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m5s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m44s
Android / Kotlin + Rust (APK) (push) Failing after 5m10s
Android / Build the server image (push) Skipped
Desktop (Tauri) / Update manifest (push) Successful in 4s
The integration test registers the owner before writing notes, and the
core test binds the "all" listing before borrowing ids from it.

Task #6315.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:28:01 -04:00
bvandeusenandClaude Opus 5.5 ba97003ea0 Android editor suggests existing tags while a #tag is typed
CI & Build / Python lint (push) Successful in 2s
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
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 / Core and FFI clippy and tests (push) Failing after 33s
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 1m11s
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) Failing after 1m29s
CI & Build / Build & push image (push) Skipped
The web editor's `#` suggestions, ported: with the caret in a `#tag`,
a row of chips under the field offers the existing tags that finish it,
prefix matches first and busiest first. A tap puts in the whole name and
a space. The rules are the web's tags.ts, with the same test cases.

Task #6316.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:23:08 -04:00
bvandeusenandClaude Opus 5.5 b98a0c315f Search and tags look through the Archive, and archived cards say so
A search or a tag filter on the main board, and a tag's own view, now
list every note not in Trash (a new `all` board view on the server and in
the core), so finished work can still be looked up. Archive and Trash
keep a search inside themselves.

An archived note met anywhere but the Archive carries an "Archived" chip
at the top of its card, on the web and on Android.

Task #6315.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:23:08 -04:00
bvandeusenandClaude Opus 5.5 5780220ff1 The image build stops pruning the runner's shared Docker daemon
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
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
CI & Build / Python lint (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
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 12s
CI & Build / integration (push) Successful in 1m25s
CI & Build / Build & push image (push) Successful in 34s
The build job ran `docker system prune -af` on the runner's shared Docker
daemon. That deletes every image no container is using at that moment,
including images other repos' jobs had just pulled and were about to start.
Jobs with a service container wait ~14s for it to become healthy between
pull and create, and fail with "No such image" when a prune lands inside
that wait (CI-runner #16). The runner host prunes nightly instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:20:33 -04:00
bvandeusenandClaude Opus 5.5 d1f8d1f561 The app links to its source, from an admin setting (#6228)
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
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 9s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 1m45s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m17s
CI & Build / Build & push image (push) Successful in 42s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m10s
Desktop (Tauri) / Update manifest (push) Successful in 3s
#6228 "The app offers its source: a Source link from an admin setting
(AGPL §13)". The AGPL requires a modified copy run for others over a
network to offer them its source; Forge's #2282 is the shape followed.

- Settings → General gains "Source code" (`source_url`), defaulting to
  the public repo so a stock install makes the offer. Empty hides it.
- It is an http(s)-only URL setting, refused otherwise on save, and
  `safe_link` refuses anything else again when it is published, because
  the web renders it as an href.
- /api/config carries it; the web shows "Source" beside the build number
  at the foot of the sidebar. The offline desktop's config has no such
  field, so the link stays hidden there.
- ci.yml's client-bake comments no longer call the forge private; the
  repo has been public since #2091.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:20:07 -04:00
bvandeusenandClaude Opus 5.5 06ed76814f Inkwell is licensed AGPL-3.0-only (#6227)
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
Android / Core and FFI clippy and tests (push) Successful in 55s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m25s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 2m20s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m29s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m27s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m17s
Android / Build the server image (push) Successful in 1s
#6227 "License Inkwell under AGPL-3.0-only: LICENSE file and SPDX
identifier". The operator chose AGPL-3.0-only, as for FabledStash (#6212).

- LICENSE is the canonical GNU AGPL v3 text from gnu.org: 661 lines, md5
  eb1e647870add0502f8f010b19de32af, the same file as FabledStash's.
- AGPL-3.0-only is declared in pyproject.toml, frontend/package.json (and
  its lockfile root entry), the four crates, and tauri.conf.json's bundle.
- The pacman package declared MIT; it now declares AGPL-3.0-only.
- pyproject's build floor moves to setuptools>=77, the first release that
  reads `license` as an SPDX expression.
- The README's Licence section names the licence, links LICENSE, and
  states the network clause in one sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:12:37 -04:00
bvandeusenandClaude Opus 5.5 b354bc4871 README quick-start keeps restart: unless-stopped
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
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) 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 / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 1m36s
CI & Build / Build & push image (push) Successful in 16s
f16224b dropped it from the inline compose file, reading rule 198
"Shipped compose files run on the orchestrator's defaults" as covering it.
The rule keeps plain Compose's restart: unless-stopped: Compose defaults
to `no`, so without it a crash or reboot leaves the stack down, and Swarm
ignores the key. docker-compose.yml already carries it (#6220).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:02:02 -04:00
bvandeusenandClaude Opus 5.5 f16224b07c Root, CI, docs and packaging describe the project to a stranger (#6225)
Android / Build, or is the channel already serving this? (push) Successful in 3s
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 / Web typecheck and unit tests (push) Successful in 12s
CI & Build / Python tests (push) Successful in 23s
Android / Core and FFI clippy and tests (push) Successful in 51s
CI & Build / integration (push) Successful in 1m49s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m20s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m46s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 8m31s
Android / Build the server image (push) Successful in 1s
#6225 "Comment standard sweep: root, config, CI workflows, docs and
packaging", milestone 546 "Bring the repo to the comment standard (rule 32)
and compose defaults (rule 198)".

- README restructured in newcomer order: what it is, quick start,
  configuration, setup pitfalls, updating and backups, features,
  troubleshooting, development, licence. Its quick-start compose carries
  no restart policy.
- Dockerfile and .env.example trimmed to one or two lines per setting.
- Workflows, packaging scripts and docs lose issue, run, note, rule and
  milestone references and their history narration. Comments that still
  described path filters and "step 6" as future work now state what the
  workflows do.
- ci-requirements.md rewritten against the current workflows. It said the
  build gated on typecheck and lint only, the integration lane did not
  gate, a v* tag published :latest, and ci-tauri lacked bsdtar and zstd;
  none of those is true now (the last checked against run 9630's log).
- Echo strings in guard-forward.sh and version.sh drop their note
  citations; every string a test asserts on is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:44:02 -04:00
bvandeusenandClaude Opus 5.5 01b53118b9 Core and desktop comments describe the code to a stranger (#6224)
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
CI & Build / Python tests (push) Successful in 12s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Android / Core and FFI clippy and tests (push) Successful in 1m3s
CI & Build / integration (push) Successful in 1m37s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m14s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m3s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m42s
Android / Build the server image (push) Successful in 1s
Milestone 546 "Bring the repo to the comment standard (rule 32) and
compose defaults (rule 198)", step #6224 "Comment standard sweep: Rust
core and desktop client".

Doc comments and comments across inkwell-core, the Tauri app and
desktop/packaging now say what the code does and the non-obvious why in
the present tense. Gone: milestone tags (M10.x, M11.3, M13, M304, M315,
M314, milestone 325), issue and Scribe-note numbers, family-idea and
family-rule citations, the run numbers and commit hashes behind past
bugs, and the narration of what used to be (the stable channel before it
held its own bundles, CARGO_PKG_VERSION as the shown version, the
MANIFEST_TAG split, the store's missing tests, the old PKGBUILD). The
schema's migration notes keep what each step does and why, without
"until now". The forge is called the forge rather than Forgejo.

Hazards stay as facts: the dev-rolling tag that must not shadow the
branch, the manifest matched on this build's version, the APK's fixed
name surviving the prune, the LIKE guard in v9, -wal/-shm moving with
the renamed store, panic = abort ending the app.

34 files, comments only: a comment-stripping diff of every changed file
against HEAD is identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:30:37 -04:00
bvandeusenandClaude Opus 5.5 0a631219ee Android comments describe the code to a stranger (#6223)
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 9s
CI & Build / Python tests (push) Successful in 15s
Android / Core and FFI clippy and tests (push) Successful in 27s
CI & Build / integration (push) Successful in 1m39s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m55s
Android / Build the server image (push) Successful in 6s
Milestone 546 "Bring the repo to the comment standard (rule 32) and
compose defaults (rule 198)", step #6223 "Comment standard sweep:
Android client".

KDoc, comments, manifest and resource notes, Gradle and TOML comments
and the ffi crate's docs now state what the code does and the
non-obvious why in the present tense. Issue, Scribe-note, milestone
(M13, M304, M315, M12) and family-idea references are gone, as are
comparisons to Minstrel and the narration of what used to be: the
capture sheet the editor replaced, the per-note colour story, the
operator's device-pass feedback, the bindgen-as-[[bin]] failure, the
0.60 edge alpha before it was re-solved, and the checker bugs behind
check-strings/check-symbols. Hazards stay as facts: the signing key that
can't be rotated, the alias that keeps the earlier name, PKCS12's single
password, unwind over abort, the UTC date-picker read, the
DerivedTint/colors.ts mirror.

One stale claim is gone: DerivedTint said the web had no test runner,
but notes/grammar.test.ts runs the same tint cases. detekt.yml said
"these two places" over a list of five.

49 files, comments only: a comment-stripping diff of every changed file
against HEAD is identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:24:52 -04:00
bvandeusenandClaude Opus 5.5 715b648b95 Web client comments describe the code to a stranger (#6222)
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
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 13s
CI & Build / integration (push) Successful in 1m40s
CI & Build / Build & push image (push) Successful in 35s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m20s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m36s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m21s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Milestone 546 "Bring the repo to the comment standard (rule 32) and
compose defaults (rule 198)", step #6222 "Comment standard sweep: web
client (frontend/)".

Comments, docstrings and template notes across frontend/ now say what
the code does and the non-obvious why as present-tense facts. Issue,
task, note, milestone and family-idea references are gone, along with
narration of what used to happen: the titles index's wiki-link origin
and its once-per-session load (#5165), the card-controls overlap a
phone showed (task 2697), the M7 and M10.9 labels, and the card
unification behind the reminders lens (task 1913). Hazards stay as
facts, among them LEAVE_MS matching the editor's leave duration, the
Android client twin that must not be widened, and keeping leavers in
flow in the multi-column masonry.

62 files, comments only: a comment-stripping diff of every changed file
against HEAD is identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:17:13 -04:00
bvandeusenandClaude Opus 5.5 a51a563fe7 Server comments describe the code to a stranger (#6221)
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
Android / Core and FFI clippy and tests (push) Skipped
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 13s
Android / Build the server image (push) Skipped
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 1m30s
CI & Build / Build & push image (push) Successful in 32s
Rule 32 "Comments describe the product to a stranger" sweep over
src/, alembic/ and tests/. Comments and docstrings now say what the code
does and the reason as a present-tense constraint, briefly.

Removed:
- issue, task, note, rule, milestone and family-idea references
- "used to" / before-and-after narration
- quotes of decisions and incident stories from the deploying instance
- paragraph-length arguments, where a line or two carries the fact

Kept every hazard as a present-tense fact, e.g. timing oracles, the
proxy hop direction, and migrations that must stay frozen.

Also:
- Three test names lose "still"/"used to"/"pre_m304" phrasing.
- pyproject's description drops "FabledSword family".

The history these comments carried stays in Scribe and in git log.
A comment-stripping diff (Scribe snippet #6226 "codeonly.py — prove a
diff changed only comments") shows no code changed apart from the
renamed tests and the description string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:07:47 -04:00
bvandeusenandClaude Opus 5.5 5556bb2136 Compose files run on the orchestrator's defaults (#6220)
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
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
CI & Build / Python lint (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
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 13s
CI & Build / integration (push) Successful in 1m34s
CI & Build / Build & push image (push) Successful in 29s
Shipped stacks drop every healthcheck and the depends_on
service_healthy wait, per rule 198 "Shipped compose files run on the
orchestrator's defaults: no healthcheck, no restart policy". A
pg_isready healthcheck turns slow storage into a killed database. The
app needs neither: inkwell.dbwait waits up to 60s for Postgres at
start, and the engine's pool_pre_ping retries per request.

- docker-compose.yml: db and app healthchecks removed; depends_on is
  a plain start order. Comments cut to one or two lines per setting
  under rule 32 "Comments describe the product to a stranger": the
  family/rule references are gone, and the three-way port explanation
  points at docs/public-hosting.md, which already holds it.
- docker-compose.dev.yml: db healthcheck and service_healthy removed;
  header shortened.
- README: the copy-paste stack loses the same two blocks.

restart: unless-stopped stays: plain Compose defaults to no restart,
and Swarm ignores the key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:45:46 -04:00
bvandeusenandClaude Opus 5.5 854cb4b403 Android: a hardware keyboard's arrow keys move the cursor (#6199)
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 / Web typecheck and unit tests (push) Successful in 12s
CI & Build / Python tests (push) Successful in 14s
Android / Core and FFI clippy and tests (push) Successful in 51s
CI & Build / integration (push) Successful in 1m50s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m8s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m59s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m38s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 8m22s
Android / Build the server image (push) Successful in 1s
Scribe #6199 "Android: a hardware keyboard's arrow keys move focus instead
of the cursor".

Compose's text field sends the arrow keys of any device reporting D-pad
keys to focus search before the field sees them, and a physical keyboard
reports exactly that, so left and right jumped to the neighbouring control.
Cancelling focus search in those directions hands the key back to the field.
Up and down move between a field's lines, and from its first or last line
on to the field above or below; single-line checklist rows still step row
to row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:14:32 -04:00
bvandeusenandClaude Opus 5.5 19487b079a Attached images open full size and copy back out (#6198)
Scribe #6198 "An attached image can't be viewed large or copied back out".

Web and desktop: clicking an image in the editor opens it full size, with
Copy image (re-encoded to PNG, the one type every clipboard accepts),
Download and Close. Copy hides where the browser has no async clipboard,
such as a server reached over plain http; right-clicking the full-size
image still copies there.

Android: long-pressing an image in the editor copies it to the clipboard;
a tap still opens it full size in the phone's viewer. Opening and copying
share one hand-out that copies the file out of the blob store behind a
FileProvider URI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:14:32 -04:00
bvandeusenandClaude Opus 5.5 142fafe0ed Android recent tags: reuse ui/Time.kt's epochMillis (#5931)
CI & Build / Python lint (push) Successful in 2s
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
Android / 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) / 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
Android / Core and FFI clippy and tests (push) Successful in 25s
CI & Build / integration (push) Successful in 1m30s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m40s
Android / Build the server image (push) Successful in 1s
RecentTags.kt declared a second epochMillis(String?): Long beside Time.kt's
epochMillis(String): Long?. A string literal resolved to the existing one, so
the test asserted 0 against its null.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:16:29 -04:00
bvandeusenandClaude Opus 5.5 7cb05b77c6 Android drawer: rememberTagViews lives with the recent-tags code (#5931)
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 15s
CI & Build / integration (push) Successful in 1m30s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
Android / Core and FFI clippy and tests (push) Successful in 26s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 4m29s
Android / Build the server image (push) Skipped
detekt's function-count limit on BoardScreen.kt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:07:09 -04:00
bvandeusenandClaude Opus 5.5 96e9fbfb3a Android drawer: tag-view tracking in its own composable (#5931)
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
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 / Python tests (push) Successful in 13s
Android / Core and FFI clippy and tests (push) Successful in 24s
CI & Build / integration (push) Successful in 1m28s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 3m32s
Android / Build the server image (push) Skipped
detekt's cyclomatic-complexity limit on BoardScreen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:58:39 -04:00
bvandeusenandClaude Opus 5.5 1a212e6779 Android drawer: keep the board screen's imports in order (#5931)
CI & Build / Build now, or wait for Android? (push) Successful in 2s
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
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 17s
Android / Core and FFI clippy and tests (push) Successful in 25s
CI & Build / integration (push) Successful in 1m29s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 3m46s
Android / Build the server image (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:51:10 -04:00
bvandeusenandClaude Opus 5.5 ea831d1857 Rail and drawer: only the 5 tags most recently used or viewed (#5931)
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 3s
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 14s
Android / Core and FFI clippy and tests (push) Successful in 1m9s
CI & Build / integration (push) Successful in 2m10s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 3m25s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m20s
Android / Kotlin + Rust (APK) (push) Failing after 6m3s
Android / Build the server image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m5s
Desktop (Tauri) / Update manifest (push) Successful in 4s
A long tag list pushed the drawer's Archive, Trash and Sync off screen on
Android, where Tags still sat above them (#5915 only reordered the web rail).
Tags now come last on Android too, and both clients list the five tags most
recently used or viewed, by name, with "All tags (N)" opening the rest in
place.

Used: the newest untrashed note carrying the tag. GET /api/labels and the
core's list_labels return it as last_used, and each client also folds in the
notes it holds, so a tag applied a moment ago counts before the next listing.
Viewed: opening the tag's lens, remembered on the device (per user on the web).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:41:33 -04:00
bvandeusenandClaude Opus 5.5 74d0d3d88e Email: the Inkwell logo in the header
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
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) 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 19s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / integration (push) Successful in 1m35s
CI & Build / Build & push image (push) Successful in 48s
The header showed only a yellow dot before the site name. It now shows the
app icon, embedded in the message as an inline part (cid:inkwell-logo, the
HTML part becoming multipart/related) rather than linked: most clients block
remote images, the app fetches nothing (rule 164), and Gmail drops SVG and
data: images. The PNG is a copy of frontend/public/icon-192.png, shipped as
package data; a test keeps the two identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:30:18 -04:00
bvandeusenandClaude Opus 5.5 c04e949c40 Stored credentials are encrypted at rest (#5927)
CI & Build / Build now, or wait for Android? (push) Successful in 2s
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 12s
CI & Build / integration (push) Successful in 1m40s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m44s
CI & Build / Build & push image (push) Successful in 38s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m18s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m9s
Desktop (Tauri) / Update manifest (push) Successful in 4s
#5927 "Adopt the family idea secret.Box": the SMTP password and the backup
bucket's secret key were kept as plain text in the settings table, so a copy
of the database alone could read them.

- New sealing.py: AES-GCM under an HKDF-derived key, each value bound to its
  setting's name. The key is a random file, /var/inkwell/sealing.key (0600),
  made on first use. It lives on the data volume, not in the database, which
  is where the signing secret sits by default.
- set_settings seals a secret before storing it; get_setting opens it. A value
  that no longer opens (key file lost) reads as not set, so Settings asks
  for it again.
- At startup, credentials stored before this are sealed in place, so nothing
  has to be re-entered. A data folder that can't be written never stops the
  server; saving a password then answers with the reason.
- New dependency: cryptography (approved by the operator).

Also #1919 "Deprecate / remove the PWA": kept. Its service worker caches
only the offline page, so an installed copy always runs the live build. One
stale comment in sw.js is corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:18:41 -04:00
bvandeusenandClaude Opus 5.5 edcb5c4c11 Backups: a daily dedupe-friendly tar to a folder or S3, with restore (#5918-#5921)
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 3s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 1m38s
CI & Build / Build & push image (push) Successful in 41s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m41s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m18s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m5s
Desktop (Tauri) / Update manifest (push) Successful in 3s
#5918 "Backup file": an uncompressed tar of per-table JSON Lines (primary-key
order, sorted keys) plus every attachment file, with fixed tar headers, a
dated name, and an atomic write through a dot-named .part file. Unchanged
rows are the same bytes every day, so restic off-site stores only the edits.
No credential ever enters a backup.

#5919 "Backup destinations": a folder, or an S3-compatible bucket through a
stdlib SigV4 put/list client (checked against AWS's published signatures),
with an upload limit. Test destination only lists, so an append-only key works.

#5920 "Backup job": daily at a chosen hour, Back up now, a 1/2/4 h retry
ladder, a one-hour deadline, an advisory lock, the last 30 runs, and an
overdue verdict on Settings -> Backups.

#5921 "Restore": python -m inkwell.restore FILE into an empty instance, an
integration test that backs up and restores, and docs/backups.md with
Fabled Archives as the worked example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:54:51 -04:00
bvandeusenandClaude Opus 5.5 c0de50d85d Email: one branded HTML layout with a text part, and invites can be emailed (#5917)
Each email was a subject and a plain-text f-string at its call site.
mail_templates.py now holds one layout: the site name, a heading, the
message, a button for the link and a footer saying who sent it and why.
Every email (password reset, test, invite) is a function returning its
subject, text and HTML. mailer.send sends them as multipart/alternative,
text first.

The HTML is email-shaped: tables, inline styles, no images or fonts, and
every value from settings or a person escaped.

POST /api/invites takes send_email. It is refused before anything is made
when email is off or the invite names no address. The link is built from
the public address. A failed send leaves the invite standing with
email_error, so the admin still gets the link once. The invite form offers
"Email the link to …" when email is on and an address is filled in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:43:13 -04:00
bvandeusenandClaude Opus 5.5 5cc71c3050 Settings: tabs instead of one long page, and a review pass (#5916)
Settings was every registry group, then invites, accounts, groups and
activity, in one scroll. It is now six tabs held in ?tab=:
- General: General, Notes, Attachments, Links.
- People: Access, Invites, Accounts, Groups.
- Email, with a line saying whether email is on and what it still needs.
- Security.
- Activity.
A registry group that no tab names lands on General, so it can't go missing.

Save saves only the current tab. Edits waiting on other tabs keep their
values and mark their tab with a dot. Leaving the page with unsaved edits
asks first.

Review fixes:
- On a phone, a field stacks under its label instead of overflowing beside
  it.
- Descriptions are easier to read.
- The SMTP encryption choice shows readable names (choice_labels).
- The test-email button uses .btn-sm and is disabled while email is off.
- The accounts list is headed Accounts, not People, now that People is a
  tab.
- The docs point at the new paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:41:03 -04:00
bvandeusenandClaude Opus 5.5 c63375b092 Rail: Tags sit below the other nav items (#5915)
CI & Build / Python lint (push) Successful in 2s
Android / Build the server 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
Android / Core and FFI clippy and tests (push) Skipped
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) Successful in 11s
CI & Build / integration (push) Successful in 1m35s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m9s
CI & Build / Build & push image (push) Successful in 49s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m23s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m6s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Tags is the one list in the rail that grows without limit, so everything
with a fixed place goes above it and stays put.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:38:49 -04:00
bvandeusenandClaude Opus 5.5 8d67fc0da5 Web editor: a typed #tag shows as a chip, and # suggests existing tags (#5913, #5914)
The tag row under the text was a copy of the note's labels taken when the
editor opened. A #tag in the body becomes a tag on the server's side of a
save, so the copy never learned of it until the note was reopened. Every
save now hands its note back, and the row is refreshed from it.

Typing # in a prose block or a checklist item opens the tags that already
exist: prefix matches first, busiest first. Arrows move, Enter or Tab puts
the tag in, Esc closes only the menu. The grammar and ranking live in
notes/tags.ts with tests, so the menu never offers a tag the server would
not derive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:38:49 -04:00
bvandeusenandClaude Opus 5.5 67df94350c DRY pass #3: the "from the text" mark is one composable (#5373)
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
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 11s
CI & Build / Python tests (push) Successful in 13s
Android / Core and FFI clippy and tests (push) Successful in 54s
CI & Build / integration (push) Successful in 1m41s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 9m17s
Android / Build the server image (push) Successful in 1s
The label picker and the editor's tag row each wrote the same Text: the
label_from_tag string, labelSmall, onSurfaceVariant, 8dp start padding.
Chips.kt's FromTextMark() is now both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:14:24 -04:00
bvandeusenandClaude Opus 5.5 d6204d320a One tag-name fold on the web and on Android, beside the core's (#5373, #5385)
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 3s
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 1m0s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m16s
CI & Build / integration (push) Successful in 2m41s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m45s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m26s
Android / Build the server image (push) Successful in 1s
The web compared or keyed tag names with name.toLowerCase() at five sites:
the labels store's merge check, LabelPicker's create check, NoteCard's
colour map, and colors.ts twice. Android had three, plus TagsScreen's rename
dialog using equals(ignoreCase = true). Each now calls foldTag(name), which
says what the core's derive::fold and the server's labeling.named say.

Android's rename dialog now predicts a merge the same way the core decides
one. Per-character ignoreCase and full lowercasing disagree on letters like
İ, whose lowercase is two characters. LabelPicker's search filter keeps
its own lowercasing: that is search, not identity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:05:30 -04:00
bvandeusenandClaude Opus 5.5 0ecff7eeb9 Core: tag names fold every letter, as the server does (#5385)
SQLite's lower() folds ASCII only, so a device could hold "Café" and
"CAFÉ" as two tags while the server held one. derive::fold (to_lowercase)
is now the one comparison. It is registered as the deterministic SQL function
fold() on every connection (schema::migrate), and find_or_create, the rename
clash, the pull clash and the unique index all use it. derive's push_unique
used eq_ignore_ascii_case and now folds the same way.

v14 merges pairs a device already holds before rebuilding the index. The
older row survives, as in rename_label. Memberships move with via_tag kept,
affected notes go dirty, and the merged-away row is a pending delete. It is
written out rather than calling store::merge_labels so the migration doesn't
depend on store code that later versions may change.

rusqlite gains its "functions" feature (operator-approved, 2026-10-08).
grammar.json gains "#café and #CAFÉ" -> ["café"], which all three
implementations run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:01:42 -04:00
bvandeusenandClaude Opus 5.5 921c0a47bd Real plurals: the web's sync messages and Android's tag counts
Web: one counted(n, one, other) helper. The sync screen's three "(s)"
messages (attachments that didn't download, changes refused, files that
didn't upload) now say "1 file" / "2 files". The six hand-written ternaries
(SyncView x2, ImportNotes, GroupList, ShareDialog, LabelsModal) use it too.

Android: tags_count was two strings chosen in code, and is now <plurals>.
The delete confirmation read "It is on 1 notes … removed from all of them"
and now has a one form of its own.

Operator decision on #5371, 2026-10-08.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:57:09 -04:00
bvandeusenandClaude Opus 5.5 29dd6dd351 Revert the forced APK failure (#5419)
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 / 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 32s
CI & Build / integration (push) Successful in 1m57s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 3m0s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m58s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m53s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 10m23s
Android / Build the server image (push) Successful in 1s
Run 8849 watched the gate reject: the APK job failed, "Build the server
image" was skipped, ci.yml 8850 stood down, nothing was dispatched, and
:dev still resolves to package version 11662 (pushed by 8848). This push
checks the green direction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:54:41 -04:00
bvandeusenandClaude Opus 5.5 eb6a1ff3ef Web: Activity loads older events as you scroll, no Show older button
Preference 172. ScrollSentinel watches the end of a cursor-paged list and
asks for the next page a screen early. It asks once per cursor, keeps going
when a page lands without pushing the end off screen, and stops on a failure
until Try again (an observer re-firing on a failure is a request loop). A
failed older page now gets its own error beside the retry, instead of
replacing the list's load error.

Adapted from fabledstash's ScrollSentinel (snippet 3760).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:54:19 -04:00
bvandeusenandClaude Opus 5.5 962637ea8b TEMPORARY: force the APK job red to watch the image gate reject (#5419)
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
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 11s
Android / Core and FFI clippy and tests (push) Successful in 49s
Android / Kotlin + Rust (APK) (push) Failing after 37s
Android / Build the server image (push) Skipped
CI & Build / integration (push) Successful in 1m39s
CI & Build / Build & push image (push) Skipped
Reverted by the next commit. Expect: build fails, server-image skipped,
no ci.yml dispatch, :dev unmoved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:51:54 -04:00
bvandeusenandClaude Opus 5.5 b2d50009d3 android.yml: a red Android lane no longer publishes the server image (#5419)
server-image ran under always(), so a failed core check or APK build still
dispatched ci.yml. Run 8839 published :<sha> for 0ecdbfe while its APK lane
(8835) was red. Rule 177: nothing publishes on red. With always() dropped,
the dispatch needs rust and build to succeed. A skipped decide still skips
it, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:51:34 -04:00
bvandeusenandClaude Opus 5.5 76fe96d135 MainActivity imports OnEachForeground (#5372)
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
CI & Build / Python tests (push) Successful in 10s
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
Android / Core and FFI clippy and tests (push) Successful in 58s
CI & Build / integration (push) Successful in 1m59s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m29s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m50s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m32s
Android / Build the server image (push) Successful in 0s
fb95c9b moved the foreground latch into ui/ForegroundTransitions.kt, but
MainActivity lives one package up and imports what it uses from ui by name;
run 8835's compileDebugKotlin stopped on the two unresolved calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:05:41 -04:00
bvandeusenandClaude Opus 5.5 ef22910154 DRY pass #3: the labels store swaps a returned label in one way (#5373)
rename and setColor each found the label and replaced it, keeping the count
the single-label PATCH doesn't recompute; keepingCount(updated) is that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:39:25 -04:00
bvandeusenandClaude Opus 5.5 cd8df384a4 DRY pass #3: one compact dialog button (#5373)
ShareDialog's Share and NoteEditor's Add note / Close each spelled out the
same compact button, smaller than .btn. .btn-compact is its shape and
.btn-compact-primary its brand fill; Close keeps its neutral colours beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:38:36 -04:00
bvandeusenandClaude Opus 5.5 0ecdbfe257 desktop.yml: every job has its own deadline (#5409)
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 10s
CI & Build / Python tests (push) Successful in 12s
Android / Core and FFI clippy and tests (push) Successful in 31s
CI & Build / integration (push) Successful in 1m34s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m56s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m10s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m54s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Failing after 5m46s
Android / Build the server image (push) Successful in 1s
Run 8830's Linux bundle sat on a rust-lld link whose 17 threads were all
parked on futexes until the runner's own 30-minute cap ended it. verify, the
Linux and Windows builds get 20 minutes (warm runs take ~4, and a cold one
compiles the tree in about two), the manifest 5. Kept under the runner's cap
so these are the limits that fire.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:37:34 -04:00
bvandeusenandClaude Opus 5.5 78810d8832 DRY pass #3: core tests open their store and seed a label one way (#5373)
Five test modules wrote the same db() over memory_conn, and portable.rs
unwrapped it inline; local::test_db() is that. Four pull/push tests wrote the
same label INSERT; local::seed_label(conn, id, name, dirty) is it. Both are
cfg(test), beside memory_conn, as wire::sample_note is beside wire::Note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:17:30 -04:00
bvandeusenandClaude Opus 5.5 1b128a9374 DRY pass #3: a secret setting is the text input with a password type (#5373)
SettingsView drew secret and plain text settings as two inputs differing only
in type, autocomplete and the saved-value placeholder; one input binds those.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:16:16 -04:00
bvandeusenandClaude Opus 5.5 9dcdb43568 DRY pass #3: both sync work requests take one network-and-backoff policy (#5373)
enable and pushSoon each set the CONNECTED constraint and the 30-second
exponential backoff; B.online() on WorkRequest.Builder sets both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:16:04 -04:00
bvandeusenandClaude Opus 5.5 91ec677025 DRY pass #3: checklist near-misses live in the grammar fixture (#5373)
test_notes.py and derive.rs each kept the same six lines of prose that look
like a checklist item. Four were already task_lines cases in grammar.json;
the other two (empty brackets, no bullet) join them, so the web suite now
tests them too, and both hand lists go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:15:17 -04:00
bvandeusenandClaude Opus 5.5 0e21b8e7f1 DRY pass #3: SyncView's update choices are one fieldset, twice (#5373)
The source and channel fieldsets were the same markup over different options.
updateChoices holds each set with its legend, radio name and when it is
offered, and one v-for draws them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:14:10 -04:00
bvandeusenandClaude Opus 5.5 31d8b1e65b DRY pass #3: unlinking a device by id or by its own token is one tail (#5373)
revoke_self and revoke_device each loaded the caller's DeviceToken, deleted
it, committed and audited DEVICE_UNLINKED; _unlink_device(which) is that,
owner-scoped as before, with each route keeping only how it names the row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:13:39 -04:00
bvandeusenandClaude Opus 5.5 498b23789c DRY pass #3: one one-time secret reveal (#5373)
AccountView's device-token reveal was OneTimeLink's markup and copy handler
again. OneTimeLink becomes OneTimeSecret (value, note, noun = link | token),
and the account page shows a new token through it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:13:18 -04:00
bvandeusenandClaude Opus 5.5 a0249564e2 DRY pass #3: the web asks one question about a password's length (#5373)
RegisterView and ResetPasswordView each wrote the min-length check and its
message; config.passwordRefusal(password) is that check, the web's side of
auth._password_refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:12:32 -04:00
bvandeusenandClaude Opus 5.5 8c45725a2a DRY pass #2, batch 8, F17: a person and an account have one shape each (#5372)
Web: GroupList's Person was adapters/repo's Member field for field, and
AccountList's Account was the session store's User; both now import them.
The bridge's Identity stays, as the Tauri mirror of the core's struct.

Server: auth._serialize_user and accounts' _serialize_account wrote the same
four fields. serialize.serialize_user is serialize_person plus is_admin, and
the account list adds created_at to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:11:29 -04:00
bvandeusenandClaude Opus 5.5 0d82e26eea DRY pass #2, batch 8, F20: drawer titles and tag colour names come from strings.xml (#5372)
Destination carried its English title as a field, and NoteTint its colour
name; every other word on screen is a string resource. Destination.title()
now reads nav_notes/reminders/archive/trash (a tag's destination keeps its
name), and NoteTint.label is a @StringRes. Error fallbacks raised in view
models and the install receiver stay as constants: they are made outside
composition, with no Context to read a resource from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:10:15 -04:00
bvandeusenandClaude Opus 5.5 cba428461c DRY pass #2, batch 8: the Shared-with-me facet is tested once (#5372)
sharing.test.ts round-tripped shared=with_me, which facets.test.ts already
does with every facet; its unknown-value case moves there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:08:38 -04:00
bvandeusenandClaude Opus 5.5 45c286a71f DRY pass #2, batch 8: inkwell.notes stops re-exporting its siblings (#5372)
notes/__init__ re-exported 17 names so it could stand in for the old single
module. Only sync.py and test_notes.py used that, and each now imports from
the module that defines the name; __all__ and the nine imports __init__ held
only for it go. sync.py also drops sa_delete, func, live and NoteLabel,
unused since 8eff5f6 (#5382). The integration test's patch on
inkwell.notes.schedule_unfurls stays: the routes call it from there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:08:24 -04:00
bvandeusenandClaude Opus 5.5 7392ca2e97 DRY pass #2, batch 8, F20: takeShared's two SEND branches are one (#5372)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:07:24 -04:00
bvandeusenandClaude Opus 5.5 fb95c9b66f DRY pass #2, batch 8, F20: one foreground observer, one foreground latch (#5372)
FlushOnStop was ForegroundTransitions with only its ON_STOP half, so it goes
and the editor calls ForegroundTransitions(onBackground = flush); both halves
now default to nothing, dropping three onBackground = {}. AutomaticUpdate and
AutomaticSync each kept a wanted flag set on the way in and consumed in a
LaunchedEffect once ready; that latch is OnEachForeground(ready, onBackground,
act), with each caller's ready and its reason kept where they were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:07:02 -04:00
bvandeusenandClaude Opus 5.5 63abff8681 DRY pass #2, batch 8: the Rust and ffi docs (#5372)
sync/mod.rs listed 5 of its 9 modules; migrate's doc sat above the v9 SQL;
client.rs had items after its test module; the ffi's sync_now doc had fused
into client_update's; complete_reminder (ffi and EditorAction) still said
recurrence advancement was to come, though the core does it; NoteQuery.view
listed views the core never matched and claimed it validated. Test scratch
dirs drop the old ts-/iw- prefixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:05:43 -04:00
bvandeusenandClaude Opus 5.5 2ce68f578a DRY pass #2, batch 8: stale comments (#5372)
useNoteList no longer names a Search view; the desktop adapter's M10.7 plan
gave way to sync under the local core; local.ts's sharing comment sat above
settings; AccountList and password_resets still said there was no mail path;
NoteEditor kept an orphan checklist-flag comment, a textarea comment from
before blocks, and the link-preview comment above the file picker; the
serialize docstring's growth plan is now what it holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:03:50 -04:00
bvandeusenandClaude Opus 5.5 e911f1d757 DRY pass #2, batch 8: the web's leftovers (#5372)
- notes/datetime.ts formatLocalDay had no caller; removed.
- style.css set body in two consecutive blocks; they are one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:01:13 -04:00
bvandeusenandClaude Opus 5.5 fae03c7eca install.sh reads INKWELL_SERVER, INKWELL_CHANNEL and INKWELL_TOKEN (#5372)
The installer's environment variables kept the ThoughtSync-era TS_ prefix
after the rename to Inkwell. They are now INKWELL_*, as is the
INKWELL_SERVER_DEFAULT line the server fills in when it serves the script.
The old names are not read any more; the operator approved the clean cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:01:13 -04:00
bvandeusenandClaude Opus 5.5 027ad6f672 DRY pass #2, batch 7, F21: update.rs reads installer markers one way (#5372)
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
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 55s
CI & Build / integration (push) Successful in 1m45s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m17s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m55s
Android / Kotlin + Rust (APK) (push) Successful in 8m19s
Android / Build the server image (push) Successful in 1s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 30m2s
Desktop (Tauri) / Update manifest (push) Skipped
update.rs read_marker(): read the installer's marker file, parse it, and
log one that doesn't parse. adopt_installer_channel and
adopt_installer_server each wrote that out; they already shared adopt().

normalize_server's doc now says why it stays apart from
compat::normalize_base_url: one tidies what a person types, the other
refuses anything odd in what a script wrote. The tauri.conf updater
endpoint stays: read_source already documents that it is never consulted,
and removing it is a config change CI would be the first to try.

rustfmt --check is clean in the CI image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:00:39 -04:00
bvandeusenandClaude Opus 5.5 17076d6a79 DRY pass #2, batch 7, F21: the desktop's main window, clock and launcher dir (#5372)
- lib.rs MAIN_WINDOW: the "main" window label that the reminder worker,
  the capture window and the shell each wrote, 5 sites in all.
- lib.rs now_ms(): the epoch-milliseconds clock that the reminder worker
  and autosync's cycle stamp each computed. LastCycle.at_ms becomes i64,
  the same number on the wire.
- integration.rs applications_dir(): the XDG launcher directory that the
  entry path and the desktop-database refresh each built.

rustfmt --check is clean in the CI image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:00:03 -04:00
bvandeusenandClaude Opus 5.5 00dc8840cc DRY pass #2, batch 6, F20: log tags, snooze lengths, one instant parser (#5372)
- LogTag.kt: the four tags the app logs under. Eight files each declared
  one of them as a string.
- SNOOZE_HOUR/SNOOZE_DAY sit beside EditorAction.SnoozeReminder; the
  notification's snooze uses SNOOZE_HOUR instead of its own 60.
- Reminders.at reads through ui.epochMillis, the parse the card and the
  overdue check already use.

Checked with ktlint and detekt in the CI image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:58:41 -04:00
bvandeusenandClaude Opus 5.5 f46dd7a707 DRY pass #2, batch 6, F19: Banner, the tinted strip (#5372)
ErrorBanner.kt Banner(tintKey) { … } is the rounded, palette-tinted row
that ErrorBanner (red) and UpdateBanner (blue) each built. TintChip moves
to Chips.kt with CHIP_RADIUS, which keeps NoteCard.kt under detekt's
function count. Checked with ktlint and detekt in the CI image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:57:25 -04:00
bvandeusenandClaude Opus 5.5 7ee4fed246 Android: loneUrl trims without a spread
detekt's SpreadOperator flagged trimEnd(*TRAILING_PUNCTUATION) from
1c4bf56. trimEnd { it in TRAILING_PUNCTUATION } trims the same characters
without copying the array. Checked with detekt in the CI image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:57:25 -04:00
bvandeusenandClaude Opus 5.5 9b8c9474bc DRY pass #2, batch 6, F19: TitleAndBody, TintChip and Swatch (#5372)
- BoardScreen TitleAndBody: the title plus quieter body that the empty
  board and the store-unavailable screen each wrote.
- NoteCard TintChip: the one-line palette chip that the reminder and
  shared-by chips each drew in full.
- TagsScreen Swatch: the colour dot that the tag row (tappable) and the
  colour picker each built.

Kept: the label chips. Each uses its tag's ink and a bigger shape, not
the card's chip pair. Checked with ktlint in the CI image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:56:18 -04:00
bvandeusenandClaude Opus 5.5 4a810df0a9 DRY pass #2, batch 6, F19: the note menu's shared rows (#5372)
TrashedNoteItems, PinItem and ArchiveItem (EditorChrome.kt) are the rows
that the editor's overflow and the board's long-press menu each built: a
trashed note's restore and delete-forever, and the pin and archive
toggles. Each menu keeps its own order and its owner-only rows. Checked
with ktlint in the CI image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:55:06 -04:00
bvandeusenandClaude Opus 5.5 0d82c17224 DRY pass #2, batch 6, F19: manualLabelIds and sharerName (#5372)
- Note.manualLabelIds (NoteAccess.kt): the tags attached by hand, which
  the label picker, the chip's remove button and the board's create-label
  each filtered out of note.labels.
- sharerName(note): who shared a note, or "Someone", which the shared-by
  line and the card's chip each spelled out.

Checked with ktlint in the CI image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:54:27 -04:00
bvandeusenandClaude Opus 5.5 709ccca64c DRY pass #2, batch 6, F19: Sheet, the bottom sheet with its title (#5372)
EditorPickers.kt Sheet(title, onDismiss, modifier, verticalArrangement):
a ModalBottomSheet holding a full-width column with the screen margin,
clear of the navigation bar, under SheetTitle. The filter, tag picker,
reminder and share sheets each built that. A site's extra (a scroll, ime
padding, bottom space) is now applied after the navigation-bar inset
rather than before it. The total inset is the same either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:53:51 -04:00
bvandeusenandClaude Opus 5.5 f2fac1674b DRY pass #2, batch 6, F19: BackButton, the arrow out of a full-screen surface (#5372)
Panel.kt BackButton(onClick, label): the IconButton + ArrowBack that the
tags, sync and editor top bars each built. Each keeps its own spoken
label. Checked with ktlint in the CI image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:52:54 -04:00
bvandeusenandClaude Opus 5.5 74ee288fd4 DRY pass #2, batch 6, F19: Hint, the quiet line under a field (#5372)
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 34s
CI & Build / integration (push) Successful in 1m37s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m2s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m8s
Android / Kotlin + Rust (APK) (push) Failing after 5m1s
Android / Build the server image (push) Successful in 1s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m0s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Panel.kt Hint(text, modifier): bodySmall in onSurfaceVariant. That is the
secondary line that the sync pairing form, the sync screen, the update card
and the share sheet each wrote as a full Text(...) at 14 sites. Sites that
add more than a modifier (the link preview's two-line clamp) stay as they
are. So does ShareSheet's own Muted, which is bodyMedium.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:51:51 -04:00
bvandeusenandClaude Opus 5.5 3d03567633 DRY pass #2, batch 6, F19: one confirm dialog, one permission vocabulary, one #name (#5372)
- Panel.kt ConfirmDialog (moved from TagsScreen, where it was private):
  the delete-forever dialog, the sync disconnect and the tag dialogs all
  ask through it now.
- strings.xml: editor_cancel and tags_cancel were both "Cancel"; they are
  one cancel string.
- NoteAccess carries the core's permission string (wire). The access
  lookup, the share sheet's choices and the board's draft read it from
  there instead of writing "owner"/"edit"/"view" again.
- hashtag(name): the #-prefixed tag name that TagsScreen, the card and the
  editor chips each wrote.

Formatted and checked with ktlint in the CI image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:51:17 -04:00
bvandeusenandClaude Opus 5.5 ef759e7d8b DRY pass #2, batch 6, F18: one failure message, one factory shape, one editor sitting (#5372)
- ui/Failure.kt: Throwable.shownAs(fallback) and FALLBACK_ERROR. The
  core's own message, else a fallback, which Share, Tags and Sync each
  defined privately and Board and Update wrote inline.
- The five view-model factories use lifecycle's viewModelFactory { initializer }
  instead of an unchecked-cast object each.
- BoardViewModel.beginSitting: the editingSession bump the four editor
  openings each spelled out.

The fallback line stays an English constant, as it was: view models hold no
Context to read strings.xml. Formatted with ktlint in the CI image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:49:44 -04:00
bvandeusenandClaude Opus 5.5 3dd47fa178 DRY pass #2, batch 5, F17: the web's smaller pairs (#5372)
- desktop/bridge.ts listen<T>(): the no-op-off-desktop event listener
  that onSynced, onCaptured and onReminderDue each wrote.
- size.ts roughSize(): one decimal below 10, none above, for the client
  download sizes (MB) and the storage line (GB).
- BaseModal's title prop draws the heading row and close button that
  LabelsModal and ShareDialog each built.
- SyncView: switchChannel and switchSource share recheck(); the four
  update-card radios render from two option lists.

Kept: the board's and the shell's is-typing guards (they ignore different
elements), the drawer's nav beside the palette's commands (one is a laid-
out list with tags between its entries, the other a command list), the
status pills (each colours differently), and plurals (the copy is the
operator's call, raised in #5371).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:48:31 -04:00
bvandeusenandClaude Opus 5.5 6e4c4930cb DRY pass #2, batch 5, F16: the admin lists load through useLoad (#5372)
Accounts, groups, invites and activity each wrote the same first load:
clear the error, try, put the server's reason or a fallback in error,
and stop loading. useLoad (composables/useAction.ts) is that, and its
load() is also the retry.

Kept: ShareDialog and Settings. Their load sets loading back to true on a
reload, which the lists never did, so moving them would change what a
retry shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:46:14 -04:00
bvandeusenandClaude Opus 5.5 52d53fb78a InviteList: load() keeps its own error line; revoke toasts
ebbe4a6 converted the wrong try block in InviteList: the edit matched from
load()'s try down to revoke()'s catch, which left load() half-converted.
load() goes back to its own try/catch/finally. revoke() uses
toastOnFailure, as intended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:45:19 -04:00
bvandeusenandClaude Opus 5.5 ebbe4a678a DRY pass #2, batch 5, F16: one way an action reports its failure (#5372)
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 4s
Android / Build, or is the channel already serving this? (push) Successful in 8s
CI & Build / Web typecheck and unit tests (push) Failing after 11s
CI & Build / Python tests (push) Successful in 19s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 29s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
Android / Core and FFI clippy and tests (push) Successful in 44s
CI & Build / integration (push) Successful in 1m32s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 4m11s
Android / Build the server image (push) Successful in 1s
composables/useAction.ts: toastOnFailure runs an action and toasts the
server's reason or a fallback; useAction adds the busy flag a button
waits on; useRowAction keeps the id of the row whose action is running.
Import, export, the menu entry, the integration prompt, sign out
elsewhere, the account reset link, the group actions (whose local act()
it replaces), invite revoke and sync disconnect each wrote that
try/catch/finally out.

Kept: AccountView's device revoke. It shows a fixed message rather than
the server's reason, and moving it would change the text. ShareDialog
shows its errors inline, not in a toast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:44:43 -04:00
bvandeusenandClaude Opus 5.5 fe1b61fa4d Android: loneUrl's chain in ktlint's layout
16ab4a1's APK lane stopped at ktlint (chain-method-continuation) on the
one-line chain 1c4bf56 wrote. Reformatted with ktlint --format in the CI
image; no code change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:42:50 -04:00
bvandeusenandClaude Opus 5.5 8899bd205f DRY pass #2, batch 5, F15: the four signed-out pages share AuthLayout (#5372)
Sign in, register, forgot and reset each wrote the same page: a centred
column, the app icon, a title, a subtitle, the form and a footer link. That
is now components/AuthLayout.vue, with the title as a prop and the
subtitle, the form, the footer and anything after it as slots. The footer
links wear the new .text-link class. Markup and classes are unchanged, so
the pages render as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:42:31 -04:00
bvandeusenandClaude Opus 5.5 b481e6857c DRY pass #2, batch 5, F14: the web's repeated class lists become component classes (#5372)
style.css gains the classes the views spelled out in full: .section-label
(17 sites), .hint (20), .form-error (13), .alert-error (5), .row-card (5),
.list-empty (5), .field (5), .page-shell (3), and the small row action
.btn-sm (4) / .btn-sm-danger (3). Only exact runs moved, so nothing renders
differently; spacing a site adds beyond a run stays a utility beside it.

Kept: the Reminders and Timeline small buttons. They carry no text colour
and inherit it, so putting them on .btn-sm would recolour them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:41:34 -04:00
bvandeusenandClaude Opus 5.5 38b94bd57e DRY pass #2, batch 4, F13: the server's remaining pairs (#5372)
- common.detach: the hold-a-reference-until-done task start that mailer and
  unfurl_queue each wrote.
- common.expired_before: the retention window, now shared by trash and the
  audit log (it moves out of retention.py, which imports audit).
- responses.too_many: the 429 with Retry-After from the credential throttle
  and the client-download throttle.
- share_sync.revoke_lost: revoke whoever could see a note before and no
  longer can, after a share or a group goes.
- serialize.serialize_person: a member as the directory, a share and a
  group listing show them.
- groups_api._get_group: the path-id lookup four group routes wrote.
- settings.apply_session_ttl: the session lifetime set at boot and on save.

Kept: the attachment-id claim check (one query; each caller answers an id it
already holds differently), the strict UUID-list parses in reorder and
set_note_labels (distinct error messages), and the checklist-items loops
(one line each).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:39:38 -04:00
bvandeusenandClaude Opus 5.5 627c5e43bc DRY pass #2, batch 4, F12: one note fetch, one tag-name match, one push-landed reply (#5372)
notes.helpers._fetch_note is the parse-id, not-purged, gated select that
_get_owned, _get_visible and _get_editable each wrote out, and note_visible
is the viewer's visibility predicate that five note reads spelled in full.
labeling.named is the case-insensitive live-name match that tags, labels
(create and rename) and the sync push each wrote; how two tag names compare
is now said in one place (#5385 will change it there). sync._landed is the
flush, read-back-the-revision and reply that four push paths ended with.

The REST routes' commit-and-serialise tails stay: each is two lines, and
whether a route refreshes the row first differs by route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:37:19 -04:00
bvandeusenandClaude Opus 5.5 16ab4a13f5 DRY pass #2, batch 4, F11: one email normaliser, one 401, one password-length refusal (#5372)
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 / Web typecheck and unit tests (push) Successful in 12s
CI & Build / Python tests (push) Successful in 14s
Android / Core and FFI clippy and tests (push) Successful in 57s
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 2m13s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m4s
Android / Kotlin + Rust (APK) (push) Failing after 4m49s
Android / Build the server image (push) Successful in 1s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m50s
Desktop (Tauri) / Update manifest (push) Successful in 3s
common.normalize_email is the trim-and-lowercase that sign-up, sign-in,
reset and invite each wrote inline. auth._unauthenticated is the 401 the
six signed-out paths returned, and auth._password_refusal is the
minimum-length check that register, reset and change repeated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:34:47 -04:00
bvandeusenandClaude Opus 5.5 a34d469e1e An attachment's file leaves the disk one way: storage.unlink_media
The purge wrote out its own try/unlink/log next to unlink_media, which says the
same thing. It couldn't import it: unlink_media lived in the notes package, which
imports retention. unlink_media moves down to storage.py, the module about what
attachments occupy, and the purge, the delete route and sync all call it.

DRY pass #2, batch 4, F10 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:33:11 -04:00
bvandeusenandClaude Opus 5.5 f5478a0ce0 The export and the Markdown frontmatter write times through common.iso
common.iso is the server's one way to put a datetime on the wire, yet the JSON
export and the importer's Markdown frontmatter wrote out the
x.isoformat() if x else None idiom it replaces, seven times. Same output.

DRY pass #2, batch 4, F10 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:33:11 -04:00
bvandeusenandClaude Opus 5.5 ac9035d9a5 Admin routes and the SPA fallback answer errors through responses.py
responses.py is the app's one JSON error shape, yet invites, accounts, the SPA
fallback and the test-email route still built jsonify({"error": ...}) by hand,
and two parsed path ids with their own try/uuid.UUID. They now use json_error,
not_found and parse_uuid. The download limiter's 429 stays for F13, with its
Retry-After twin.

DRY pass #2, batch 4, F10 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:33:11 -04:00
bvandeusenandClaude Opus 5.5 bfe2ed783f The web's password forms read the server's minimum length
The server's MIN_PASSWORD_LEN was 8, and the web wrote 8 out five times: two
checks and three placeholders. The constant moves beside the other policy numbers
in settings.py (auth.py imports it), /api/config serves it as
min_password_length, and the config store hands it to Register, Reset and
Account. 8 stays only as the fallback until the config answers.

DRY pass #2, batch 3 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:31:22 -04:00
bvandeusenandClaude Opus 5.5 1c4bf56058 A lone link's preview is looked up where the server filed it
The server unfurls what detect_urls finds, trailing .,;:!? trimmed, and files the
preview under that. The web and Android cards looked a lone link's preview up
under body.trim(), punctuation included, so a note reading
"https://example.com/a." never showed its card.

- grammar.json gains a urls section: what the server finds in a body, and the
  link a lone-link note is filed under.
- The web's rule moves out of NoteCard into notes/links.ts loneUrl(); Android's
  LinkPreviewRow gets the same loneUrl(); both trim like the server.
- The server and web suites run the cases; Android's JVM test pins them by hand,
  as it does the tint.

Fixes #5399. DRY pass #2, batch 3 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:30:32 -04:00
bvandeusenandClaude Opus 5.5 3766d59b2e The server's palette is checked against the shared fixture's hues
The web already checked its tint keys against grammar.json; the server's
NOTE_COLORS, which normalize_color accepts, had no such guard. It is now the
fixture's hues plus "default". The core has no palette to check: it stores
whatever the UI (which only offers palette keys) or the server sends.

DRY pass #2, batch 3 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:28:43 -04:00
bvandeusenandClaude Opus 5.5 4285026cfe The repeat rules are pinned by the shared fixture
daily/weekly/monthly/yearly was written out in the server (REMINDER_RECURRENCES),
the core (recur::RECURRENCES), the web editor's <option>s and Android's picker,
with nothing holding them together. grammar.json now has a recurrences list; the
server, core and web suites each check theirs against it, and the web's options
come from notes/recurrence.ts rather than the template. Android's picker pins the
list by hand with its localised labels, as it does the tint: its JVM tests do not
read the fixture.

DRY pass #2, batch 3 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:28:22 -04:00
bvandeusenandClaude Opus 5.5 db9e9a2dfb A note's name is one rule, pinned by the shared fixture
The server and the core each derived display_title and disagreed twice: the
server cut it at 200 characters and the core didn't, and the server split lines
with splitlines(), which also breaks on a lone \r or a U+2028, where the core and
every other reading of the grammar split on \n alone.

- grammar.json gains a display_titles section: blank lines, markers, an empty
  item, \r\n, a lone \r, U+2028, and a 201-character line of 'é' (the cut is
  characters, not bytes).
- derive::display_title and DISPLAY_TITLE_CAP are the core's half, moved next to
  strip_marker. The server splits on "\n". Both suites run the cases.

Behaviour: a device now names a note with a first line over 200 characters the
way the web always has, and the server names a note containing a lone \r or a
U+2028 the way devices always have.

Fixes #5398. DRY pass #2, batch 3 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:27:32 -04:00
bvandeusenandClaude Opus 5.5 2809c26214 Sync tests build a server note from wire::sample_note
pull's note() and push's server_note each wrote out all nineteen fields of a
wire::Note. wire::sample_note(id, revision) is that note; push's version changes
only the body and attachments, by struct update.

DRY pass #2, batch 2, F9 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:25:14 -04:00
bvandeusenandClaude Opus 5.5 2dec89bf8c Core tests make a throwaway blob store with blobs::scratch
blobs, store and portable each built a BlobStore in a temp directory their own
way: pid+tag twice, a uuid once. blobs::scratch(tag) is that, with a counter, so
two tests can never share a directory even if they pick the same tag. The
desktop's and ffi's temp-dir helpers stay, one per crate: sharing them would
need a test-util feature on the core crate.

DRY pass #2, batch 2, F9 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:24:52 -04:00
bvandeusenandClaude Opus 5.5 3ea7c817bf Core tests open their store through local::memory_conn
Seven test modules each built a migrated in-memory store by hand: open, migrate,
and (in sharing) wrap it in a Db. local::memory_conn() is that, and
open_in_memory uses it too. Each module's db() is now one line, and the schema,
Connection and Mutex imports it needed are gone.

DRY pass #2, batch 2, F9 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:24:04 -04:00
bvandeusenandClaude Opus 5.5 c35e7fd589 cargo fmt: three chains rustfmt splits
Formatting only. portable::instant (from F5), and the ffi's link and unlink
(F2/F3), were laid out by hand without a toolchain; rustfmt splits each chain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:23:57 -04:00
bvandeusenandClaude Opus 5.5 ee771e3734 Every lock of the store goes through Db::conn
Db::conn is documented as the one way to take the lock, yet five production and
test sites reached past it with db.0.lock(): the startup summary, the desktop's
trash sweep and config_get, and tests in sharing and update. All five now call
conn().

DRY pass #2, batch 2, F8 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:23:07 -04:00
bvandeusenandClaude Opus 5.5 63dd206b91 Access and entity words are core constants: access::*, entity::*
"owner"/"edit"/"view" and the entity names "note"/"label"/"attachment"/"preview"
were literals at about thirty sites across store, pull and push, including match
arms whose spelling had to agree with the rows a different module wrote.
models::access and models::entity now name them, and push names its two ops.
SQL text keeps its literals; Rust-side comparisons and writes read the constants.

DRY pass #2, batch 2, F7 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:22:49 -04:00
bvandeusenandClaude Opus 5.5 8aa854d60b The store's migrations are a table of steps, applied in order
schema::migrate was thirteen hand-copied blocks of "if version < N, apply,
stamp N". The versions are now a STEPS list (SQL, or code for v8). migrate walks
the list, applies each step a store hasn't had and stamps it. A new version is a
new entry at the end; there is no block to copy.

DRY pass #2, batch 2, F7 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:21:47 -04:00
bvandeusenandClaude Opus 5.5 61658946d7 Which reminders are this device's is one predicate: OWN_REMINDERS
reminders and due_reminders each wrote out "owner's, not trashed, has a time".
The predicate is now one constant both queries read, carrying the reason a
shared note's reminder is not ours.

DRY pass #2, batch 2, F7 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:21:05 -04:00
bvandeusenandClaude Opus 5.5 dc9cf85cdc rename_label writes its rename once, merge or not
The rename UPDATE appeared twice: once for a merge's survivor and once for a
plain rename. The branch now picks which row is renamed, and one UPDATE follows.

DRY pass #2, batch 2, F7 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:20:45 -04:00
bvandeusenandClaude Opus 5.5 695efb3112 The store reads a note's body through note_body everywhere
should_snapshot and snapshot_revision each wrote out the SELECT that note_body
already is.

DRY pass #2, batch 2, F7 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:20:36 -04:00
bvandeusenandClaude Opus 5.5 54b1e13750 "Pending" has one definition: has_pending reads pending_fingerprint
has_pending listed the same four predicates as pending_fingerprint (dirty notes,
dirty labels, pending deletes, unsent uploads) in a second query. It is now
pending_fingerprint(..)?.is_some(). Counting where LIMIT 1 would do costs nothing
on a local store.

DRY pass #2, batch 2, F7 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:20:16 -04:00
bvandeusenandClaude Opus 5.5 fe84924f78 The opaque content type is one core constant: local::OPAQUE_MIME
"application/octet-stream" was written out in the blob server, its test, the
store's normalize_mime and the wire default. All four now read OPAQUE_MIME; the
schema's SQL column default names the same string and says so.

DRY pass #2, batch 2, F7 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:20:01 -04:00
bvandeusenandClaude Opus 5.5 4ac15d3557 Every call to the server fails the same way through client::send
Seven calls each spelled out send, describe the transport error, map a 401, and
refuse anything else as unexpected_status; two read the server's {"error"} words
the same way. send_raw (transport + a 401 whose meaning the caller names), send
(and anything but success is unexpected) and server_reason now hold those steps.
Each call keeps only what is its own: device_login's and fetch_identity's 401
wording, the release's 404 = none, sharing's 404 and refusal reason, upload's
retry split.

DRY pass #2, batch 2, F6 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:19:38 -04:00
bvandeusenandClaude Opus 5.5 0101b05487 Stored timestamps have one writer: local::iso and local::now
CI & Build / Build now, or wait for Android? (push) Successful in 6s
Android / Build, or is the channel already serving this? (push) Successful in 6s
CI & Build / Python tests (push) Successful in 13s
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 13s
CI & Build / integration (push) Successful in 2m6s
CI & Build / Build & push image (push) Skipped
Android / Core and FFI clippy and tests (push) Successful in 1m18s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 3m19s
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) Successful in 9m29s
Android / Build the server image (push) Successful in 2s
The store's time format (RFC 3339, UTC, milliseconds, Z) is what makes lexical
order chronological. It was spelled out ten times as
to_rfc3339_opts(SecondsFormat::Millis, true), with two private now() copies
(store, pull). local::iso(t) and local::now() now hold it; store, pull, engine
and portable call them.

DRY pass #2, batch 2, F5 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:18:33 -04:00
bvandeusenandClaude Opus 5.5 ef839ba0cd The store's layout names live in the core: local::DB_FILE, BLOBS_DIR
"inkwell.db" and "blobs" were spelled out in the ffi and the desktop (whose copy
of DB_FILE sat in the crossover shim). The layout is the core's, the same on
every client, so the names are now core constants and both clients read them.

DRY pass #2, batch 1, F4 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:17:26 -04:00
bvandeusenandClaude Opus 5.5 1f2ddd70d3 Unlinking a device is one flow in the core: link::unlink
The desktop's sync_unlink and the ffi's unlink were both written out in full: try
the revoke, clear the link either way, and log the outcome. link::unlink(db, held)
now does that. Each client reads its link with state::credentials (with its seal)
before the await and passes it in.

DRY pass #2, batch 1, F3 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:16:59 -04:00
bvandeusenandClaude Opus 5.5 91c47245ab Linking a device is one flow in the core: sync::link
The desktop's sync_link and the ffi's link_with_password/link_with_token were
the same steps written out twice: probe, refuse an incompatible server before
any credential is sent, log in or verify a pasted token, keep the link, and adopt
the server's trash retention. link::authenticate(url, Credential) does the
network half and link::store(conn, ..., seal) keeps it, sealed when the client
has a seal. Each client now only reads its input and picks its seal.

The desktop checks for a missing email/password before probing rather than after.
Same error, sooner.

DRY pass #2, batch 1, F2 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:16:53 -04:00
bvandeusenandClaude Opus 5.5 70274347af One read of a device's link: state::credentials, with the client's seal
Five places read the server address and token straight from sync_state:
sharing, autosync, sync_unlink, update's download token, and the ffi. Reading it
raw is how Android came to send its sealed token to the share routes (#5381).
state::credentials(conn, seal) now holds that read. With a seal it opens the token
(open_token), and without one (the desktop keeps it plain) it returns it as stored.
Every site calls it.

DRY pass #2, batch 1, F1 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:14:18 -04:00
bvandeusenandClaude Opus 5.5 17ae8c0863 A failed update install says the install failed, not the check
installUpdate, and a failed channel or source switch, all fell back to "The
update check failed." Each now names what failed. A check that runs after a
successful switch keeps its own message.

Fixes #5387.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:12:13 -04:00
bvandeusenandClaude Opus 5.5 1173c1cf12 A device's snooze is clamped to the server's range, 1 minute to 30 days
The server clamped; the core took any i64, so 0 or less set a reminder in the past
and a huge value overflowed Duration::minutes. Every caller passes 60 or 1440
today, so this was latent. Both sides now name the range, SNOOZE_MAX_MINUTES,
and point at each other.

Fixes #5386.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:11:53 -04:00
bvandeusenandClaude Opus 5.5 7259708f28 Session length, trash retention and attachment size have bounds
Each accepted any integer. A session length of 0 expired every session at once,
the admin's own included; an attachment limit above the 64 MB body ceiling
allowed files no request could carry, and a negative one refused every upload.

- session_ttl_days 1..3650, trash_retention_days 0..3650 (0 = keep), and
  max_attachment_mb 1..MAX_BODY_MB-1, leaving room for the multipart envelope.
- MAX_BODY_MB is the one number app.py's MAX_CONTENT_LENGTH and that maximum
  both read.
- A value stored before its bounds existed reads as the nearest bound.

Fixes #5384.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:10:28 -04:00
bvandeusenandClaude Opus 5.5 bfab8746ad Opening a purged note, or one of its files, is a 404
get_note and get_attachment selected with the ACL inline and skipped the purged
filter, so a tombstone came back 200 (#2128 says a purged note reads as absent).
Both now go through _get_visible, which cannot skip it. Reorder's batch lookup
gains the same filter, so a stale id cannot write a place onto a tombstone.

Fixes #5383.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:09:40 -04:00
bvandeusenandClaude Opus 5.5 8eff5f60a6 A deleted tag is a tombstone on every path, and the web skips tombstones
The web deleted a tag's row outright (delete, and merge's source), so the change
feed never carried it and linked devices kept the tag. A device's delete left a
tombstone that the web still listed, matched by name on create and rename, minted
#tags onto, and accepted in a picker.

- labeling.tombstone_label is the one way a tag is deleted: drop its links, set
  purged_at. REST delete, merge and sync's op=delete all use it.
- labeling.live(owner) is the one definition of a tag that exists; every catalog
  read uses it (list, lookup, create/rename matching, #tag minting, picker ids,
  export, and sync's name-clash check).
- 0040: (owner_id, name) is unique among live tags only, so a tombstone gives its
  name back and #grocery can be made again, on the web or from a device.

Fixes #5382.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:08:54 -04:00
bvandeusenandClaude Opus 5.5 be4897276c Android sharing sends the opened device token, not the sealed one
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
CI & Build / Python tests (push) Successful in 12s
CI & Build / Web typecheck and unit tests (push) Successful in 13s
Android / Core and FFI clippy and tests (push) Successful in 34s
CI & Build / integration (push) Successful in 1m39s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m58s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m5s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m11s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m17s
Android / Build the server image (push) Successful in 1s
Android has stored its device token sealed ("sealed:…") since 8592b83, and
core's sharing calls read the token from the store themselves. The ffi opened
it in credentials() and then threw the result away, so every Share-sheet
request went out as `Bearer sealed:…` and the server refused it.

The sharing functions now take the server address and token from the caller.
The ffi passes what credentials() opened; the desktop, which stores its token
plain, reads it through sharing::stored_link. A new ffi test serves one request
on a loopback port and checks the bearer token that arrives (#5381).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:05:10 -04:00
bvandeusenandClaude Opus 5.5 39b1ebae96 Settings → Activity: an audit log of what happened to accounts
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 / 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 14s
CI & Build / integration (push) Successful in 1m27s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m47s
CI & Build / Build & push image (push) Successful in 45s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m4s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Sign-ins and failed sign-ins, accounts created and sign-ups refused,
password changes, resets and reset links, devices linked and unlinked,
invites made and revoked. Each is kept in `audit_events` with the address
it came from, for `audit_retention_days` (Settings → Security, 90 by
default, 0 keeps them forever), and listed newest first for admins under
Settings → Activity. The retention loop deletes older events.

`audit.record` writes in its own session, so a refusal is kept even when
the request's transaction rolls back. A failure to record is logged and
swallowed, never the reason a sign-in fails. A throttled attempt (429) is
not recorded: a row per refused request would make each request in a
flood cost a database write. Throttle trips stay in the app log.

Also: the storage-limit test puts `storage_quota_gb` back afterwards,
since settings outlive the per-test truncate.

#2939 §5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:19:41 -04:00
bvandeusenandClaude Opus 5.5 043c87a8dc Each account may store 5 GB of attachments; admins have no limit
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
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 12s
CI & Build / integration (push) Successful in 1m33s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m12s
CI & Build / Build & push image (push) Successful in 55s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m43s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m32s
Desktop (Tauri) / Update manifest (push) Successful in 5s
#2939 §4. max_attachment_mb capped one file, so any account could fill the
volume. The new Settings → Attachments → storage_quota_gb (default 5, 0 for
no limit) caps an account's total. That total is every attachment on every
note the account owns, trash included, since trashed files stay on disk until
emptied. Admins are exempt.

storage.upload_refusal is now the one check every upload makes: per file, then
per account. It is used by:
- the web upload route;
- the sync PUT, which judges the declared Content-Length before reading the
  bytes;
- the importer, which learns the room left up front and refuses the whole
  archive if its attachments don't fit (nothing is committed).

Over the limit is answered 507 Insufficient Storage, not 413. The core treats
a 4xx as a permanent refusal it never retries, and a 5xx as worth another try.
So a file refused for want of room syncs by itself once space is freed. The
cost is that an over-limit device re-sends that file each cycle until then.

GET /api/auth/storage returns used and limit, and the Account page shows it as
a Storage row ("1.2 GB of 5 GB used").

docs/public-hosting.md drops the quota gap and gains a section on the limit.
Its Android paragraph still said a public http:// address was only warned
about; since 1dd6fc1 it is refused, and the paragraph now says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:12:26 -04:00
bvandeusenandClaude Opus 5.5 4b4659157e Retire the bridge to the old dev channel release
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 / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python tests (push) Successful in 18s
CI & Build / Web typecheck and unit tests (push) Successful in 14s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / integration (push) Successful in 1m33s
CI & Build / Build & push image (push) Successful in 34s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m55s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m51s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Scribe #3884. The dev channel's release tag moved from `dev` to `dev-rolling`
on 2026-09-10. Since then, the manifest job has also written latest.json to the
old `dev` release, so that desktop apps installed before the move could update
across. The operator has had two desktop installs and is fine reinstalling,
so the bridge goes. The old release and tag are deleted next, through the forge.

- desktop.yml: the BRIDGE_TAG=dev export is removed.
- write-manifest.sh: the TEMPORARY bridge block is removed.
- ci-requirements.md: the "Transitional" paragraph becomes a note that the tag
  is gone, and that an app installed before 2026-09-10 reinstalls with
  install.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:46:37 -04:00
bvandeusenandClaude Opus 5.5 8592b83538 android: the device token is stored sealed under a Keystore key
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 / Python lint (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 20s
CI & Build / Python tests (push) Successful in 20s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Android / Core and FFI clippy and tests (push) Successful in 1m12s
CI & Build / integration (push) Successful in 1m44s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m17s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m30s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m27s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m15s
Android / Build the server image (push) Successful in 1s
Family idea #5105, practice 12, as the operator chose on 2026-10-08: the token
is encrypted, and Android backup stays on.

The core:
- Adds a TokenSeal trait in sync/state.rs, with set_sealed_link and
  open_token.
- A sealed token is stored as "sealed:<value>".
- A plain token, stored before this change or while sealing failed, is sealed
  in place on its next read.
- A sealed token that won't open is dropped, and the server address and cursor
  are kept, so the app reads as unlinked and asks to sign in again. That is
  what happens after Android restores the app onto another phone.
- The desktop passes no seal and keeps storing the token as before.

The FFI:
- Exports TokenSeal as a uniffi foreign trait (seal_token / open_token, null
  rather than an exception).
- Requires it in Inkwell's constructor, so there is no moment a token could be
  stored unsealed.
- Routes credentials(), unlink() and store_link() through it.

Kotlin:
- KeystoreTokenSeal is AES-GCM under an Android Keystore key, using the
  SealedBox framing from Minstrel's KeystoreSessionVault (Scribe snippet #5025),
  with no new dependency.
- SealedBoxTest checks the framing on the JVM.

allowBackup stays true, and the manifest says why. An unlinked phone's notes
exist only on the phone, and the backup is their one other copy. The backup
carries a token nothing can open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:23:22 -04:00
bvandeusenandClaude Opus 5.5 bb591871a4 Account page: change your password, or sign out everywhere else
Family idea #5105, practice 4. Either action signs the account out of every
other browser and unlinks every device. The browser that made the change stays
signed in.

- POST /api/auth/password needs the current password. A wrong one returns 403,
  not 401, so this browser doesn't read as signed out, and it counts against the
  sign-in throttle. A short new password returns 400.
- POST /api/auth/sign-out-elsewhere does the same sign-out without a password
  change. Called from a device, it keeps that device linked.
- _sign_out_elsewhere moves session_epoch on and deletes device tokens. The
  reset route now uses it too, keeping no device.
- The page is renamed from "Linked devices" to "Account", in the router title
  and both nav entries. Its sections are Linked devices, Password (one short
  line, then the form) and Sessions (a single "Sign out everywhere else" row in
  the device rows' style), per preference 188: one line each, no paragraphs.
- docs/public-hosting.md says how sessions end, and why a browser session isn't
  listed the way a device is: it is a signed cookie, ended by moving the epoch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:22:16 -04:00
bvandeusenandClaude Opus 5.5 1dd6fc1e20 Apps refuse to send their token over plain http:// to a public address
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 3s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
CI & Build / Python tests (push) Successful in 13s
Android / Core and FFI clippy and tests (push) Successful in 56s
CI & Build / integration (push) Successful in 1m20s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m23s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m33s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m22s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m13s
Android / Build the server image (push) Successful in 1s
Family idea #5105, practice 13, as the operator chose on 2026-10-08.
The check lives in the shared core, so the desktop and Android both get it.

compat::cleartext_allowed decides from the address text alone, with no DNS
lookup. It allows https:// always. It allows http:// to private, loopback,
link-local and CGNAT IPs (CGNAT covers Tailscale), to fc00::/7, fe80::/10 and
::1, to single-label names, and to LAN suffixes (.local, .lan, .home.arpa,
.ts.net and others).

The refusal runs in two places:
- probe, so linking stops before a password or token is sent;
- the top of run_cycle, so a device linked before this change stops syncing
  with a message telling it to re-link, instead of sending its token on
  every cycle.

The server is unchanged and never forces HTTPS (rule 94). Plain http:// on
a LAN links and syncs as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:14:14 -04:00
bvandeusenandClaude Opus 5.5 5d08d8a7a6 The first account can only be made in a 30-minute setup window
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
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) 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 11s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 38s
Family idea #5105, practice 8 (Scribe #5113), the operator's choice of
setup window over a setup code.

Before this, whoever reached /register first on an empty server became
its admin. On a fresh server at a public address, that could be a
stranger, and a new DNS name is found within minutes.

Now the first registration is refused once 30 minutes have passed since
the server started (create_app records STARTED_AT). A restart opens the
window again. It is a constant rather than a Setting, because there is no
admin yet to change one. Once an account exists it no longer matters, so
existing servers are unaffected. public-hosting.md says so, and two
integration tests cover both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:08:29 -04:00
bvandeusenandClaude Opus 5.5 4c350838b1 Server idle timeout 120s, and say why there is no read timeout
Family idea #5105, practice 9 (Scribe #5113). The body cap was already
there (MAX_CONTENT_LENGTH, 64 MiB). For timeouts, read from hypercorn
0.18's source:

- --keep-alive goes from 600 to 120. It is also the header timeout:
  hypercorn marks a connection busy only once a whole request has
  arrived, so a client dribbling headers was allowed ten minutes per
  connection. 120 stays above Traefik's 90s backend idle timeout, so the
  proxy never reuses a connection this server just closed.
- No --read-timeout, deliberately. It bounds every socket read, including
  the whole of a streaming download while the client sends nothing, so it
  would cut off an APK fetched slowly over mobile data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:08:29 -04:00
bvandeusenandClaude Opus 5.5 65f004029b Signed-in app downloads are cached privately, not publicly
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 / 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 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 / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 49s
Quart's send_file marks every file it sends Cache-Control: public. The app
download is behind a login, so a shared cache or proxy could have kept one
account's copy and handed it to anyone. send_artifact now marks it
private, as the attachment route already does. Family idea #5105,
practice 11 (Scribe #5113).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:33:48 -04:00
bvandeusenandClaude Opus 5.5 97b04f9f92 Close the gaps family idea #5103 found in how Inkwell distributes its app
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 5s
Android / Build, or is the channel already serving this? (push) Successful in 6s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 7s
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 18s
CI & Build / integration (push) Successful in 1m13s
CI & Build / Build & push image (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 12s
Android / Core and FFI clippy and tests (push) Successful in 45s
Android / Kotlin + Rust (APK) (push) Successful in 9m50s
Android / Build the server image (push) Successful in 2s
Three of the idea's practices this project still owed (Scribe #5118):

Practice 3, CI fails on the wrong signer. The signing step printed the
certificate and went on. It now fails unless the APK has exactly one
signer and that signer is the release certificate (SHA-256 408a5835…,
pinned from run 8753). The steps that publish come after it in the same
job, so a wrongly signed build is never staged or published.

Practice 6, app downloads are throttled and carry a sha256 ETag.
- The download route counts per account and answers 429 with
  Retry-After past the limit. The limit is a new Settings → Security
  value, "App downloads per account per hour" (default 30), live like the
  sign-in limits.
- The ETag is the sidecar's sha256, not Quart's mtime-and-path, so a
  phone resuming a download across a redeploy is not told its partial
  copy is stale. Quart's own ETag and conditional handling are off, and
  the route runs the conditional pass after setting the ETag, so Range
  and If-Range are judged against the content.

Practice 9, the update offer and debug builds.
- The install-permission notice re-reads the grant each time the app
  comes back, as ReminderNotice does. Read once, it stayed up after
  someone granted the permission in Settings and came back.
- A debuggable build says it can't update itself and checks for nothing.
  Android would refuse the release-signed APK over a debug signature
  anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:21:40 -04:00
bvandeusenandClaude Opus 5.5 f0ce5687cc android: build the signed APK's Rust with the release profile
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 / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 12s
Android / Core and FFI clippy and tests (push) Successful in 25s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m11s
Android / Build the server image (push) Successful in 1s
Every APK so far carried a debug-profile libinkwell_ffi.so (opt-level 0),
because the workspace's release profile sets strip = true, which removes
the symbols uniffi's --library mode reads the interface from (run 4077).

The cargoNdk task now builds --release with two environment overrides, for
this build only:
- CARGO_PROFILE_RELEASE_STRIP=debuginfo keeps the symbol table. A release
  build has no debug info, so this keeps symbols and nothing else.
- CARGO_PROFILE_RELEASE_PANIC=unwind keeps a core panic reaching Kotlin as
  an exception, which the board shows as an error, not an app exit. Phones
  have always had unwind, because debug unwinds, so this keeps what they
  do.

Overrides rather than a profile of our own because cargo-ndk copies its -o
output from the release directory, and nothing says it handles another.
The desktop's binaries are unchanged. android.yml passes release on the
signed path; the unsigned debug path keeps debug.

Scribe #2810.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:39:36 -04:00
bvandeusenandClaude Opus 5.5 8cdb36dc2a web: two board columns on a phone, as the Android app has
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 / 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 5s
CI & Build / Web typecheck and unit tests (push) Successful in 11s
CI & Build / Python tests (push) Successful in 18s
CI & Build / integration (push) Successful in 1m22s
CI & Build / Build & push image (push) Successful in 47s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m11s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m8s
Desktop (Tauri) / Update manifest (push) Successful in 3s
The board was one column below 640px, so on a phone it read as a list
while the app showed two. It is now two columns from the smallest width,
with an 8px gap there (16px from sm up). Two columns at 16px on a 390px
screen would leave ~170px cards. NoteCard's bottom margin, the vertical
half of the gap, tightens with it.

NoteGrid is the only place the board's columns are defined, so board,
search, timeline and reminders all change together.

Fill order is untouched. CSS columns fill top to bottom, so note #2 sits
under #1 rather than beside it, unlike Android. That is left until it has
been looked at on a phone, as #2950 recommends.

Scribe #2950.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:32:16 -04:00
bvandeusenandClaude Opus 5.5 87725ecab7 android: search narrows the board in view, and combines with its filters
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 9s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 7s
CI & Build / Web typecheck and unit tests (push) Successful in 11s
CI & Build / Python tests (push) Successful in 17s
Android / Core and FFI clippy and tests (push) Successful in 1m5s
CI & Build / integration (push) Successful in 1m42s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m24s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m59s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m14s
Android / Build the server image (push) Successful in 1s
As on the web, the search box is now one more facet on the board you are
looking at, rather than a separate unfiltered search. "These words, in
notes tagged grocery" works: on the main board the text is sent to the
core together with the Filters sheet's tags, attachment and shared
switches, and the core ANDs them in list_notes. Archive, Trash and a
tag's view take the text alone. A search typed on Reminders, which is not
a board view, moves to the main board, as the web does.

The Filters chip stays while you search; it was hidden before, on the
mistaken claim that the web hides its filters too. Drag-to-reorder stays
off during a search, since a filtered subset can't be renumbered against
notes it can't see.

store::search and the FFI's search_notes had no other callers, and are
removed. They also searched archived notes and ignored pinning, which the
board's query does not.

Scribe #2942.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:19:50 -04:00
bvandeusenandClaude Opus 5.5 5fa261cea7 android: hold Coil at 3.5.0, the last release that builds on compileSdk 36
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 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 11s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 1m43s
CI & Build / Build & push image (push) Skipped
Android / Core and FFI clippy and tests (push) Successful in 1m7s
Android / Kotlin + Rust (APK) (push) Successful in 8m47s
Android / Build the server image (push) Successful in 2s
Run 8731 failed checkAarMetadata: Coil 3.6.x requires compileSdk 37, and
it pulls in Compose 1.12, which requires AGP 9.1. This project is on
compileSdk 36 and AGP 9.0.1. Coil 3.5.0's AARs ask for 36, and its Compose
(JetBrains 1.11.1) is within this project's BOM (Compose 1.11.2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:59:09 -04:00
bvandeusenandClaude Opus 5.5 a213e2e186 android: link preview cards show the page's image, as on the web
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (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
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 11s
Android / Core and FFI clippy and tests (push) Successful in 28s
CI & Build / integration (push) Successful in 1m21s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 6m10s
Android / Build the server image (push) Successful in 1s
The card draws the linked page's og:image in a strip down its left edge,
cropped to the card's height: 96dp full, 48dp compact, matching the web's
w-24 and w-12. The image is remote, so the phone fetches it from whatever
host the link points at, exactly as a browser does for the web card. The
operator chose that parity (Scribe #3307).

The image is Coil 3's AsyncImage, with OkHttp as its fetcher. Coil's disk
cache means a card scrolled past twice costs one download. When there is
no image, or it fails to load, nothing is drawn rather than an empty box,
and the card is the text card it was before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:47:26 -04:00
bvandeusenandClaude Opus 5.5 10412a2fb7 desktop: rustfmt's shape for the server feed arm
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 / 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 / Python tests (push) Successful in 11s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / integration (push) Successful in 1m20s
CI & Build / Build & push image (push) Successful in 21s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m45s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m6s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Run 8721's format check; clippy and the Rust tests were already green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:00:07 -04:00
bvandeusenandClaude Opus 5.5 b03c9cf81a desktop: in-app updates follow the server you installed from
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
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 1m17s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 1m45s
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 40s
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
348 changed files with 15406 additions and 7496 deletions
+16 -31
View File
@@ -9,44 +9,30 @@
# --- required ---------------------------------------------------------------
# Generate one and keep it: changing it later means also changing it inside the
# database, or Postgres will reject the app's connection.
# Generate one and keep it: changing it later means changing it inside the database
# too. Letters and digits only — it goes into a URL, where `@ / : #` break it.
#
# openssl rand -base64 24 | tr -d '/+=' | head -c 32
#
# Stick to letters and digits. This value goes into a connection URL, so a `@`,
# `/`, `:` or `#` in it will be misparsed as URL structure rather than password.
POSTGRES_PASSWORD=
# --- optional ---------------------------------------------------------------
# Which build to run.
#
# latest tracks the `main` branch — the production line (default)
# dev tracks the `dev` branch — newer, less settled
# <commit sha> pins one exact build; every push publishes one, and this is
# the rollback lever when an upgrade misbehaves
#
# 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.
# Which build: `latest` (the `main` branch, default), `dev` (newer, less settled),
# or a commit sha on `main` to pin one exact build — the rollback lever.
#INKWELL_TAG=latest
# The host port the app is published on.
#INKWELL_PORT=5000
# Which interface to bind. The default (all interfaces) is what lets desktop
# clients on your network reach the server. Behind a reverse proxy, set this to
# 127.0.0.1 so only the proxy can talk to it.
# Which interface to bind. Behind a reverse proxy, set 127.0.0.1 so only the proxy
# can reach the app.
#INKWELL_BIND=0.0.0.0
# NOTE: how many proxies sit in front of this app is a SETTING, not an env var —
# Settings → Security → "Trusted proxy hops" in the admin UI. It defaults to 1 (one
# reverse proxy terminating HTTPS) and belongs there because it is something you may
# need to change while the server is running, alongside the sign-in limits.
# How many proxies sit in front is a setting, not an env var: Settings → Security →
# "Trusted proxy hops" (default 1).
# How much the app says. Credential events (sign-ins, failures, throttles, new
# accounts, device tokens issued) are logged at INFO and read with
# `docker compose logs app`.
# How much the app logs. Credential events (sign-ins, failures, throttles, device
# tokens issued) log at INFO; read them with `docker compose logs app`.
#INKWELL_LOG_LEVEL=INFO
# Database identity. Changing these AFTER the first start does not rename anything
@@ -70,14 +56,13 @@ POSTGRES_PASSWORD=
#POSTGRES_USER=thoughtsync
#POSTGRES_DB=thoughtsync
#
# Rename any THOUGHTSYNC_* lines already in your .env to INKWELL_* — the old
# names are no longer read.
# Rename any THOUGHTSYNC_* lines already in your .env to INKWELL_*; the old names
# aren't read.
# --- a note on HTTPS --------------------------------------------------------
#
# The app marks its session cookie Secure automatically when a request arrives over
# HTTPS, directly or via a proxy setting X-Forwarded-Proto — no setting needed.
# The session cookie is marked Secure automatically when a request arrives over
# HTTPS, directly or via a proxy setting X-Forwarded-Proto.
#
# Worth knowing if you use the desktop app: typing a bare hostname there defaults to
# https://, deliberately, so a device token never crosses the wire in cleartext by
# accident. Serving over plain HTTP means typing the `http://` yourself.
# The desktop and Android apps read a bare hostname as https://, so a server on
# plain HTTP has to be typed with its `http://`.
+95 -53
View File
@@ -1,11 +1,7 @@
name: Android
# The native Kotlin/Compose client over the shared Rust core (M12).
#
# Replaces the Tauri-mobile lane deleted in step 2. What changed is what this
# builds, not that Android has a lane: the UI is Compose, and the store and sync
# engine are `inkwell-core` cross-compiled by cargo-ndk and loaded through
# uniffi.
# The native Kotlin/Compose client over the shared Rust core. The store and sync
# engine are `inkwell-core` cross-compiled by cargo-ndk and loaded through uniffi.
#
# CI can only prove this BUILDS. A Linux runner cannot execute an APK, so anything
# about feel, touch or on-device correctness is an operator pass on an emulator or
@@ -15,12 +11,12 @@ name: Android
# unsigned debug one when it is not. That distinction is not cosmetic: two builds
# signed with different keys cannot replace one another, and bridging that gap
# means uninstalling first — which deletes the app's database and every local note
# with it (Scribe issue 2803).
# with it.
on:
push:
# NO `paths:` FILTER — the `decide` job below reads the real file set instead.
# See desktop.yml for why, and 85ead4d for what the duplication cost.
# See desktop.yml for why.
branches: [dev, main]
workflow_dispatch:
@@ -32,17 +28,17 @@ env:
# Silences the JDK 22+ "restricted method in java.lang.System has been called"
# warning that Gradle 9.1's bundled native-platform jar trips at launch. This
# targets the LAUNCHER JVM, which is why org.gradle.jvmargs in
# gradle.properties is not enough on its own (Minstrel hit the same thing).
# gradle.properties is not enough on its own.
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
jobs:
# Does the APK need rebuilding, or is the channel already serving this source?
# See the equivalent job in desktop.yml — same reasoning, same replacement of a
# hand-kept `paths:` filter with the one file set in `packaging/version.sh`.
# See the equivalent job in desktop.yml — same reasoning, the same one file set in
# `packaging/version.sh`.
#
# The guard runs here so it covers the skip path too (§6.3).
# The guard runs here so it covers the skip path too.
#
# 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
# 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.
@@ -72,22 +68,44 @@ jobs:
sh packaging/guard-forward.sh android "$channel"
echo "build=$(sh packaging/should-build.sh android "$channel")" >> $GITHUB_OUTPUT
build:
name: Kotlin + Rust (APK)
# 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.
#
# 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, so `openssl-sys` fails
# 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 is only a scheduling label (Label Model B). flutter-ci is the
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:
name: Kotlin + Rust (APK)
needs: [decide, rust]
if: needs.decide.outputs.build == 'true'
# runs-on is only a scheduling label. flutter-ci is the
# proven-working label that can pull our container images.
runs-on: flutter-ci
container:
# The image repurposed from ci-tauri-android in M12 step 3: Rust + the four
# Rust + the four
# Android ABIs + cargo-ndk + SDK/NDK + JDK 25 + ktlint + detekt.
image: git.fabledsword.com/bvandeusen/ci-rust-android:1.97
permissions:
contents: write
# For the dispatch at the end: this lane starts the server image build.
actions: write
defaults:
run:
@@ -97,7 +115,7 @@ jobs:
- uses: actions/checkout@v4
with:
# Derives a version, so it needs the whole history — see the note in
# desktop.yml. Depth-1 is silently wrong here, not loudly broken (§6.1).
# desktop.yml. Depth-1 is silently wrong here, not loudly broken.
fetch-depth: 0
- name: Cache Gradle and Cargo
@@ -121,7 +139,7 @@ jobs:
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
# TWO CLOCKS, ON PURPOSE (note 3127 §2). The NAME answers "is this the
# TWO CLOCKS, ON PURPOSE. The NAME answers "is this the
# same code?", so it comes from the COMMIT and a dev build and the main
# build of one commit read identically. The CODE answers "may this be
# installed over that?" and must be monotonic BY CONSTRUCTION, because
@@ -137,12 +155,10 @@ jobs:
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/inkwell-release.jks
echo "variant=Release" >> $GITHUB_OUTPUT
echo "label=release" >> $GITHUB_OUTPUT
# DEBUG profile, in a release APK, deliberately — see the note above
# the cargoNdk task. The release profile strips the symbols uniffi
# reads its metadata out of, so `generateUniffiBindings` fails
# outright (run 4077). Unpicking that is worth doing and is not worth
# blocking signed builds on.
echo "profile=debug" >> $GITHUB_OUTPUT
# An optimised .so for the APK people install. The cargoNdk task keeps
# the symbols uniffi reads its metadata from, which the release
# profile would strip.
echo "profile=release" >> $GITHUB_OUTPUT
echo "keystore=/tmp/inkwell-release.jks" >> $GITHUB_OUTPUT
echo "apk=android/app/build/outputs/apk/release/app-release.apk" >> $GITHUB_OUTPUT
echo "Signed release build — $version (versionCode $code)"
@@ -170,7 +186,7 @@ jobs:
run: ./gradlew generateUniffiBindings -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }}
# The image's PINNED CLIs, not Gradle plugins. ci-rust-android carries both
# (M12 step 3) precisely so this lane needs no second image, and going
# precisely so this lane needs no second image, and going
# through Gradle plugins would mean a second version of each tool resolved
# at build time and kept in lockstep with the image's by hand.
#
@@ -185,12 +201,12 @@ jobs:
- name: Unit tests
# Host-JVM tests only. Anything touching the core needs an Android
# 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
# tests in the desktop lane.
# an emulator, not here — the Rust side is the `rust` job, which this
# one needs.
#
# DEBUG regardless of what is being packaged: AGP creates unit-test tasks
# only for `testBuildType`, which is debug, so `testReleaseUnitTest` does
# not exist (run 4082). It costs one extra Kotlin compile and buys the
# not exist. It costs one extra Kotlin compile and buys the
# type-check on the debug variant, which is the one an emulator build
# would use.
run: ./gradlew testDebugUnitTest -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }}
@@ -212,11 +228,30 @@ jobs:
# generated. Signing with the WRONG key produces a perfectly valid APK that
# simply refuses to install over the app already on the phone — a failure
# that otherwise only shows up on the device, after the run is green.
- name: Show the signing certificate
#
# And FAILS unless there is exactly one signer and it is the release key.
# Printing alone is not a check: a build signed with any other key would go on
# to be staged and published, and every phone would refuse it. The steps that
# publish come after this one in the same job, so a failure here stops them.
- name: Check the signing certificate
if: steps.build.outputs.keystore != ''
env:
# The release certificate's SHA-256, as apksigner prints it for a signed
# APK (CN=Bryan Van Deusen, O=fabledsword). Every
# installed copy carries this certificate. It only changes if the key
# does, and then every install has to be replaced by hand anyway.
RELEASE_CERT_SHA256: 408a5835ea1627f329d9fba4f00712b096faf2d30624ec6f3a8b2b5f8bb15caa
run: |
apksigner="$(ls /opt/android-sdk/build-tools/*/apksigner | head -1)"
"$apksigner" verify --print-certs "app/build/outputs/apk/release/app-release.apk"
certs="$("$apksigner" verify --print-certs "app/build/outputs/apk/release/app-release.apk")"
printf '%s\n' "$certs"
signers="$(printf '%s\n' "$certs" | grep -c '^Signer #[0-9]* certificate SHA-256 digest:' || true)"
digest="$(printf '%s\n' "$certs" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p')"
if [ "$signers" != 1 ] || [ "$digest" != "$RELEASE_CERT_SHA256" ]; then
echo "::error::The APK is not signed by the release key alone ($signers signer(s), first $digest; expected $RELEASE_CERT_SHA256)."
exit 1
fi
echo "Signed by the release key."
# Staged with a STABLE name plus the sidecar the server reads its version
# out of — an APK keeps that in a binary manifest Python cannot parse, and
@@ -245,10 +280,9 @@ jobs:
# publishing an unsigned APK would offer people something they cannot install
# over what they already have.
#
# `stable` from main is new in M314 step 3, and it is what lets the server image
# bake in a client that matches its own channel: a :latest image fetches the APK
# from `stable`, a :dev image from `dev`. Before this, main published no APK at
# all and every image — stable included — baked in the dev one.
# Publishing `stable` from main is what lets the server image bake in a client
# that matches its own channel: a :latest image fetches the APK from `stable`, a
# :dev image from `dev`.
- name: Publish to the channel for this branch
if: (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && steps.build.outputs.keystore != ''
working-directory: .
@@ -260,41 +294,49 @@ jobs:
*) channel=dev; RELEASE_PRERELEASE=true ;;
esac
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
# (packaging/channel-tag.sh). A tag named `dev` shadowed the branch.
# (packaging/channel-tag.sh). A tag named `dev` would shadow the branch.
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing the APK to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh
- name: Upload the APK
# Stock action: it works on this forge since the runner moved to
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
# out of the action bundle (Scribe snippet #2271). Never @v3 — it reports
# success while Gitea serves artifacts back only through the v4 API, so the
# upload is stored and invisible (Scribe 2270).
# Stock action: it works on this forge because gitea/runner 3.x edits
# upload-artifact's client-side GHES refusal out of the action bundle. Never
# @v3 — it reports success while Gitea serves artifacts back only through the
# v4 API, so the upload is stored and invisible.
uses: actions/upload-artifact@v7
with:
# The APK's variant, NOT the Cargo profile — those are the same word
# for different things and the profile is pinned to debug (#2810).
# The APK's variant, NOT the Cargo profile. They are the same word for
# different things, and an unsigned debug APK still differs from a
# signed release one in more than its Rust.
name: inkwell-android-${{ steps.build.outputs.label }}-${{ github.sha }}
path: ${{ steps.build.outputs.apk }}
if-no-files-found: error
server-image:
name: Build the server image
needs: [decide, rust, build]
if: 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
# 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
# from here — that is the other half of this.
#
# `always()`: a FAILED Android build must still let the server image through.
# There is no new client in that case, so it bakes in the previous one, which
# is exactly right — the alternative is a broken Android lane silently
# blocking server delivery.
# No `always()`: a red core check or APK build means no image for this
# commit. Building regardless would publish a `:<sha>` holding a client that
# isn't from that commit. The previous `:dev` stays where it is; fix forward.
#
# Not `if: success()` and not skipped on tags either: every ref that builds an
# image needs the call, or nothing builds one at all.
# Not skipped on tags: every ref that builds an image needs the call, since
# ci.yml stands down on these pushes and nothing else builds one.
- 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:
GITHUB_TOKEN: ${{ github.token }}
run: |
+63 -70
View File
@@ -3,22 +3,21 @@
# Push to dev: typecheck + lint + test + build :dev
# Push to main: typecheck + lint + test + build :latest + :<sha>
#
# THAT IS THE COMPLETE TAG SET (rule 145). No version-shaped image tag in any lane:
# nothing pins one — verified by looking for a consumer, not for whether one is
# imaginable — and the git release tag is a different object in a different system
# (step 7). The image is addressed by CHANNEL or by COMMIT; the release by date.
# THAT IS THE COMPLETE TAG SET. No version-shaped image tag in any lane: nothing
# pins one, and the git release tag is a different object in a different system.
# The image is addressed by CHANNEL or by COMMIT; the release by date.
#
# A `v*` tag builds nothing at all. The merge to main already published everything,
# so a tag rebuilding that same source would re-push :<sha> with different bytes,
# which rule 145 forbids even when they match.
# and a tag rebuilding that same source would re-push :<sha>, which a published tag
# never allows.
#
# main is the production line, so a merge moves :latest to its tip (family rule 46)
# — no version release required. :<sha> is the immutable rollback unit, and it is
# on main ONLY: a sha tag per dev push is a rollback target nobody has ever pulled,
# accumulating forever, for a channel whose entire contract is that it moves.
# main is the production line, so a merge moves :latest to its tip — no version
# release required. :<sha> is the immutable rollback unit, and it is on main ONLY: a
# sha tag per dev push is a rollback target nobody pulls, accumulating forever, for
# a channel whose entire contract is that it moves.
#
# Required secret (repo -> Settings -> Secrets -> Actions):
# REGISTRY_TOKEN -- Forgejo PAT with write:packages scope
# REGISTRY_TOKEN -- forge PAT with write:packages scope
# The registry username is derived from github.repository_owner (public — it's in
# the image path), so no REGISTRY_USER secret is needed.
name: CI & Build
@@ -28,13 +27,12 @@ on:
# NO `paths:` FILTER, and unlike the client lanes this one does not skip either —
# the image ALWAYS builds. Two reasons:
#
# * Rule 145 promises that every push to `main` publishes a `:<sha>`, so any
# production commit is addressable. A path filter quietly broke that promise
# for a docs-only merge: no trigger, no image, no sha tag for that commit.
# * Every push to `main` publishes a `:<sha>`, so any production commit is
# addressable. A path filter would break that for a docs-only merge: no
# trigger, no image, no sha tag for that commit.
# * It is the artifact most exposed to base-image staleness (`python:3.12-slim`
# is a floating tag and this can face the internet), and building every push
# picks those updates up. That is why note 3127 §4's base tension does not
# bite here — the one artifact it would apply to never skips.
# picks those updates up.
#
# Affordable because it is the cheap one: ~15 seconds, against 6 and 9 minutes
# for the clients, which is why THEY skip and this does not.
@@ -62,17 +60,17 @@ jobs:
#
# A push touching the Android app runs BOTH workflows at once. Building here
# would bake in the PREVIOUS client and then, when the new one landed, there
# would be no second build — `:<sha>` is the immutable rollback unit (rule 46)
# would be no second build — `:<sha>` is the immutable rollback unit
# and rebuilding it with different content would make it neither.
#
# So on such a push this workflow stands down, and the Android lane dispatches it
# when it is finished. Exactly one image per commit, containing the client from
# that commit.
#
# The path list below MUST match android.yml's trigger. Two places holding one
# decision is the recurring failure in this repo (issues 2181-2183); it is here
# because a workflow cannot read another's filters, and it is a `git diff` rather
# than a config so at least it is inspectable in the log.
# The path list below MUST match android's file set in packaging/version.sh. Two
# places holding one decision drift apart; it is here because this gate needs the
# answer before the Android lane has decided, and it is a `git diff` rather than a
# config so at least it is inspectable in the log.
gate:
name: Build now, or wait for Android?
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
@@ -125,11 +123,10 @@ jobs:
echo "Changed in this push:"
echo "$changed" | sed 's/^/ /'
# MUST match android's file set in packaging/version.sh. `packaging/` was
# missing here after step 4 added it there — so a packaging-only push had
# the Android lane rebuild and dispatch while this gate ALSO let the image
# build, producing two images for one commit and, on main, a second push of
# the same :<sha> with different bytes. Rule 145's exact prohibition.
# MUST match android's file set in packaging/version.sh. A path missing here
# lets a push touching only it rebuild and dispatch from the Android lane
# while this gate ALSO lets the image build: two images for one commit and,
# on main, a second push of the same :<sha> with different bytes.
if echo "$changed" | grep -qE '^(android/|core/|packaging/|Cargo\.toml$|Cargo\.lock$|\.forgejo/workflows/android\.yml$)'; then
echo ""
echo "This push also changes the Android client. Standing down: the"
@@ -145,8 +142,8 @@ jobs:
# 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.
# lane. Among other things they guard the web copy of the note grammar, which is
# mirrored in Rust and Python.
typecheck:
name: Web typecheck and unit tests
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
@@ -203,17 +200,16 @@ jobs:
# and runs in the job below.
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration"
# Real-Postgres lane (family rule 6). Until this existed, `alembic upgrade head` ran
# for the first time when the operator's container started — 26 revisions, none of
# them ever executed by CI — and the schema the migrations build had never been
# checked against the models that read it.
# Real-Postgres lane: `alembic upgrade head` runs here before any deployment runs
# it, and the schema the migrations build is checked against the models that read
# it.
#
# Gates the build, along with every other lane — see the `build` job's `needs`.
#
# Job key stays separator-free ("integration") with no `name:` — rule 80. act_runner
# Job key stays separator-free ("integration") with no `name:`. act_runner
# derives the service-container name from the truncated job display name, and the
# discovery step below filters `docker ps` by it. Service hostnames are not routable
# on this runner (rule 79), so the step resolves the container's bridge IP.
# on this runner, so the step resolves the container's bridge IP.
integration:
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
@@ -249,12 +245,22 @@ jobs:
set -eux
echo "=== container landscape (diagnostic for the name filter) ==="
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
PG=$(docker ps --filter "name=integration" --filter "ancestor=postgres:16-alpine" -q | head -n1)
test -n "$PG"
# Only THIS job's services. The runner's docker daemon is shared, so another
# repo's job can be running beside this one, and a job-name filter matches its
# containers too, and would take 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")
test -n "$PG_IP"
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 —
# no bash /dev/tcp — so use the Python that is always present here.
/opt/venv/bin/python - "$PG_IP" <<'PY'
import socket, sys, time
@@ -267,7 +273,7 @@ jobs:
else:
sys.exit("postgres did not become reachable")
PY
# Real migrations build the schema, never metadata.create_all (rule 82) —
# Real migrations build the schema, never metadata.create_all —
# testing a schema no deployment has ever seen would prove nothing. This
# step IS the migration test: a broken revision fails the job here.
/opt/venv/bin/alembic upgrade head
@@ -275,15 +281,9 @@ jobs:
build:
name: Build & push image
# Every lane gates the build. This once stopped at lint + typecheck, on the
# reasoning that DB-backed testing happened manually against the dev image
# rather than on every push — true until 6f21db8 added the integration lane,
# and false since.
#
# What that gap cost: run 4293 failed `test` and published :dev and :<sha>
# anyway, so the deployed server ran a build whose test lane was red. An image
# tag is the rollback substrate (family rule 46); one that can be published
# from a failing run is not a substrate you can roll back TO.
# Every lane gates the build. An image tag is the rollback substrate, and one
# that can be published from a failing run is not a substrate you can roll back
# TO.
needs: [gate, typecheck, lint, test, integration]
if: needs.gate.outputs.build == 'true'
runs-on: python-ci
@@ -296,22 +296,20 @@ jobs:
- uses: actions/checkout@v6
with:
# Derives a version — see the note in desktop.yml. Depth-1 sees one commit
# and produces a too-low value silently, with the lane green (§6.1).
# and produces a too-low value silently, with the lane green.
fetch-depth: 0
- name: Generate image tags and version
id: tags
# run: steps execute under busybox sh (family rule 81), so use POSIX `case`,
# run: steps execute under busybox sh, so use POSIX `case`,
# NOT bash `[[ ]]`.
run: |
# The image's version is DERIVED from its own shipped files — including the
# Android client it bakes in, which is why an APK-only change re-versions
# it. One value and no ordering key: nothing compares a server image, so
# §2 says do not invent one just because the other artifacts have one.
# it. One value and no ordering key: nothing compares a server image.
#
# This was a short sha on main and the literal "dev" elsewhere, which could
# not answer "how old is this instance?" — the question that actually gets
# asked of a self-hosted app running in several places.
# Date-shaped, because "how old is this instance?" is the question that
# actually gets asked of a self-hosted app running in several places.
BUILD_VERSION="$(sh packaging/version.sh display server)"
case "${{ github.ref }}" in
refs/heads/dev)
@@ -328,23 +326,22 @@ jobs:
echo "value=$TAGS" >> $GITHUB_OUTPUT
echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT
- name: Free disk space
run: |
docker system prune -af || true
docker builder prune --keep-storage 5g -f || true
# No disk-space prune step: the runner's Docker daemon is shared, and
# `docker system prune -af` here deleted images other repos' jobs had
# just pulled and were about to start (CI-runner #16). The runner host
# prunes nightly instead.
# Bake EVERY client in, on every image build, so a self-hoster gets a working
# app for their machine from the server holding their notes — without an
# account on this forge, which is private (issue 2091) and is why serving them
# from a release page was never an option for anybody but the operator.
# app for their machine from the server holding their notes, with no trip to a
# release page on the forge.
#
# ~104 MB on top of the ~85 MB image, almost all of it the AppImage. That is
# the price of the product being complete (rule 23), and the AppImage is not
# the price of the product being complete, and the AppImage is not
# optional within it: it is the ONLY bundle that can replace itself in place,
# so a server without one cannot serve in-app updates to anyone.
#
# Fetched by the JOB, not by the Dockerfile: the releases are private, and a
# token used inside a build lands in the context or a layer.
# Fetched by the JOB, not by the Dockerfile, so the token it may use never lands
# in the build context or a layer.
#
# NEVER fails the build — see the script. A platform with nothing published
# means the server advertises nothing for it and the UI hides that download,
@@ -357,12 +354,8 @@ jobs:
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves dev clients;
# :latest serves stable ones. This read `download/dev` unconditionally
# until M314 step 3, on every branch — so every stable server shipped a
# dev-channel APK to anyone who downloaded the client from it. Not a
# versioning gap; a plain defect, and the reason the channel is chosen here
# rather than inside the script: the caller is what knows which image it is
# building.
# :latest serves stable ones. The channel is chosen here rather than inside
# the script because the caller is what knows which image it is building.
case "${{ github.ref_name }}" in
main) channel=stable ;;
*) channel=dev ;;
+61 -79
View File
@@ -4,22 +4,20 @@
# It DOES run on frontend changes. tauri's generate_context! embeds the built
# frontend in the binary, so a frontend commit that never triggers this ships to
# the web and silently never reaches the desktop app — and desktop, web and Android
# are peer surfaces held to one quality bar, not a primary and its fallbacks. The
# filter was once narrowed to the adapter/bridge directories against a "~20-40 min"
# build; measured runs are 4-5 minutes, so the cost that justified the narrowing
# isn't there.
# are peer surfaces held to one quality bar, not a primary and its fallbacks. A
# build takes 4-5 minutes, so there is no cost worth narrowing that for.
#
# Toolchain comes from the ci-tauri image (Rust + Node + WebKitGTK 4.1 + tauri-cli);
# runs-on is just a registered scheduling label (Label Model B), not a per-purpose
# runs-on is just a registered scheduling label, not a per-purpose
# runner. The frontend is built here because tauri's generate_context! embeds it.
name: Desktop (Tauri)
on:
push:
# NO `paths:` FILTER. It was a second, independent statement of this artifact's
# file set, hand-kept beside the one in `packaging/version.sh`, and it drifted
# from it within a day (85ead4d). The `decide` job below reads the real set and
# skips in seconds when nothing moved — one definition, one reader (§3).
# NO `paths:` FILTER. It would be a second, hand-kept statement of this artifact's
# file set beside the one in `packaging/version.sh`, and the two drift. The
# `decide` job below reads the real set and skips in seconds when nothing moved —
# one definition, one reader.
#
# The cost is that this workflow starts on every push rather than on a matching
# one. That is a ~15s container for a decision, against a lane that cannot
@@ -40,13 +38,9 @@ permissions:
jobs:
# Does anything need building at all?
#
# ONE reader of ONE definition — the file sets in `packaging/version.sh` — replacing
# the `paths:` filters that used to state the same fact a second time. They drifted
# from it within a day: `packaging/` was added to the sets and not to the filters,
# so the commit fixing a derivation bug never ran on the two lanes it fixed
# (85ead4d). Note 3127 §3 warns about exactly that duplication.
# ONE reader of ONE definition: the file sets in `packaging/version.sh`.
#
# THE GUARD RUNS HERE, so it runs on every path INCLUDING the skip one (§6.3).
# THE GUARD RUNS HERE, so it runs on every path INCLUDING the skip one.
# Skipping because "the channel already serves this version" is indistinguishable
# from "we derived a stale value that happens to match" unless something checks.
decide:
@@ -60,7 +54,7 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
# Derives a version — depth-1 is silently wrong (§6.1).
# Derives a version — depth-1 is silently wrong.
fetch-depth: 0
- name: Decide
@@ -76,13 +70,12 @@ jobs:
echo "build=$(sh packaging/should-build.sh desktop "$channel")" >> $GITHUB_OUTPUT
# The workspace checks, in their OWN job, so that BOTH publishing jobs can need
# them (rule 177: nothing publishes on red).
# them: 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.
# As steps inside `build` they would gate the Linux publish and nothing else, and
# the Windows installer would publish beside a failed clippy. 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
@@ -91,6 +84,7 @@ jobs:
verify:
name: Web tests, clippy, Rust tests and rustfmt
needs: [decide]
timeout-minutes: 20
if: needs.decide.outputs.build == 'true'
runs-on: python-ci
container:
@@ -107,25 +101,20 @@ jobs:
# The web's unit tests, HERE as well as in ci.yml. The installers embed this
# frontend, and ci.yml's verdict is invisible to this workflow — run there only,
# a red web test would still let both installers publish (rule 177).
# a red web test would still let both installers publish.
- name: Web unit tests
run: npm test
working-directory: frontend
# --locked on the FIRST cargo invocation is the lockfile gate: it fails the
# run if Cargo.toml and the committed Cargo.lock disagree, instead of silently
# re-resolving (issue 2102). Both publishing jobs need this job, so neither
# re-resolving. 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.
#
# These three steps used to run inside the desktop crate, which was right when
# it was the only Rust in the repo. After the core was extracted (M12 step 1)
# it silently stopped being right: cargo scoped to the desktop PACKAGE, so the
# core's 89 tests stopped running and nothing lints the Android uniffi shim at
# all. Both crates are dependencies of the desktop, so they still COMPILED —
# which is exactly why the gap was invisible, and why a green run kept meaning
# less than it looked like it meant.
# Run from the REPO ROOT with --workspace, not from desktop/src-tauri. Inside the
# desktop crate, cargo scopes to the desktop PACKAGE: the core's tests would not
# run and nothing would lint the Android uniffi shim, while both still COMPILE
# as dependencies — so the run stays green and the gap stays invisible.
- name: Clippy
run: cargo clippy --locked --workspace --all-targets -- -D warnings
@@ -146,6 +135,11 @@ jobs:
build:
name: Tauri desktop (Linux)
needs: [decide, verify]
# A deadline, not a budget: warm builds take ~4 minutes. Without one a hung step
# runs to the runner's own 30-minute cap, as a rust-lld link whose threads have
# all deadlocked will. Kept under that cap so this one is the limit
# that fires, whatever the runner is configured with.
timeout-minutes: 20
if: needs.decide.outputs.build == 'true'
runs-on: python-ci
container:
@@ -160,7 +154,7 @@ jobs:
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
# the lane green — 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.
@@ -174,10 +168,10 @@ jobs:
# Frontend already built above; skip the beforeBuildCommand rebuild.
#
# createUpdaterArtifacts is applied only when a signing key exists (M10.9):
# tauri FAILS the build if it's asked to produce updater artifacts with no key,
# so making it conditional is what lets the pipeline stay green before the
# operator has added the secret. With the key present, each bundle gets a
# createUpdaterArtifacts is applied only when a signing key exists: tauri
# FAILS the build if it's asked to produce updater artifacts with no key, so
# making it conditional is what lets the pipeline stay green without the
# secret. With the key present, each bundle gets a
# `.sig` beside it — the file the updater actually verifies against.
- name: Tauri build (deb + AppImage)
env:
@@ -194,10 +188,10 @@ jobs:
# The ORDERING KEY, not the display version: this string is what Tauri's
# updater parses as semver, and what it stamps into bundle FILENAMES that
# `write-manifest.sh` then selects on. The human-readable version is a
# separate value and arrives with the UI that shows it (#3181).
# separate value, exported just below.
version="$(sh ../../packaging/version.sh key desktop)"
echo "Building desktop ordering key $version"
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
# The DISPLAY version, baked into the binary by `option_env!`.
# 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
@@ -213,7 +207,7 @@ jobs:
# Tauri's AppImage bundles the build host's graphics/display libs
# (libEGL/libGL/libdrm/libgbm/libwayland-*), which clash with end-user GPU
# drivers and abort to a black window (EGL_BAD_PARAMETER, issue 2021).
# drivers and abort to a black window (EGL_BAD_PARAMETER).
# Strip that host-coupled stack so the app uses the running system's
# graphics libs; webkit/gtk stay bundled. Runs from the repo root (the
# script resolves its own paths), overwriting the AppImage in place.
@@ -247,9 +241,9 @@ jobs:
# control file and cross-checks it against what the ELF actually needs
# (dpkg-shlibdeps).
#
# We deliberately do NOT set bundle.linux.deb.depends: run 2872 showed
# tauri already infers exactly libwebkit2gtk-4.1-0 + libgtk-3-0, so
# declaring them again only produced a control file listing each twice.
# We deliberately do NOT set bundle.linux.deb.depends: tauri already infers
# exactly libwebkit2gtk-4.1-0 + libgtk-3-0, so declaring them again only
# produces a control file listing each twice.
# This step is the guard instead — if tauri's inference ever stops
# covering what the binary links, the build fails here.
- name: Verify the .deb
@@ -265,13 +259,12 @@ jobs:
run: bash desktop/packaging/arch/package-prebuilt.sh
# Make the built .deb + .AppImage downloadable from the run (for hand-testing).
# Stock action: it works on this forge since the runner moved to
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
# out of the action bundle (Scribe snippet #2271). Never @v3 — it uploads
# something Gitea stores but will never serve back (it returns artifacts only
# through the v4 API, which filters on content_encoding='application/zip').
# No continue-on-error: a swallowed upload failure is exactly how 110
# unreachable artifacts accumulated here unnoticed. Fail loudly instead.
# Stock action: it works on this forge because gitea/runner 3.x edits
# upload-artifact's client-side GHES refusal out of the action bundle. Never
# @v3 — it uploads something Gitea stores but will never serve back (it returns
# artifacts only through the v4 API, which filters on
# content_encoding='application/zip'). No continue-on-error: a swallowed upload
# failure lets unreachable artifacts pile up unnoticed. Fail loudly instead.
- name: Upload bundles
uses: actions/upload-artifact@v7
with:
@@ -286,14 +279,11 @@ jobs:
# The rolling channel for this branch: `dev` from dev, `stable` from main. Both
# are releases whose tag never moves, so the updater has a permanent URL to
# read — Forgejo has no /releases/latest/download/<asset> route, so "newest"
# read — the forge has no /releases/latest/download/<asset> route, so "newest"
# cannot be named in a URL.
#
# MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until
# M314 step 3 this job built on main and published nothing, so the stable
# channel moved only when somebody cut a tag — that section's diagnostic
# failing outright: main publishing was not sufficient for a user to receive
# the build.
# MAIN PUBLISHING HERE is what makes a `v*` tag optional: a merge to main
# reaches stable users with no tag cut.
#
# Gated on the signing key INSIDE the script rather than with an `if:`, because
# the secrets context isn't reliably available to step conditions. Publishing
@@ -309,7 +299,7 @@ jobs:
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
exit 0
fi
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh.
# `prerelease` is true for dev so it does not read as a supported build,
# and false for stable, which is the real thing.
case "$GITHUB_REF_NAME" in
@@ -317,7 +307,7 @@ jobs:
*) channel=dev; RELEASE_PRERELEASE=true ;;
esac
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
# (packaging/channel-tag.sh). A tag named `dev` shadowed the branch.
# (packaging/channel-tag.sh). A tag named `dev` would shadow the branch.
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing to the $RELEASE_TAG channel."
@@ -339,6 +329,7 @@ jobs:
windows:
name: Windows installer (cross-compiled)
needs: [decide, verify]
timeout-minutes: 20
if: needs.decide.outputs.build == 'true'
runs-on: python-ci
container:
@@ -348,7 +339,7 @@ jobs:
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
# the lane green — 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.
@@ -389,10 +380,10 @@ jobs:
# The ORDERING KEY, not the display version: this string is what Tauri's
# updater parses as semver, and what it stamps into bundle FILENAMES that
# `write-manifest.sh` then selects on. The human-readable version is a
# separate value and arrives with the UI that shows it (#3181).
# separate value, exported just below.
version="$(sh ../../packaging/version.sh key desktop)"
echo "Building desktop ordering key $version"
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
# The DISPLAY version, baked into the binary by `option_env!`.
# 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
@@ -423,14 +414,11 @@ jobs:
# The rolling channel for this branch: `dev` from dev, `stable` from main. Both
# are releases whose tag never moves, so the updater has a permanent URL to
# read — Forgejo has no /releases/latest/download/<asset> route, so "newest"
# read — the forge has no /releases/latest/download/<asset> route, so "newest"
# cannot be named in a URL.
#
# MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until
# M314 step 3 this job built on main and published nothing, so the stable
# channel moved only when somebody cut a tag — that section's diagnostic
# failing outright: main publishing was not sufficient for a user to receive
# the build.
# MAIN PUBLISHING HERE is what makes a `v*` tag optional: a merge to main
# reaches stable users with no tag cut.
#
# Gated on the signing key INSIDE the script rather than with an `if:`, because
# the secrets context isn't reliably available to step conditions. Publishing
@@ -446,7 +434,7 @@ jobs:
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
exit 0
fi
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh.
# `prerelease` is true for dev so it does not read as a supported build,
# and false for stable, which is the real thing.
case "$GITHUB_REF_NAME" in
@@ -454,7 +442,7 @@ jobs:
*) channel=dev; RELEASE_PRERELEASE=true ;;
esac
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
# (packaging/channel-tag.sh). A tag named `dev` shadowed the branch.
# (packaging/channel-tag.sh). A tag named `dev` would shadow the branch.
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing to the $RELEASE_TAG channel."
@@ -471,6 +459,7 @@ jobs:
manifest:
name: Update manifest
needs: [build, windows]
timeout-minutes: 5
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
container:
@@ -480,7 +469,7 @@ jobs:
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
# the lane green — 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.
@@ -511,19 +500,12 @@ jobs:
# 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.
#
# No tag arm any more. A `v*` tag does not reach this workflow at all — it
# No tag arm: a `v*` tag does not reach this workflow at all — it
# triggers release.yml, which writes a changelog and builds nothing.
case "${GITHUB_REF_NAME}" in
main) RELEASE_TAG="$(sh packaging/channel-tag.sh stable)"
export RELEASE_NOTES="Stable build from ${GITHUB_SHA}" ;;
*) RELEASE_TAG="$(sh packaging/channel-tag.sh dev)"
# TEMPORARY one-shot bridge (Scribe #2184). Desktop apps installed
# before the rename have .../download/dev/latest.json compiled in,
# so the manifest is also written to the old `dev` release; its
# URLs name dev-rolling assets, so those apps update once into a
# build that reads the new tag. Delete this line together with the
# old `dev` release and tag.
export BRIDGE_TAG=dev
export RELEASE_NOTES="Development build from ${GITHUB_SHA}" ;;
esac
# Assigned bare above so a failing channel-tag.sh fails this step;
+6 -6
View File
@@ -1,26 +1,26 @@
name: Release
# A RELEASE BUILDS NOTHING. That is the whole point of this lane (M314 step 7).
# A RELEASE BUILDS NOTHING. That is the whole point of this lane.
#
# The merge to `main` already published everything a user can receive: the server
# image as `:latest` + `:<sha>`, the desktop bundles and the APK to the `stable`
# channel, and the updater manifest that advertises them. A tag rebuilding that same
# source would produce identical artifacts under identical names, and would re-push
# `:<sha>` with different bytes — which rule 145 forbids even when they match.
# `:<sha>`, which a published tag never allows.
#
# So the tag is a BOOKMARK, and this lane gives it the only job it has left: saying
# what was in it. Note 3127 §5 — there are two halves to "what am I running", and
# what was in it. There are two halves to "what am I running", and
# the version answers only the first:
#
# which build is this? the footer, /api/config, the APK's versionName
# what changed since the one ← this
# I was running last month?
#
# Cutting the tag is the operator's act (rule 2). This only responds to one.
# This lane never cuts a tag; it only responds to one.
#
# THE TAG IS NOT AN IMAGE TAG and never becomes one. `ci.yml` does not trigger on
# tags at all. The image is addressed by channel or by commit; the release by date.
# Same string as the artifact version (rule 148, `vYYYY.MM.DD.HHMM`), different
# Same string as the artifact version (`vYYYY.MM.DD.HHMM`), different
# system.
on:
@@ -62,7 +62,7 @@ jobs:
# Through publish-release.sh for its create-or-PATCH-on-409 path: a
# release that is only ever POSTed keeps whatever body its first run
# wrote (#2182), so re-tagging or re-running must rewrite it. No bundles
# wrote, so re-tagging or re-running must rewrite it. No bundles
# exist in this workspace, so its asset globs match nothing and it
# uploads none — which is the intended behaviour, not a side effect.
RELEASE_TAG="$GITHUB_REF_NAME" bash desktop/packaging/publish-release.sh
+1 -1
View File
@@ -1,7 +1,7 @@
# Rust workspace. The framework-free client core, and the two shims that wrap it:
# the Tauri desktop app and the uniffi bindings the native Android client loads.
# Neither shim owns the core — that is the reason it is a crate at all rather than a
# module inside the desktop app (Scribe note 2730).
# module inside the desktop app.
[workspace]
resolver = "2"
members = ["core", "desktop/src-tauri", "android/ffi", "android/bindgen"]
+19 -3
View File
@@ -43,6 +43,11 @@ COPY alembic/ alembic/
# 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 forge's raw URL keeps working.
COPY desktop/packaging/install.sh src/inkwell/install.sh
ENV PYTHONPATH=/app/src
ARG BUILD_VERSION=dev
@@ -50,6 +55,17 @@ ENV APP_VERSION=$BUILD_VERSION
EXPOSE 5000
# Wait for the database, run migrations, then serve. The DB wait keeps a briefly
# slow/unready database from crash-looping the container. Family convention
# (rule 82): schema is built by real migrations, never metadata.create_all.
CMD ["sh", "-c", "python -m inkwell.dbwait && alembic upgrade head && hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
# slow or unready database from crash-looping the container. The schema is built by
# real migrations, never metadata.create_all.
#
# Timeouts, as hypercorn 0.18 implements them:
# - `--keep-alive 120` is the idle timeout. It is also the header timeout:
# hypercorn marks a connection busy only once a whole request has arrived, so a
# client dribbling headers is still "idle" and is cut off at this. 120 stays
# above Traefik's 90s idle timeout for backend connections, so the proxy never
# reuses one this server has just closed (a 502).
# - No `--read-timeout`, on purpose. It bounds every socket read, including the
# whole time a download is streaming and the client sends nothing, so it would
# cut off an APK fetched slowly over mobile data. Bodies are capped instead
# (MAX_CONTENT_LENGTH in app.py).
CMD ["sh", "-c", "python -m inkwell.dbwait && alembic upgrade head && hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 120"]
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+129 -93
View File
@@ -1,62 +1,21 @@
# Inkwell
<p align="center">
<img src="frontend/public/icon.svg" width="96" alt="Inkwell logo">
</p>
Self-hosted personal thought-capture web app in the **FabledSword** family — a
Google-Keep-style **masonry post-it board** for capturing disparate thoughts in
under a second, designed to grow into a lightweight second brain (labels, search,
`[[wiki-links]]`, graph).
<h1 align="center">Inkwell</h1>
- **Stack:** Quart (async Python) + Vue 3 + PostgreSQL, Docker, Fabled-Git CI.
- **Auth:** native email + password (signed-cookie sessions). Multi-user with the
family owner + direct-share + group-share ACL.
- **Web-first.** An Android companion is a later, conditional milestone.
<p align="center">Capture a thought in under a second, and find it again later.</p>
## Layout
Inkwell is a self-hosted notes app built around a Keep-style board of cards. Type
and it's saved; tag with `#words`, tick checklists, set reminders, attach files,
and search or browse by date when you need something back. It runs in the browser,
as a desktop app for Linux and Windows that works offline, and as an Android app,
all syncing with your own server.
```
src/inkwell/ Quart app (app factory, auth, models, ACL, config, db)
alembic/ async migrations (schema built via `alembic upgrade head`)
tests/ DB-free unit tests (pytest)
frontend/ Vue 3 + Vite + TypeScript + Tailwind SPA
Dockerfile 2-stage build (Vue -> python:3.12-slim runtime)
docker-compose.yml local app + Postgres stack
```
## Quick start
## Development
Backend (needs a Postgres reachable at `INKWELL_DATABASE_URL`):
```sh
pip install -e ".[dev]"
alembic upgrade head
hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000
```
Frontend (proxies `/api` to `:5000`):
```sh
cd frontend
npm install
npm run dev # http://localhost:5173
```
Or run it in Docker. **Hot-reload dev stack** (edit code, changes reload live) —
Postgres + backend + Vite dev server, app at http://localhost:5173:
```sh
docker compose -f docker-compose.dev.yml up
```
Or the **full production image** (SPA baked in) at http://localhost:5000:
```sh
docker compose up --build
```
## Deploy (self-host)
A complete two-container stack (app + Postgres) you can copy and run. Save it as
`docker-compose.yml`, change the two `CHANGE_ME` passwords (they must match), then
`docker compose up -d`:
Save this as `docker-compose.yml`, replace both `CHANGE_ME`s with the same password,
and run `docker compose up -d`:
```yaml
services:
@@ -65,26 +24,20 @@ services:
restart: unless-stopped
environment:
POSTGRES_USER: inkwell
POSTGRES_PASSWORD: CHANGE_ME # change this
POSTGRES_PASSWORD: CHANGE_ME
POSTGRES_DB: inkwell
volumes:
- inkwell-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U inkwell"]
interval: 5s
timeout: 5s
retries: 10
app:
image: git.fabledsword.com/bvandeusen/inkwell:latest # :dev for the current dev build
image: git.fabledsword.com/bvandeusen/inkwell:latest
restart: unless-stopped
depends_on:
db:
condition: service_healthy
- db
environment:
INKWELL_DATABASE_URL: postgresql+asyncpg://inkwell:CHANGE_ME@db:5432/inkwell
volumes:
- inkwell-data:/var/inkwell # uploaded images; omit if you don't use attachments
- inkwell-data:/var/inkwell
ports:
- "5000:5000"
@@ -93,36 +46,119 @@ volumes:
inkwell-data:
```
Then open `http://<host>:5000` and register — **the first account becomes the admin**.
Open `http://<host>:5000` and register. **The first account becomes the admin.**
- **Only `INKWELL_DATABASE_URL` is required.** `INKWELL_SECRET_KEY` is optional; if
unset, a signing key is generated and persisted in the database (sessions survive restarts).
- Uploaded images live under the `inkwell-data` volume at `/var/inkwell`.
- The app waits for the database and runs migrations (`alembic upgrade head`) automatically on start.
- **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev`
build) · `:<git-sha>` on `main` only (immutable, the rollback unit). There are
no version-shaped tags: nothing pins one, and the build reports its own version
at `/api/config` and `/health`.
- **Putting it on the public internet:** there are four things to do first — close
registration, terminate TLS and forward `X-Forwarded-Proto`, stop publishing the app
port, and back up the attachment volume as well as the database. See
[docs/public-hosting.md](docs/public-hosting.md), which also lists what the app
hardens on its own and what it deliberately doesn't.
- **Install as an app (PWA):** Inkwell is installable ("Add to Home Screen" / the
browser's install button) for an app-like window. Browsers only offer install over a
**secure context**, so put the app behind a reverse proxy terminating **HTTPS** (or reach
it via `localhost`) — plain `http://<host>:5000` won't show the install prompt.
The repository's own [docker-compose.yml](docker-compose.yml) is the same stack read
from a `.env` file; copy [.env.example](.env.example) to `.env` to use it.
## Milestones
## Configuration
- **M0 — Foundation & Identity** ✅: Quart+Vue+Postgres skeleton, native auth,
sharing-ACL spine, Fabled-Git CI.
- **M1 — Capture Core** ✅: note model + masonry board (quick-add, colors, pin,
edit-in-place, archive, trash+restore).
- **M1.5 — Roles & DB-backed Settings** ✅: first user is admin, admin Settings UI,
`DATABASE_URL` is the only required env.
- M2 — Organize: labels/tags, search, checklists, attachments.
- M3 — Second-brain seeds: `[[wiki-links]]`, backlinks, graph view, reminders.
- M4 — Launch hardening: responsive/PWA, settings UI, polish.
| Variable | Required | What it is |
|---|---|---|
| `INKWELL_DATABASE_URL` | yes | The Postgres connection URL. |
| `INKWELL_SECRET_KEY` | no | Signs sessions. Unset, one is generated and kept in the database. |
| `INKWELL_LOG_LEVEL` | no | `INFO` by default. Sign-ins and other credential events log at INFO. |
Work happens on `dev`; `main` is protected (PR-only).
Everything else — site name, sign-ups, email, attachment limits, trash retention,
link previews, backups, proxy hops — is set by the admin in **Settings**.
## Before you put it on the internet
Close registration, terminate TLS and forward `X-Forwarded-Proto`, stop publishing
the app port directly, and back up the attachment volume as well as the database.
[docs/public-hosting.md](docs/public-hosting.md) walks through each one, and lists
what the app already hardens on its own.
The browser only offers to install Inkwell as an app (PWA) over HTTPS or `localhost`.
## Updating and backups
- **Update:** `docker compose pull && docker compose up -d`. Migrations run on start.
- **Roll back:** pin `image: …/inkwell:<commit-sha>`. Every build on `main` publishes one.
- **Back up:** turn on scheduled backups in **Settings → Backups** (a folder, an
S3-compatible bucket, or Fabled Archives). See [docs/backups.md](docs/backups.md).
Otherwise back up both volumes: `inkwell-db` and `inkwell-data`.
## Features
**Capturing**
- A masonry board of cards. Start typing anywhere on it to write a new note.
- Notes save as you type; there is no save button.
- Checklists inside a note, ticked straight from the card.
- Images and files attached to a note, shown on its card; click an image to see it
full size or copy it back out.
- Paste a link and it gets a preview card.
- On the desktop, a global hotkey opens a small capture window over whatever you're doing.
- On Android, share text or images into Inkwell from any app.
**Organising**
- `#tags` written in the text become tags; a line of tags on its own is tidied away.
- Each tag gets a colour, and tags can be renamed, recoloured, merged and deleted.
- Pin, archive, drag to reorder.
- Trash with undo, emptied after a retention period the admin sets.
**Finding**
- Full-text search, combined with filters for tags, attachments and notes shared with you.
- A timeline that groups notes by when you wrote them.
- A command palette that jumps to any note by name.
- Version history: every editing session keeps the note as it was before.
**Remembering**
- Reminders, one-off or repeating, with Done and snooze from the card or the
notification.
- The desktop and Android apps notify you when the app isn't in front.
**Sharing and accounts**
- Share a note with a person or a group, to view or to edit.
- Invites, groups, per-account storage limits and an activity log for the admin.
- Password reset by email, or by a link the admin makes.
- Export everything as a zip, and import an Inkwell export or a Google Keep Takeout.
**Every device**
- Web app, installable as a PWA.
- Desktop app for Linux (`.deb`, Arch package, AppImage) and Windows. It keeps its
own local store, works with no server at all, and syncs once you link one. Install
it from your server's Account page, or with `curl -fsSL https://<your-server>/install.sh | sh`.
- Android app, downloaded from your server and updated by it.
## Troubleshooting
| What you see | Why | Fix |
|---|---|---|
| The app container restarts with a database error at startup | Postgres isn't up yet, or the two passwords differ | Wait a minute; check both `CHANGE_ME`s match |
| No "Install app" option in the browser | The page isn't on HTTPS | Serve it over HTTPS, or open it on `localhost` |
| The desktop or Android app can't reach a plain-HTTP server | A bare hostname is read as `https://` | Type the address with `http://` |
| A deployment upgraded from ThoughtSync shows no notes | It's pointed at new, empty volumes | Set `INKWELL_DB_VOLUME` and `INKWELL_DATA_VOLUME`; see [.env.example](.env.example) |
## Development
Backend (needs Postgres at `INKWELL_DATABASE_URL`):
```sh
pip install -e ".[dev]"
alembic upgrade head
hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000
```
Frontend, proxying `/api` to `:5000`: `cd frontend && npm install && npm run dev`
(http://localhost:5173). Or run everything with hot reload:
`docker compose -f docker-compose.dev.yml up`.
| Path | What's there |
|---|---|
| `src/inkwell/` | The Quart server |
| `alembic/` | Database migrations |
| `frontend/` | The Vue web app, also the desktop app's UI |
| `core/` | The Rust store and sync engine the desktop and Android apps share |
| `desktop/` | The Tauri desktop app and its packaging |
| `android/` | The Kotlin/Compose Android app |
How devices sync is in [docs/sync.md](docs/sync.md); how the Android app reaches a
server is in [docs/android-distribution.md](docs/android-distribution.md). Work
happens on `dev`; `main` is the stable line.
## Licence
Inkwell is free software under the [GNU Affero General Public License v3.0 only](LICENSE)
(`AGPL-3.0-only`). If you run a modified version for other people over a network, the
licence requires you to offer them its source.
+2 -2
View File
@@ -4,8 +4,8 @@ Revision ID: 0001
Revises:
Create Date: 2026-07-19
The M0 identity + sharing-ACL spine. No notes yet (those arrive in M1); the
shares table is polymorphic so notes reuse it without a schema retrofit.
Identity and the sharing ACL. The shares table is polymorphic, so any shareable
entity uses it.
"""
from alembic import op
import sqlalchemy as sa
+1 -1
View File
@@ -4,7 +4,7 @@ Revision ID: 0002
Revises: 0001
Create Date: 2026-07-19
The M1 capture core: the notes table the masonry board renders.
The notes table the board renders.
"""
from alembic import op
import sqlalchemy as sa
+1 -1
View File
@@ -5,7 +5,7 @@ Revises: 0002
Create Date: 2026-07-19
Adds the admin role bit and the DB-backed settings table. No rows are seeded —
defaults live in the code registry (thoughtsync.settings), so an empty table
defaults live in the code registry (inkwell.settings), so an empty table
means every setting is at its default.
"""
from alembic import op
+2 -4
View File
@@ -16,10 +16,8 @@ depends_on = None
def upgrade() -> None:
# The note's display NAME: explicit title if set, else the first body line.
op.add_column("notes", sa.Column("display_title", sa.Text(), nullable=False, server_default=""))
# Best-effort backfill (kept intentionally simple + unambiguous — CI has no
# Postgres lane, so this only runs on a real deploy). The app recomputes the
# precise value via derive_display_title on the next save (which additionally
# skips leading blank lines). Capped at 200 chars.
# Best-effort backfill, capped at 200 chars. The app recomputes the exact value
# (derive_display_title, which also skips leading blank lines) on the next save.
op.execute(
r"""
UPDATE notes SET display_title = LEFT(btrim(
+1 -4
View File
@@ -1,4 +1,4 @@
"""sync revision + tombstone infrastructure (M8 sync hub, step 1)
"""sync revision + tombstone infrastructure
Revision ID: 0015
Revises: 0014
@@ -11,9 +11,6 @@ forget to bump it. Child-row changes (items / attachments / label membership) bu
their parent note's revision, since a note syncs as a whole. `purged_at` is the
hard-delete tombstone (distinct from `deleted_at` = recoverable trash) so an
offline client learns a row is gone instead of resurrecting it.
No API behavior changes here — this is pure infrastructure. Triggers/behavior are
operator-verified on deploy (no Postgres CI lane).
"""
from alembic import op
import sqlalchemy as sa
+1 -1
View File
@@ -1,4 +1,4 @@
"""device_tokens (M8 sync hub, step 2)
"""device_tokens
Revision ID: 0016
Revises: 0015
+1 -1
View File
@@ -1,4 +1,4 @@
"""labels.updated_at (M8 sync hub, step 4 — LWW field for the label catalog)
"""labels.updated_at, the last-write-wins field for the label catalog
Revision ID: 0017
Revises: 0016
+1 -1
View File
@@ -1,4 +1,4 @@
"""note_attachments.sha256 (M8 sync hub, step 5 — attachment blob sync)
"""note_attachments.sha256, for attachment blob sync
Revision ID: 0018
Revises: 0017
+1 -1
View File
@@ -1,4 +1,4 @@
"""note_attachments.filename (any-file attachments — M6 1900)
"""note_attachments.filename, for attachments of any file type
Revision ID: 0019
Revises: 0018
+1 -1
View File
@@ -1,4 +1,4 @@
"""note_link_previews (URL unfurl — M6 1901)
"""note_link_previews, cached URL unfurls
Revision ID: 0020
Revises: 0019
+1 -1
View File
@@ -1,4 +1,4 @@
"""saved_filters (richer search + saved views — M6 1902)
"""saved_filters, saved search views
Revision ID: 0021
Revises: 0020
+1 -1
View File
@@ -1,4 +1,4 @@
"""notes.recurrence (recurring reminders — M6 1908)
"""notes.recurrence, for recurring reminders
Revision ID: 0022
Revises: 0021
+3 -6
View File
@@ -1,14 +1,11 @@
"""note_links.target_id — resolve [[links]] to a note, not to a string (M13 step 1)
"""note_links.target_id — resolve [[links]] to a note, not to a string
Revision ID: 0023
Revises: 0022
Create Date: 2026-08-22
A wiki-link stored only as normalized TEXT means a note's name IS the edge: rename
the note and every inbound link stops matching. The old answer was to rewrite the
`[[Old Name]]` text inside every note that linked to it — workable while an explicit
title existed to hold still, untenable once a note's name is just its first body
line (M13).
A wiki-link stored only as normalized text makes a note's name the edge, so renaming
the note breaks every inbound link. Resolving to an id survives a rename.
`target_norm` stays: it is what an UNRESOLVED link carries, since linking to a note
that doesn't exist yet is a supported way to create one.
+5 -13
View File
@@ -1,22 +1,14 @@
"""drop note_links — [[wiki-links]] are removed (note 2897)
"""drop note_links — [[wiki-links]] are removed
Revision ID: 0024
Revises: 0023
Create Date: 2026-08-22
ThoughtSync is an intermediary surface for capture and recall; a linking system is
organization, which is not what it is for. Backlinks, the graph and the name index
went with it.
0023 stays in the chain: removing an applied revision would strand a database's
alembic_version pointer.
0023 (which added `note_links.target_id`) is deliberately left in the chain rather
than deleted. It shipped in an image and may already be applied, and removing an
applied revision would strand a database's alembic_version pointer. So the column is
dropped here along with the table it lived on, and the history stays honest about the
fact that it existed for a day.
No down-migration data concern: note_links was always DERIVED from note bodies. The
`[[text]]` is still sitting in every body it was written in; nothing a person typed is
lost by this.
note_links was derived from note bodies, so nothing typed is lost; the `[[text]]` is
still in every body.
"""
from alembic import op
import sqlalchemy as sa
+5 -14
View File
@@ -1,19 +1,11 @@
"""drop notes.kind — a checklist is something a note HAS (M13 step 2)
"""drop notes.kind — a checklist is something a note has
Revision ID: 0025
Revises: 0024
Create Date: 2026-08-22
`kind` was never a type: a plain TEXT column with no enum and no CHECK, compared
against a hardcoded ("text", "list") tuple in six places. `note_items` was always an
ordinary child table keyed by note_id, serialization always emitted `items` whatever
the kind, and the Android editor already toggled between the two losslessly. The
storage has modelled "a body plus optional checkable items" the whole time; only the
gates forbade it.
Nothing is lost. Items were already rows in their own table, and a note that was
`kind = 'list'` keeps every one of them — it just stops being a different sort of
thing from the note next to it.
A note is a body plus optional checkable items. Items are rows in their own table,
so a `kind = 'list'` note keeps every one.
"""
from alembic import op
import sqlalchemy as sa
@@ -29,7 +21,6 @@ def upgrade() -> None:
def downgrade() -> None:
# server_default so existing rows get a value; every note comes back as 'text',
# which is right — a restored note with items would previously have hidden its
# body, and there is no record of which ones were once lists.
# server_default so existing rows get a value. Every note comes back as 'text':
# nothing records which were lists.
op.add_column("notes", sa.Column("kind", sa.Text(), nullable=False, server_default="text"))
+6 -25
View File
@@ -4,33 +4,14 @@ Revision ID: 0026
Revises: 0025
Create Date: 2026-08-22
M13 step 3. A note is a body plus optional checkable items; its NAME is the first
non-empty line of that body, falling back to its first checklist item. There is no
separate field to type into, and `display_title` (already persisted, already what
search results and export filenames read) carries the name.
A note's name is its first non-empty body line, carried by `display_title`.
## The search vector has to be rebuilt, not just left alone
`notes.search_vector` is a stored generated column over `title` (0005, weight A), and
Postgres won't drop a column a generated one depends on. So it is recreated over
`display_title`, keeping the name ranked above the body. That recomputes every row
and rebuilds the GIN index, which takes a while on a large table.
`notes.search_vector` is a STORED GENERATED column whose expression names `title`
(migration 0005, weight A) — Postgres will refuse to drop a column another generated
column depends on, and even if it didn't, the weighting would be wrong. So it is
dropped and recreated over `display_title` instead, which keeps the original
intent: the note's NAME ranks above the rest of its body.
Rebuilding a stored generated column re-computes every row, and the GIN index is
rebuilt with it. On a personal instance that is milliseconds; it is worth knowing
before running this against something large.
## What happens to existing titles
Nothing preserves them, deliberately: `display_title` was already derived from the
title when one was set, so every note keeps the NAME it had. What is lost is the
distinction between "this note has an explicit title" and "this note's first line is
its name" — which is the distinction being removed.
Imports are the exception and are handled in code, not here: a Keep note's title, or
one in an export taken before this, is folded in as the note's first body line rather
than dropped (see `_create_imported_note`).
`display_title` was already derived from the title, so every note keeps its name.
"""
from alembic import op
import sqlalchemy as sa
@@ -4,64 +4,31 @@ Revision ID: 0027
Revises: 0026
Create Date: 2026-08-24
M304. A checklist item becomes a `- [ ] milk` line of `notes.body`, and `note_items`
goes. The reason is positional, not cosmetic: a row had a position in a table and no
position in the text, so a separate list could only ever render AFTER the prose. With
the items in the body, a list can sit between two paragraphs — which is the thing that
could not be built before and no amount of restyling would have delivered.
A checklist item becomes a `- [ ] milk` line of `notes.body`, so a list can sit
between two paragraphs, and `note_items` goes.
## This migration rewrites note bodies
Every note that has items gets its body appended to. The rules below are strict
because rewriting somebody's text deserves it — not, as an earlier draft of this
docstring claimed, because this instance holds imported Google Keep notes. It does
not; note 2916's headline is that nothing here is anyone's work but the operator's
test data. What 2916 actually says about imports is conditional — text arriving from
another app WOULD be real, and any import path has to treat it that way — and the
importer this migration shares a format with is one nobody here has run.
* Rows are read before the table is dropped, in this one transaction.
* The existing body is only appended to.
* The layout (a blank line between prose and the list, nothing between items) is
what the export writes and what `derive::append_item` produces, so the clients
migrate their own stores independently and still agree with the server.
Careful was still the right call. It cost little, and the same care is what the rule
demands the day someone does import something:
The fold is inlined rather than imported from `notes/checklist.py`: a migration must
keep producing what it produced the day it ran.
* Rows are read BEFORE the table is dropped, in this one transaction.
* The existing body is never rewritten, only appended to.
* The layout — a blank line between prose and the list, nothing between consecutive
items — is byte-for-byte what `_note_markdown` has always exported and what
`derive::append_item` produces on every client. All three landing on the same text
is what lets the clients migrate their own SQLite stores independently and still
agree with the server, with no sync required to reconcile them.
## `updated_at` is left alone
## The fold is inlined on purpose
Raw SQL, so `onupdate` never fires, and a client holding an unpushed edit keeps the
newer `updated_at` and wins last-write-wins. The `sync_revision` trigger does fire,
so every migrated note is pulled once and clients converge.
`notes/checklist.py` has this same function and this migration deliberately does not
import it. A migration has to keep producing what it produced the day it ran; if the
app's spacing rule ever changes, this file must not change with it.
## The downgrade is not a true inverse
## `updated_at` is left alone, and that is load-bearing
Raw SQL, so SQLAlchemy's `onupdate` never fires. Two reasons, and the second matters
more than the first. Every client folds the same rows the same way, so the new body is
news to nobody. And a client holding an UNPUSHED body edit still has the newer
`updated_at`, so when it pulls the migrated note last-write-wins keeps its edit instead
of the migration silently winning.
The `notes` row's own `sync_revision` trigger (migration 0015) does fire, so every
migrated note becomes pullable once. That is wanted: it is what makes a client whose
local fold somehow differed converge on the server's text.
## The downgrade is not a true inverse, and says so
It recreates an empty `note_items` and leaves the bodies alone. Nothing is lost —
every item is still there as text, which is where this migration put it — but the old
code would show those notes as prose with no checklist. A faithful inverse is not
possible: once the items are lines, nothing distinguishes a line this migration wrote
from one somebody typed, and a downgrade that guessed would eat hand-written task
lists. The real rollback is a database restore.
Recreating the table is not decoration, though. Migration 0015's downgrade runs
`DROP TRIGGER IF EXISTS trg_note_items_bump_note ON note_items`, and `IF EXISTS`
covers the trigger, not the table — against a missing table that statement errors. So
this is what keeps the migration chain runnable all the way back down.
It recreates an empty `note_items` and leaves the bodies alone: nothing tells a line
this wrote from one somebody typed. The real rollback is a database restore. The
table must exist because 0015's downgrade drops a trigger on it.
"""
import re
@@ -100,9 +67,8 @@ def upgrade():
for note_id, items in grouped.items():
body = bind.execute(sa.text("SELECT body FROM notes WHERE id = :id"), {"id": note_id}).scalar()
# An item whose note is already gone has nothing to fold into. The foreign key
# should make this impossible; skipping costs nothing and failing here would
# leave the database half-migrated.
# An item whose note is gone has nothing to fold into. The foreign key should
# prevent it; skipping avoids a half-migrated database.
if body is None:
continue
for text, checked in items:
@@ -113,9 +79,8 @@ def upgrade():
def downgrade():
# Column-for-column as migration 0006 created it, index name included: 0015's
# downgrade names both the table and its trigger, so a near-enough copy is not
# good enough.
# Exactly as 0006 created it, index name included: 0015's downgrade names both
# the table and its trigger.
op.create_table(
"note_items",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
+19 -71
View File
@@ -4,73 +4,27 @@ Revision ID: 0028
Revises: 0027
Create Date: 2026-08-26
M311. A `#tag` was being shown twice — once as the text you typed and once as a chip —
and with the chip moved to the top of the card the text is redundant. This removes it,
but only from notes where the tag was standing on its own.
A tag standing on its own line shows as a chip, so its text is removed, by the same
rule as `notes/tags.py:split_body_tags`:
## This migration rewrites note bodies
* A line containing nothing but tags and whitespace is removed.
* Every other line is left exactly as written, so `remember to call #mom` stays.
* A line inside a ``` fence is never touched.
* A note that is nothing but tags keeps its text, rather than becoming a blank card.
The rule is deliberately narrow, and the same one `notes/tags.py:split_body_tags`
applies from here on:
The label rows flip to `via_tag = false` in the same transaction: the text backing
them is gone, and `_lift_and_reconcile_tags` would otherwise detach them on the next
save.
* A line containing nothing but tags and whitespace is REMOVED.
* Every other line is left exactly as written.
The transform and `_display_title` are inlined: a migration must keep producing what
it produced the day it ran. The title is recomputed only where the body moved.
So `#todo` on its own line goes, and `remember to call #mom tomorrow` does not. The
looser reading — also stripping a trailing tag off a prose line — was rejected because
the text does not say which kind it is: `buy milk #grocery` is filing, `remember to
call #mom` is the sentence's object, and lifting the second leaves "remember to call".
Rewriting somebody's words to save a duplicate chip is a bad trade, and a migration is
the worst possible place to make it.
Raw SQL, so `onupdate` never fires and a client's unpushed edit still wins
last-write-wins. The `sync_revision` trigger fires, so clients pull the server's text.
Two guards, both of which cost a note nothing:
* A line inside a ``` fence is never touched. A `#tag` there is a shell comment in a
snippet somebody pasted, and deleting it would eat a line of their example.
* A note that is NOTHING but tags keeps its text. Lifting would leave a blank card,
which is worse than the duplication this fixes.
## The label rows have to graduate in the same transaction
A `via_tag` row means "this label is backed by text still in the body". Once the text
is gone that is false, and leaving it true is not cosmetic: `_lift_and_reconcile_tags`
detaches any `via_tag` row it cannot find a `#tag` for, so the note would lose the tag
on its very next save. The flip to `via_tag = false` is what makes the label the record
instead — and what makes the chip's × appear in both editors, which is now the only way
to remove a tag whose text no longer exists.
## The transform is inlined, like 0027's
`split_body_tags` is deliberately NOT imported. A migration has to keep producing what
it produced the day it ran; if the app's rule is ever loosened, this file must not
loosen with it and start eating prose it previously left alone.
`_display_title` is inlined for the same reason, and is only recomputed for a note whose
body actually moved — a note named after a `#todo` line needs a new name, and reading it
from the app would couple this migration to a rule that has already changed once (M13).
## `updated_at` is left alone, and that is load-bearing
Raw SQL, so SQLAlchemy's `onupdate` never fires. A client holding an UNPUSHED body edit
keeps the newer `updated_at`, so when it pulls the migrated note last-write-wins keeps
its edit instead of the migration silently winning.
The `sync_revision` trigger (migration 0015) does fire, so every rewritten note becomes
pullable once and clients converge on the server's text. That is wanted here: unlike
0027, the clients do NOT yet apply this rule locally, so the server's copy is the only
correct one until they do.
## The downgrade is not a true inverse, and says so
It cannot be. Nothing distinguishes a `#todo` line this migration deleted from one that
was never there, and putting one back would be guessing at where in the note it went.
Nothing is lost, though, which is why that is acceptable: the tag still exists as a
label on the note, and the chip still shows it. What a downgrade cannot restore is the
DUPLICATE — which is the thing this migration set out to remove. Rolling the rows back
to `via_tag = true` would be actively harmful: the text that flag claims to be backed by
is gone, so the next save would detach the label and lose the tag for real. So the
downgrade leaves both alone. The real rollback is a database restore.
The downgrade does nothing. Restoring the lines would be guessing, and flipping the
rows back to `via_tag = true` would detach every label on the next save. The real
rollback is a database restore.
"""
import re
@@ -152,17 +106,11 @@ def upgrade():
sa.text("UPDATE notes SET body = :body, display_title = :title WHERE id = :id"),
{"body": lifted, "title": _display_title(lifted), "id": note_id},
)
# Even when the body did not move, a tag can be standalone only in the sense
# that its line was already removed by an earlier pass — so the flip is driven
# by the tag list, not by whether the text changed.
# The flip is driven by the tag list, not by whether the text changed: an
# earlier pass may already have removed the line.
if standalone:
bind.execute(flip, {"nid": note_id, "names": [n.lower() for n in standalone]})
def downgrade():
"""Deliberately empty — see the module docstring.
Restoring the deleted lines would be guessing, and flipping the rows back to
`via_tag = true` would be worse than doing nothing: the text that flag claims backs
them is gone, so the next save would detach the label and lose the tag for real.
"""
"""Deliberately empty; see the module docstring."""
+9 -42
View File
@@ -4,50 +4,18 @@ Revision ID: 0029
Revises: 0028
Create Date: 2026-08-28
M315 step 3. A note's colour was set by a picker and read by three card renderers.
Steps 1 and 2 stopped every one of those reads: the card is one neutral per theme and
the only coloured thing on a board is a tag. This drops the column that nothing has
been reading since, and the picker goes with it.
Cards are one neutral surface per theme; the only colour on a board is a tag's.
`labels.color` is untouched. A colour a note was given is dropped with the column.
`labels.color` is untouched. That is the colour that survived, and the one the whole
milestone was about keeping.
`saved_filters.params` may carry a `"color"` facet, so stored views are swept too. In
Python rather than `params::jsonb - 'color'`, because Postgres has no try-cast and one
malformed blob would abort the migration; a row that doesn't parse is left as it is.
## What is lost, and why that is the change rather than a cost of it
Any colour a note was explicitly given. There is nowhere to preserve it TO — the field
it would be preserved in is the one being dropped — and nothing renders it, so a
preserved value would be a column kept warm for a feature that was deliberately
removed. A note that had a colour now takes its identity from its tags, which is what
the operator asked for: "strip color from the cards ... and keep the color for tags
just on the tag."
The palette itself is not lost. `NOTE_COLORS` moved from `models/note.py` to
`colors.py` in the same change — labels still name a colour, and leaving the vocabulary
defined on the model that lost one would be an invitation to put the column back.
## The saved-filter sweep is not optional
`saved_filters.params` is opaque JSON mirroring the `GET /api/notes` facet query, and
a stored view could carry `"color": "teal"`. With the facet gone that key would sit
there forever, and `clean_params` only guards what is written FROM here on. A view that
silently filters on a field the app no longer has is worse than one that visibly lost a
criterion, so the stored rows are swept too.
Done in Python rather than as `params::jsonb - 'color'`, deliberately. Postgres has no
try-cast: one malformed blob would abort the whole migration, and these rows are
somebody's saved views. `json.loads` in a try/except lets a corrupt row keep whatever it
holds and lets every other row be fixed.
## Search is not affected
`notes.search_vector` is a stored generated column over `display_title` and `body`
(rebuilt in 0026). It never named `color`, so unlike the title drop there is nothing
here to tear down and recreate.
The search vector never named `color`, so it needs no rebuild.
## Downgrade
Restores the column, empty, at its old default. The values are not recoverable — see
above. It is the schema that comes back, not the data.
Restores the column, empty, at its old default. The values are not recoverable.
"""
import json
@@ -71,9 +39,8 @@ def upgrade() -> None:
try:
parsed = json.loads(params)
except (ValueError, TypeError):
# A blob that does not parse cannot be edited safely. Leaving it is
# correct: it was already unreadable by the app, and this migration is not
# the place to decide what it should have said.
# A blob that doesn't parse can't be edited safely; the app couldn't read
# it either.
continue
if not isinstance(parsed, dict) or "color" not in parsed:
continue
+3 -9
View File
@@ -4,15 +4,9 @@ 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 app was renamed from ThoughtSync to Inkwell. The Settings page saves every key,
so most servers have a stored `site_name` of the old default without anyone choosing
it. The row is rewritten only when it holds exactly that; a typed name is left alone.
The match is on the stored JSON text (`settings.value` is `json.dumps(value)`), so
`'"ThoughtSync"'` is the whole of what it looks for.
@@ -4,16 +4,9 @@ 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.
A note syncs as a whole, so every child table bumps its note's `sync_revision`. With
this, a preview fetched after a save, or dismissed on the web, reaches every device.
The trigger is the function 0015 installs on the other child tables.
## Downgrade
+2 -3
View File
@@ -4,9 +4,8 @@ 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.
An invite lets one person register while registration stays closed. Only the token's
SHA-256 hash is stored.
## Downgrade
+5 -6
View File
@@ -4,20 +4,19 @@ 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.
An admin makes a reset link for the account and hands it over. 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.
It starts at 0, the value a cookie without one is read as, so the upgrade signs
nobody out.
## Downgrade
Drops the table and the column. Outstanding reset links stop working; sessions keep
working, since nothing checks an epoch any more.
working, since nothing checks an epoch.
"""
import sqlalchemy as sa
from alembic import op
+2 -3
View File
@@ -4,11 +4,10 @@ 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:
For the Share dialog:
- 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).
stacking two (the same for a group).
- A check that `permission` is `view` or `edit`.
- An index on `shared_with_user_id` for "Shared with me".
+1 -1
View File
@@ -5,7 +5,7 @@ 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
them. 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
+4 -6
View File
@@ -4,12 +4,10 @@ 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.
Pin, archive and board order are personal. The owner's are columns on `notes`; this
table holds everyone else's, one row per (note, person it is shared with) who has
pinned, archived or moved it. 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
@@ -4,12 +4,8 @@ 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.
Nothing set `email_verified` or read `avatar_path`, and a field whose value means
nothing would be trusted by a later feature.
## Downgrade
@@ -0,0 +1,39 @@
"""saved_filters: drop the table, saved views are gone
Revision ID: 0038
Revises: 0037
Create Date: 2026-10-08
Saved views are removed; tags in the drawer 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"])
+40
View File
@@ -0,0 +1,40 @@
"""audit_events: the audit log
Revision ID: 0039
Revises: 0038
Create Date: 2026-10-08
Keeps credential events in the database for `audit_retention_days`, for admins to
read in Settings → Activity; the app log isn't queryable and rotates away.
## Downgrade
Drops the table and every event in it.
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision = "0039"
down_revision = "0038"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"audit_events",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("event", sa.Text(), nullable=False),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("email", sa.Text(), nullable=True),
sa.Column("address", sa.Text(), nullable=True),
sa.Column("detail", sa.Text(), nullable=True),
)
op.create_index("ix_audit_events_at", "audit_events", ["at"])
def downgrade() -> None:
op.drop_index("ix_audit_events_at", table_name="audit_events")
op.drop_table("audit_events")
+39
View File
@@ -0,0 +1,39 @@
"""labels: a tag's name is unique among LIVE tags only
Revision ID: 0040
Revises: 0039
Create Date: 2026-10-08
Deleting a tag leaves a tombstone row (`purged_at` set) so the change feed tells
linked devices. A tombstone must not hold its name, so `(owner_id, name)` is unique
only where `purged_at IS NULL`.
## Downgrade
Restores the plain unique constraint. Tombstones are deleted first, since one may
share its name with a live tag; devices that have not pulled since keep the tag.
"""
import sqlalchemy as sa
from alembic import op
revision = "0040"
down_revision = "0039"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_constraint("uq_labels_owner_name", "labels", type_="unique")
op.create_index(
"uq_labels_owner_name_live",
"labels",
["owner_id", "name"],
unique=True,
postgresql_where=sa.text("purged_at IS NULL"),
)
def downgrade() -> None:
op.drop_index("uq_labels_owner_name_live", table_name="labels")
op.execute("DELETE FROM labels WHERE purged_at IS NOT NULL")
op.create_unique_constraint("uq_labels_owner_name", "labels", ["owner_id", "name"])
+41 -34
View File
@@ -10,17 +10,16 @@ plugins {
val workspaceRoot: Directory = layout.projectDirectory.dir("../..")
// The ABIs a release APK carries. arm64 is essentially every real device; armv7
// covers older 32-bit hardware; the two x86 targets are what emulators run on, and
// dropping them would make the app untestable on a desktop emulator (the reason
// x86_64 was added to the old Tauri lane in task 1864).
// covers older 32-bit hardware; the two x86 targets are what emulators run, so the
// app stays testable on a desktop emulator.
val androidAbis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
/**
* Cross-compile `inkwell-ffi` for each Android ABI and drop the resulting
* `.so` into jniLibs, where AGP packages it.
*
* `ExecOperations` injected rather than `project.exec`: the latter was REMOVED in
* Gradle 9, and reaching for `project` at execution time is also what breaks the
* `ExecOperations` injected rather than `project.exec`: Gradle 9 has no
* `project.exec`, and reaching for `project` at execution time breaks the
* configuration cache this build has enabled.
*/
abstract class CargoNdkBuild : DefaultTask() {
@@ -53,11 +52,27 @@ abstract class CargoNdkBuild : DefaultTask() {
// --locked so an Android build cannot silently re-resolve the workspace
// lockfile the desktop lanes are gated on.
args += "--locked"
if (cargoProfile.get() == "release") args += "--release"
val release = cargoProfile.get() == "release"
if (release) args += "--release"
execOps.exec {
commandLine(listOf("cargo") + args)
workingDir = workspaceDir.get().asFile
if (release) {
// The workspace's release profile with two settings put back, for this
// build only. Environment overrides rather than a profile of our own,
// because cargo-ndk copies its `-o` output from the `release`
// directory; the desktop's binaries are untouched either way.
//
// Keep the symbol table: `strip = true` removes the symbols uniffi's
// `--library` mode reads the interface out of, and bindgen then fails
// with "No UniFFI metadata found". A release build has no debug info to
// strip, so this keeps symbols and nothing else.
environment("CARGO_PROFILE_RELEASE_STRIP", "debuginfo")
// Unwind, so a panic in the core reaches Kotlin as an exception the
// board can show rather than ending the app. See the ffi crate's docs.
environment("CARGO_PROFILE_RELEASE_PANIC", "unwind")
}
}
}
}
@@ -124,21 +139,13 @@ val rustInputs =
/**
* Which Cargo profile the `.so` is built with.
*
* A property rather than a debug/release task PAIR, deliberately. This runner has
* no working Gradle or Cargo cache (`reserveCache failed` on every run), so a cold
* cross-compile of four ABIs costs about four minutes — and a lane that both
* type-checks and packages would pay that twice if the two used different
* profiles. `android.yml` picks one profile and uses it for every Gradle call in
* the run.
* A property rather than a debug/release task pair: this runner has no working
* Gradle or Cargo cache, so a cold cross-compile of four ABIs costs about four
* minutes, and a lane that type-checks and packages with different profiles would
* pay it twice. `android.yml` picks one profile for every Gradle call in the run.
*
* CI currently passes `debug` even for a release APK, which is not where this
* should end up: an unoptimised store and sync engine is a real difference on a
* phone, not a theoretical one. The blocker is that the workspace's release
* profile sets `strip = true`, which removes the symbols uniffi reads its
* interface metadata from — `generateUniffiBindings` then fails with "No UniFFI
* metadata found" (run 4077). Fixing it means either an Android-specific profile
* that keeps symbols or generating the bindings from a separate unstripped
* build, and neither is worth holding signed APKs up for. Scribe #2810.
* CI passes `release` for a signed APK, so a phone runs an optimised store and sync
* engine; `CargoNdkBuild` keeps the symbols bindgen needs.
*/
val rustProfile =
(project.findProperty("INKWELL_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() }
@@ -174,8 +181,7 @@ android {
defaultConfig {
applicationId = "com.fabledsword.inkwell"
// 26 (Android 8, 2017) matches Minstrel and clears the NDK's floor with
// room to spare.
// 26 (Android 8, 2017) clears the NDK's floor with room to spare.
minSdk = 26
targetSdk = 36
// Injected by CI from the git tag + commit count for a release; "dev"
@@ -199,11 +205,10 @@ android {
}
// The signing key reaches this build only through the environment: CI decodes
// it from a secret into a file and points ANDROID_KEYSTORE_FILE at that path.
// It is never in the repo and never in this file. Generated by the operator
// and never seen by an agent session, because an Android signing key cannot be
// rotated without the original — v3 lineage needs it — so a leaked or lost one
// means every install has to be removed and replaced by hand.
// it from a secret into a file and points ANDROID_KEYSTORE_FILE at that path. It
// is never in the repo. An Android signing key cannot be rotated without the
// original (v3 lineage needs it), so a leaked or lost one means every install
// has to be removed and replaced by hand.
val keystoreFile = System.getenv("ANDROID_KEYSTORE_FILE")?.takeIf { it.isNotBlank() }
val keystorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")?.takeIf { it.isNotBlank() }
@@ -212,13 +217,11 @@ android {
create("release") {
storeFile = File(keystoreFile)
storePassword = keystorePassword
// Hardcoded, and NOT a secret: the alias is fixed for the life of
// this app and is written into the certificate every install
// already carries. Hiding it would buy nothing and stop this file
// describing its own signing setup. 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.
// Hardcoded, and NOT a secret: the alias is fixed for the life of this
// app and written into the certificate every install carries. It says
// `thoughtsync`, the app's earlier name, because it names the key
// inside the existing keystore; renaming it would stop the key being
// found.
keyAlias = "thoughtsync"
// PKCS12 cannot hold a key password distinct from the store
// password — keytool refuses to set one — so this is the same
@@ -291,5 +294,9 @@ dependencies {
implementation(libs.compose.ui.tooling.preview)
debugImplementation(libs.compose.ui.tooling)
// A link preview's image (LinkPreviewRow.kt).
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
testImplementation(libs.junit)
}
+26 -23
View File
@@ -25,9 +25,8 @@
FOREGROUND_SERVICE used only for expedited work; nothing here asks
for it, and it arrives with the library
Verified against the built APK's merged manifest, not assumed. Noted here
because all four appear in the app's permission list and nothing else in
this file would explain where they came from.
Listed because all four appear in the app's permission list and nothing
else in this file would explain where they came from.
-->
<!--
@@ -64,24 +63,22 @@
refusing costs precision, not the feature — see Reminders.scheduleNext.
RECEIVE_BOOT_COMPLETED already arrives via WorkManager (below), but is
declared here too because ReminderReceiver now depends on it directly. A
declared here too because ReminderReceiver depends on it directly. A
permission this file relies on should be visible in this file.
-->
<!--
Updating this app from the server it syncs with (M12 step 7).
Updating this app from the server it syncs with.
REQUEST_INSTALL_PACKAGES lets the app hand an APK to the system installer at
all. It is NOT what makes an install look suspicious to on-device heuristics
— Mihon declares it too — the legacy ACTION_VIEW install intent was, and this
app uses a PackageInstaller session instead. See AppUpdate.kt and Scribe note
2437. The person must additionally grant "install unknown apps" in system
settings; the update card asks before downloading anything.
all. It is NOT what makes an install look suspicious to on-device heuristics;
the legacy ACTION_VIEW install intent is, and this app uses a PackageInstaller
session instead (AppUpdate.kt). The person must also grant "install unknown
apps" in system settings; the update card asks before downloading anything.
UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+) is what removes the install
confirmation on the UPDATE path, and only there — Android will not let an app
silently put a NEW package on a device, which is correct. It also only applies
when the new build is signed with the same key as the installed one, which is
why signing had to land before any of this could work.
UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+) removes the install confirmation
on the UPDATE path only: Android will not let an app silently put a NEW package
on a device. It applies only when the new build is signed with the same key as
the installed one.
-->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
@@ -90,6 +87,13 @@
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!--
allowBackup stays true. Inkwell is local-first, so an unlinked phone's notes
exist only on the phone, and Android's backup is their one other copy. The
device token is stored sealed under a Keystore key that never leaves the phone
(KeystoreTokenSeal.kt), so a backup carries the notes and a token nothing can
open, and a restored app asks to sign in again.
-->
<application
android:name=".InkwellApplication"
android:allowBackup="true"
@@ -103,12 +107,11 @@
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.
intent, but 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 on the activity, every share while the app is running
would stack a second MainActivity: a second view model, a second board,
and a back press that lands on a stale copy of the app.
-->
<activity
android:name=".MainActivity"
@@ -127,7 +130,7 @@
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).
and uploaded on the next sync that reaches a server.
image/* rather than */*: a photo or a screenshot is what people
share into a notes app. Claiming every type would put Inkwell in the
@@ -172,7 +175,7 @@
Where the system reports what happened to an install we committed. Not
exported: the only sender is the PendingIntent this app handed to
PackageInstaller. Without it a failed install would be indistinguishable
from someone declining the dialog (Scribe #2438).
from someone declining the dialog.
-->
<!--
Hands an attachment to the app that opens its type. Not exported, as a
@@ -3,6 +3,7 @@ package com.fabledsword.inkwell
import android.content.Context
import android.content.Intent
import android.content.IntentSender
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInstaller
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
@@ -17,24 +18,20 @@ import java.io.File
*
* ## A PackageInstaller session, not an install intent
*
* The obvious route — `ACTION_VIEW` on the APK with
* `application/vnd.android.package-archive` — is the one on-device install
* heuristics are tuned against, and it is what produces the "bypassing Android
* security" warning the operator saw on Minstrel (Scribe note 2437). It also never
* tells the OS that this app is the legitimate updater of its own package, and it
* returns nothing: a failed install is indistinguishable from a person dismissing
* the dialog.
* The obvious route, `ACTION_VIEW` on the APK with
* `application/vnd.android.package-archive`, is the one on-device install
* heuristics are tuned against, and it produces a "bypassing Android security"
* warning. It also never tells the OS that this app is the legitimate updater of its
* own package, and it returns nothing: a failed install is indistinguishable from a
* person dismissing the dialog.
*
* A session says who is doing what. On Android 12+ it can also declare that no user
* action is required, which — paired with `UPDATE_PACKAGES_WITHOUT_USER_ACTION` —
* removes the confirmation dialog entirely on the UPDATE path. Not on a first
* install: the OS will not let an app quietly put a NEW package on a device, which
* is right.
* action is required, which, paired with `UPDATE_PACKAGES_WITHOUT_USER_ACTION`,
* removes the confirmation dialog on the UPDATE path. Not on a first install: the
* OS will not let an app quietly put a NEW package on a device.
*
* Two things from that same research that are NOT done here, deliberately:
* `setRequestUpdateOwnership` was chased and turned out to be a red herring, and
* `REQUEST_INSTALL_PACKAGES` is not the differentiator either — Mihon declares it
* too. The mechanism was the whole difference.
* Neither `setRequestUpdateOwnership` nor `REQUEST_INSTALL_PACKAGES` is what avoids
* the warning; the session is.
*
* ## The outcome comes back
*
@@ -42,7 +39,7 @@ import java.io.File
* [UpdateReceiver], which is why a failure can be shown rather than guessed at.
*/
object AppUpdate {
private const val TAG = "InkwellUpdate"
private const val TAG = LogTag.UPDATE
/** This build's versionCode — what the server's is compared against. */
fun installedVersionCode(context: Context): Long =
@@ -50,6 +47,17 @@ object AppUpdate {
context.packageManager.getPackageInfo(context.packageName, 0).longVersionCode
}.getOrDefault(0L)
/**
* Whether this build can update itself at all.
*
* A debuggable build is signed with a debug key (CI's unsigned path, or a local
* build), and Android refuses the release-signed APK over it with
* INSTALL_FAILED_UPDATE_INCOMPATIBLE. Offering that update would be a download
* that can only fail, so a debug build says so instead.
*/
fun selfUpdates(context: Context): Boolean =
(context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) == 0
/**
* Whether this app may install packages at all.
*
@@ -112,10 +120,9 @@ object AppUpdate {
val params =
PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Only honoured on an UPDATE of an app signed with the same key —
// exactly our case, and the reason the signing work had to land
// first. Android ignores it for anything else rather than failing,
// so there is no need to guard on which it is.
// Only honoured on an UPDATE of an app signed with the same key, which
// is this case. Android ignores it for anything else rather than
// failing, so there is no need to guard on which it is.
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
}
@@ -137,11 +144,8 @@ object AppUpdate {
}
/**
* Stream the APK into the session.
*
* Its own function only because the two nested `use` blocks read badly inline —
* and detekt agreed, which is fair: a stream inside a session inside a try is
* three things to hold at once.
* Stream the APK into the session. Its own function so a stream inside a session
* inside a try isn't three nested things to hold at once.
*/
private fun writeApk(
session: PackageInstaller.Session,
@@ -12,10 +12,9 @@ import android.content.Context
*
* 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.
* client header wants the literal the core recognizes. 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, never as a plausible default.
*/
fun Context.installedVersionName(): String? =
runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull()
@@ -32,15 +32,13 @@ class InkwellApplication : Application() {
override fun onCreate() {
super.onCreate()
// Introduce this app to any server it links to, before anything can sync.
// The core cannot name us — the same crate is compiled into the desktop app,
// and it used to announce every phone as `inkwell-desktop` carrying the
// core crate's own version. "unknown" rather than a guess when the package
// manager will not say (note 3127 §5).
// Introduce this app to any server it links to, before anything can sync. The
// core can't name its host app, since the same crate is compiled into the
// desktop app. "unknown" rather than a guess when the package manager won't say.
setClientAgent("inkwell-android", installedVersionName() ?: "unknown")
try {
val handle = Inkwell(filesDir.absolutePath)
val handle = Inkwell(filesDir.absolutePath, KeystoreTokenSeal())
core = handle
Log.i(TAG, "local store ready — ${handle.summary()}")
} catch (e: Exception) {
@@ -53,6 +51,6 @@ class InkwellApplication : Application() {
}
private companion object {
const val TAG = "Inkwell"
const val TAG = LogTag.APP
}
}
@@ -0,0 +1,96 @@
package com.fabledsword.inkwell
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Log
import com.fabledsword.inkwell.core.TokenSeal
import java.security.KeyStore
import java.util.Base64
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Keeps the device token sealed under a key in the Android Keystore. The core
* stores what this returns, never the token itself.
*
* The key never leaves this phone, so a copy of the app's files yields a token
* nothing can open. That includes Android's backup, which stays on: an unlinked
* phone's notes exist only here, and a backup is their one other copy. Restored
* onto another phone, the notes come back and the app asks to sign in again.
*
* Both methods answer null rather than throw, because an exception crossing into
* the core would be a panic there. The core reads null from [sealToken] as "store
* it as is for now" and from [openToken] as "sign in again".
*/
class KeystoreTokenSeal : TokenSeal {
override fun sealToken(token: String): String? =
runCatching { SealedBox.seal(key(), token) }
.onFailure { Log.w(TAG, "couldn't seal the device token", it) }
.getOrNull()
override fun openToken(sealed: String): String? =
runCatching { SealedBox.open(key(), sealed) }
.onFailure { Log.w(TAG, "the device token won't open on this phone", it) }
.getOrNull()
private fun key(): SecretKey {
val store = KeyStore.getInstance(KEYSTORE).apply { load(null) }
(store.getKey(ALIAS, null) as? SecretKey)?.let { return it }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
generator.init(
KeyGenParameterSpec
.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(KEY_BITS)
.build(),
)
return generator.generateKey()
}
private companion object {
const val TAG = LogTag.APP
const val KEYSTORE = "AndroidKeyStore"
const val ALIAS = "inkwell-device-token"
const val KEY_BITS = 256
}
}
/**
* AES-GCM with a fresh IV per seal, the IV stored in front of the ciphertext. Split
* from the Keystore so it can be tested on the JVM with an ordinary key.
*/
internal object SealedBox {
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val TAG_BITS = 128
private const val IV_BYTES = 12
// Binds a sealed value to what it is, so it can't be passed off as another secret.
private val AAD = "inkwell-device-token-v1".toByteArray(Charsets.UTF_8)
fun seal(
key: SecretKey,
plaintext: String,
): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
// The provider picks a random IV; the Keystore refuses a caller-chosen one.
cipher.init(Cipher.ENCRYPT_MODE, key)
cipher.updateAAD(AAD)
val sealed = cipher.iv + cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
return Base64.getEncoder().encodeToString(sealed)
}
fun open(
key: SecretKey,
sealed: String,
): String {
val bytes = Base64.getDecoder().decode(sealed)
require(bytes.size > IV_BYTES) { "sealed value too short" }
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, bytes, 0, IV_BYTES))
cipher.updateAAD(AAD)
return String(cipher.doFinal(bytes, IV_BYTES, bytes.size - IV_BYTES), Charsets.UTF_8)
}
}
@@ -0,0 +1,12 @@
package com.fabledsword.inkwell
/**
* The tags the app logs under, one per concern, so `adb logcat -s` on one of them
* finds every line about it whichever file wrote it.
*/
internal object LogTag {
const val APP = "Inkwell"
const val UPDATE = "InkwellUpdate"
const val REMINDERS = "InkwellReminders"
const val WORKER = "InkwellWorker"
}
@@ -35,6 +35,7 @@ import com.fabledsword.inkwell.ui.ForegroundTransitions
import com.fabledsword.inkwell.ui.InkwellTheme
import com.fabledsword.inkwell.ui.LocalAttachmentFiles
import com.fabledsword.inkwell.ui.NoteEditorScreen
import com.fabledsword.inkwell.ui.OnEachForeground
import com.fabledsword.inkwell.ui.ShareSheet
import com.fabledsword.inkwell.ui.ShareViewModel
import com.fabledsword.inkwell.ui.StoreUnavailableScreen
@@ -131,8 +132,8 @@ class MainActivity : ComponentActivity() {
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_SEND, Intent.ACTION_SEND_MULTIPLE ->
SharedIn(intent.takeSendText(), intent.takeStreams())
Intent.ACTION_PROCESS_TEXT -> SharedIn(intent.takeProcessText(), emptyList())
else -> null
}
@@ -204,7 +205,7 @@ private enum class Screen { BOARD, EDITOR, SYNC, TAGS }
* both cover the display completely, so keeping the board's two-column grid
* measuring and recomposing underneath one would be pure waste.
*
* Still no navigation library. Four destinations, each entered from exactly one
* No navigation library. Four destinations, each entered from exactly one
* place and left by back — a nav graph would be ceremony around an enum, and the
* state that actually matters (which note is open, whether this device is linked)
* already lives in view models.
@@ -218,15 +219,16 @@ private fun App(
) {
val context = LocalContext.current
val board: BoardViewModel =
viewModel(
factory =
BoardViewModel.factory(core, readFile = files::read) {
// Any store write can have moved the next reminder. Called on
// the IO dispatcher by the view model, which is where it has to
// be — this reads every note carrying a reminder.
Reminders.refresh(context, core)
},
)
viewModel {
BoardViewModel(
core,
// Any store write can have moved the next reminder. Called on the IO
// dispatcher by the view model, which is where it has to be — this
// reads every note carrying a reminder.
onRemindersChanged = { Reminders.refresh(context, core) },
readFile = files::read,
)
}
// Consumed, not just read: without clearing it, every later recomposition
// would reopen the same note and make the editor impossible to leave.
@@ -250,15 +252,14 @@ private fun App(
// 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.
val sync: SyncViewModel =
viewModel(factory = SyncViewModel.factory(core, onStoreChanged = board::refresh))
viewModel { SyncViewModel(core, onStoreChanged = board::refresh) }
// Screen visibility is view STATE, not view-model state: it is about what is on
// the display, and nothing in the store cares. Saveable so a rotation does not
// close it.
//
// The capture sheet used to keep its own flag here too. It is gone: the + button
// opens the editor on an unsaved draft, so writing a note and editing one are the
// same surface with the same toolbar.
// There is no separate capture sheet: the + button opens the editor on an unsaved
// draft, so writing a note and editing one are the same surface.
var showingSync by rememberSaveable { mutableStateOf(false) }
var showingTags by rememberSaveable { mutableStateOf(false) }
@@ -266,14 +267,14 @@ private fun App(
// 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))
viewModel { TagsViewModel(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))
viewModel { ShareViewModel(core, onStoreChanged = board::refresh) }
val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context))
val update: UpdateViewModel = viewModel { UpdateViewModel(core, context.applicationContext) }
val settings = remember(context) { SyncSettings(context) }
var automatic by remember { mutableStateOf(settings.automatic) }
@@ -359,6 +360,8 @@ private fun App(
onOpenSync = { showingSync = true },
onManageTags = { showingTags = true },
onSearch = board::search,
onFilter = board::filter,
onReorder = board::reorder,
onCompose = board::compose,
onToggleItem = board::toggleItem,
// The SAME seam the editor uses. `onEditorAction` is already the
@@ -428,7 +431,7 @@ private fun ReminderAlarms(core: Inkwell) {
refresh()
}
ForegroundTransitions(onForeground = { refresh() }, onBackground = {})
ForegroundTransitions(onForeground = { refresh() })
LaunchedEffect(Unit) {
// TIRAMISU is where POST_NOTIFICATIONS became a runtime permission. Below
@@ -443,33 +446,21 @@ private fun ReminderAlarms(core: Inkwell) {
}
/**
* Looking for an app update without being asked.
*
* Until this existed, `check()` had exactly one caller: a button on the sync screen.
* So a new build was found only by someone who went looking for one, and the operator
* had to remember to go looking — which is the same as not being told.
* Looking for an app update without being asked, so a new build is found without
* anyone going to look for one.
*
* On coming forward rather than on a timer: it is the moment the person is present,
* and the view model rate-limits so flicking between two apps is not a re-check.
* Unlinked devices are skipped entirely — updates come from a linked server, and
* there is nothing to ask.
* Unlinked devices are skipped, since updates come from a linked server.
*/
@Composable
private fun AutomaticUpdate(
linked: Boolean,
onCheck: () -> Unit,
) {
var wanted by remember { mutableStateOf(false) }
ForegroundTransitions(onForeground = { wanted = true }, onBackground = {})
LaunchedEffect(wanted, linked) {
if (!wanted || !linked) return@LaunchedEffect
// Consumed here, so this fires once per trip to the foreground however many
// times the effect restarts. There is no suspension point before the call, so
// the block completes before the recomposition that would cancel it.
wanted = false
onCheck()
}
// Waits for a link rather than dropping the trip: linking a device while the app is
// in front checks as soon as it is linked.
OnEachForeground(ready = linked, act = onCheck)
}
/**
@@ -506,28 +497,18 @@ private fun AutomaticSync(
if (state.linked && enabled) SyncSchedule.enable(context) else SyncSchedule.disable(context)
}
var wanted by remember { mutableStateOf(false) }
ForegroundTransitions(
onForeground = { wanted = true },
// Ready once `loading` is done, so the decision waits for the stored link to be
// READ. At first composition `linked` is still false because nothing has looked in
// the database yet, and acting on that would skip the sync on every cold start.
OnEachForeground(
ready = !state.loading,
onBackground = {
// Unsent work follows the person out of the app. Without this, a note
// written on a phone that then goes into a pocket for the night does
// not reach the desktop until the app is opened again by hand.
if (enabled && state.linked && state.pending) SyncSchedule.pushSoon(context)
},
)
// Keyed on `loading` so the decision waits for the stored link to be READ. At
// first composition `linked` is still false because nothing has looked in the
// database yet, and acting on that would skip the sync on every cold start.
LaunchedEffect(wanted, state.loading) {
if (!wanted || state.loading) return@LaunchedEffect
// Consumed here, so this fires exactly once per trip to the foreground
// however many times the effect restarts. Writing a key from inside the
// effect does restart it — but there is no suspension point between here
// and the call below, so the block runs to completion before the
// recomposition that would cancel it can be scheduled.
wanted = false
) {
val worthIt = state.pending || olderThan(state.status?.lastSyncAt, STALE_MINUTES)
if (enabled && state.linked && worthIt) onSync()
}
@@ -117,5 +117,5 @@ internal object ReminderNotification {
)
private const val CHANNEL = "reminders"
private const val TAG = "InkwellReminders"
private const val TAG = LogTag.REMINDERS
}
@@ -67,6 +67,6 @@ class ReminderReceiver : BroadcastReceiver() {
const val ACTION_DUE = "com.fabledsword.inkwell.REMINDER_DUE"
const val ACTION_DONE = "com.fabledsword.inkwell.REMINDER_DONE"
const val ACTION_SNOOZE = "com.fabledsword.inkwell.REMINDER_SNOOZE"
private const val TAG = "InkwellReminders"
private const val TAG = LogTag.REMINDERS
}
}
@@ -9,7 +9,8 @@ import android.util.Log
import androidx.core.app.NotificationManagerCompat
import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.inkwell.core.Note
import java.time.OffsetDateTime
import com.fabledsword.inkwell.ui.SNOOZE_HOUR
import com.fabledsword.inkwell.ui.epochMillis
/**
* Getting a reminder in front of someone at the time they asked for.
@@ -25,7 +26,7 @@ import java.time.OffsetDateTime
* ## AlarmManager, not WorkManager
*
* The background sync runs on WorkManager and is right to: nobody minds whether it
* happens at 3:05 or 3:19. A reminder minded very much. WorkManager's periodic
* happens at 3:05 or 3:19. A reminder does. WorkManager's periodic
* floor is fifteen minutes and it batches work into maintenance windows, so
* "remind me at 09:00" would routinely arrive at 09:14 — which is not a reminder,
* it is a rebuke.
@@ -51,8 +52,7 @@ object Reminders {
*/
private const val MISSED_WINDOW_MS = 24L * 60 * 60 * 1000
private const val SNOOZE_MINUTES = 60L
private const val TAG = "InkwellReminders"
private const val TAG = LogTag.REMINDERS
/**
* Announce what is due, then schedule the next one.
@@ -137,7 +137,7 @@ object Reminders {
core: Inkwell,
noteId: String,
) {
runCatching { core.snoozeReminder(noteId, SNOOZE_MINUTES) }
runCatching { core.snoozeReminder(noteId, SNOOZE_HOUR) }
.onFailure { Log.w(TAG, "could not snooze reminder", it) }
ReminderNotification.dismiss(context, noteId)
}
@@ -194,10 +194,7 @@ object Reminders {
// ────────────────────────────── bookkeeping ──────────────────────────────
/** Epoch millis of a note's reminder, or null if it has none we can read. */
private fun at(note: Note): Long? =
note.remindAt?.let {
runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull()
}
private fun at(note: Note): Long? = note.remindAt?.let(::epochMillis)
/**
* Identity of one OCCURRENCE, not of the note.
@@ -9,6 +9,7 @@ import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkRequest
import java.util.concurrent.TimeUnit
/**
@@ -38,8 +39,7 @@ object SyncSchedule {
fun enable(context: Context) {
val request =
PeriodicWorkRequestBuilder<SyncWorker>(PERIOD_MINUTES, TimeUnit.MINUTES)
.setConstraints(networkRequired())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS)
.online()
.build()
// UPDATE, not KEEP: this is called on every launch, and KEEP would ignore
@@ -68,8 +68,7 @@ object SyncSchedule {
fun pushSoon(context: Context) {
val request =
OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(networkRequired())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS)
.online()
.build()
// REPLACE rather than KEEP: if an earlier attempt is sitting in a long
@@ -79,11 +78,14 @@ object SyncSchedule {
.enqueueUniqueWork(PUSH, ExistingWorkPolicy.REPLACE, request)
}
private fun networkRequired(): Constraints =
Constraints
.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
/** What both requests share: a network to run on, and the same backoff. */
private fun <B : WorkRequest.Builder<B, *>> B.online(): B =
setConstraints(
Constraints
.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build(),
).setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS)
private const val PERIODIC = "inkwell-periodic-sync"
private const val PUSH = "inkwell-push-pending"
@@ -67,6 +67,6 @@ class SyncWorker(
}
private companion object {
const val TAG = "InkwellWorker"
const val TAG = LogTag.WORKER
}
}
@@ -0,0 +1,31 @@
package com.fabledsword.inkwell
import android.content.Context
/**
* When each tag's lens was last opened on this phone, for the drawer's recent tags.
* Opening a tag counts as using it, alongside the notes that carry it.
*
* Kept on the device rather than synced: it is a convenience about where this
* person's thumb goes on this screen, and losing it costs one tap.
*/
class TagViews(
context: Context,
) {
// applicationContext, as in SyncSettings: this must not hold an Activity.
private val prefs =
context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
fun all(): Map<String, Long> = prefs.all.mapNotNull { (id, at) -> (at as? Long)?.let { id to it } }.toMap()
/** Records a view now and returns every view, this one included. */
fun record(tagId: String): Map<String, Long> {
val now = System.currentTimeMillis()
prefs.edit().putLong(tagId, now).apply()
return all() + (tagId to now)
}
private companion object {
const val FILE = "inkwell-tag-views"
}
}
@@ -12,8 +12,7 @@ import android.util.Log
* Without this the app would `commit` and learn nothing — a failed install would
* look exactly like a person deciding not to go ahead, and the update card would
* sit there claiming an update is available with no explanation of why nothing
* happened. That was the specific complaint recorded against Minstrel's first
* attempt (Scribe #2438).
* happened.
*
* The result is written to [UpdateOutcome] rather than notified: the app is on
* screen when this fires — someone just tapped Update — so the place to say it is
@@ -72,6 +71,6 @@ class UpdateReceiver : BroadcastReceiver() {
companion object {
const val ACTION_INSTALLED = "com.fabledsword.inkwell.UPDATE_INSTALLED"
private const val TAG = "InkwellUpdate"
private const val TAG = LogTag.UPDATE
}
}
@@ -1,5 +1,7 @@
package com.fabledsword.inkwell.ui
import android.content.ClipData
import android.content.ClipDescription
import android.content.Context
import android.content.Intent
import android.database.Cursor
@@ -10,6 +12,7 @@ import android.media.ExifInterface
import android.net.Uri
import android.provider.OpenableColumns
import android.util.LruCache
import androidx.annotation.StringRes
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
@@ -40,15 +43,18 @@ sealed interface PickedFile {
) : 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
/**
* What [AttachmentFiles] made to hand a file to another app — an intent to start, a
* clip for the clipboard — or why there isn't one.
*/
sealed interface Handout<out T> {
data class Ready<T>(
val value: T,
) : Handout<T>
data class Failed(
val message: String,
) : Opener
) : Handout<Nothing>
}
/**
@@ -117,29 +123,56 @@ class AttachmentFiles(
* An intent that hands the file to whatever app opens its type, or the reason
* there can't be one.
*
* Blocking; call it off the main thread, then start the intent from a screen.
*/
fun opener(attachment: Attachment): Handout<Intent> =
handOut(attachment, R.string.attach_open_failed) { uri ->
Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, attachment.mime)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
/**
* A clip that puts the file itself on the clipboard, so it pastes into another
* app as the picture rather than as a path.
*
* The clip carries the attachment's own type: built from the URI alone, its type
* would be guessed from the file name's extension, and a pasted image saved
* without one would paste as an unknown file. The clipboard grants whichever app
* pastes it read access to the one URI.
*
* Blocking; call it off the main thread.
*/
fun clip(attachment: Attachment): Handout<ClipData> =
handOut(attachment, R.string.attach_copy_failed) { uri ->
val label = attachment.filename.orEmpty()
ClipData(ClipDescription(label, arrayOf(attachment.mime)), ClipData.Item(uri))
}
/**
* The file as a URI another app may read, made into whatever [make] builds from
* it.
*
* 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 {
private fun <T> handOut(
attachment: Attachment,
@StringRes failed: Int,
make: (Uri) -> T,
): Handout<T> {
val source = attachment.sha256?.let { core.blobPath(it) }?.let(::File)
if (source == null) return Opener.Failed(app.getString(R.string.attach_not_here))
if (source == null) return Handout.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)
Handout.Ready(make(FileProvider.getUriForFile(app, "${app.packageName}.files", copy)))
} catch (e: IOException) {
Opener.Failed(app.getString(R.string.attach_open_failed, e.message ?: attachment.filename.orEmpty()))
Handout.Failed(app.getString(failed, e.message ?: attachment.filename.orEmpty()))
}
}
@@ -10,7 +10,7 @@ import kotlin.math.roundToInt
/**
* 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).
* excluded on every surface: it is a document that can carry script.
* The same rule as `rendersInline` in the web's `notes/attachments.ts`.
*/
fun rendersInline(mime: String): Boolean {
@@ -1,12 +1,16 @@
package com.fabledsword.inkwell.ui
import android.content.ActivityNotFoundException
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Build
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.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -33,8 +37,10 @@ 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.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
@@ -115,6 +121,7 @@ fun EditorAttachments(
onAction: (EditorAction) -> Unit,
) {
val open = rememberOpener()
val copy = rememberCopier()
Column(modifier = Modifier.padding(top = 12.dp)) {
attachments.forEach { attachment ->
val remove = { onAction(EditorAction.RemoveAttachment(attachment.id)) }
@@ -124,6 +131,7 @@ fun EditorAttachments(
attachment = attachment,
maxPx = EDITOR_IMAGE_PX,
onClick = { open(attachment) },
onLongClick = { copy(attachment) },
) {
FileRow(attachment = attachment, onClick = { open(attachment) })
}
@@ -205,6 +213,9 @@ private sealed interface ImageLoad {
/**
* An attachment drawn as a picture, decoded off the main thread.
*
* In the editor a tap opens it full size in the phone's viewer and a long press
* copies it, the same gesture that copies an image in a browser.
*
* [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.
@@ -214,6 +225,7 @@ private fun AttachmentImage(
attachment: Attachment,
maxPx: Int,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
fallback: @Composable () -> Unit,
) {
val files = LocalAttachmentFiles.current
@@ -234,7 +246,15 @@ private fun AttachmentImage(
)
is ImageLoad.Ready -> {
val ratio = (state.bitmap.width.toFloat() / state.bitmap.height).coerceAtLeast(MIN_ASPECT)
val tap = onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier
val tap =
onClick?.let { click ->
Modifier.combinedClickable(
onClickLabel = stringResource(R.string.attach_view),
onLongClickLabel = onLongClick?.let { stringResource(R.string.attach_copy) },
onLongClick = onLongClick,
onClick = click,
)
} ?: Modifier
Image(
bitmap = state.bitmap,
contentDescription = attachment.filename,
@@ -311,8 +331,39 @@ private fun rememberOpener(): (Attachment) -> Unit {
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)
is Handout.Ready -> start(context, opener.value)
is Handout.Failed -> toast(context, opener.message)
}
}
}
}
}
/**
* Put an image on the clipboard, or say why not.
*
* Android 13 and later show their own confirmation when anything is copied, so the
* toast is only for the versions that don't — two notices for one copy reads as two
* copies.
*/
@Composable
private fun rememberCopier(): (Attachment) -> Unit {
val files = LocalAttachmentFiles.current
val context = LocalContext.current
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
return { attachment ->
if (files != null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
scope.launch {
when (val clip = withContext(Dispatchers.IO) { files.clip(attachment) }) {
is Handout.Ready -> {
context.getSystemService(ClipboardManager::class.java).setPrimaryClip(clip.value)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
toast(context, context.getString(R.string.attach_copied))
}
}
is Handout.Failed -> toast(context, clip.message)
}
}
}
@@ -321,10 +372,10 @@ private fun rememberOpener(): (Attachment) -> Unit {
private fun start(
context: Context,
opener: Opener.Ready,
intent: Intent,
) {
try {
context.startActivity(opener.intent)
context.startActivity(intent)
} catch (expected: ActivityNotFoundException) {
toast(context, context.getString(R.string.attach_no_app))
}
@@ -1,11 +1,14 @@
package com.fabledsword.inkwell.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.horizontalScroll
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
@@ -16,11 +19,15 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
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.focus.FocusRequester
@@ -28,24 +35,29 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.core.Label
/**
* The note's body, as fields and checkboxes rather than as markup.
*
* The point of the whole shape: a box you can tick while looking at the note, rather
* than `- [ ] ` to read and edit around. What the note IS never changed.
* than `- [ ] ` to read and edit around. The note's stored markup is the same.
*/
@Composable
fun BlockBody(
blocks: List<EditorBlock>,
readOnly: Boolean,
focus: Long?,
/** Every tag there is, offered while a `#tag` is typed (see [TagSuggestions]). */
tags: List<Label>,
onChange: (List<EditorBlock>) -> Unit,
onFocus: (Long?) -> Unit,
modifier: Modifier = Modifier,
@@ -76,6 +88,7 @@ fun BlockBody(
TaskBlock(
block = block,
readOnly = readOnly,
tags = tags,
requester = requester,
onChange = { replace(index, it) },
onEnter = {
@@ -89,8 +102,8 @@ fun BlockBody(
val remaining = blocks.withoutIndex(index)
onChange(remaining)
// The row above — or, for the FIRST row, whichever one takes
// its place. `index - 1` alone is -1 there, which left the
// keyboard up with nothing focused.
// its place. `index - 1` alone is -1 there, which would leave
// the keyboard up with nothing focused.
onFocus(remaining.getOrNull((index - 1).coerceAtLeast(0))?.id)
},
)
@@ -98,6 +111,7 @@ fun BlockBody(
ProseBlock(
block = block,
readOnly = readOnly,
tags = tags,
requester = requester,
onChange = { replace(index, it) },
onBlur = {
@@ -114,7 +128,7 @@ fun BlockBody(
}
/**
* A run of prose: one ordinary multi-line field, exactly as the editor always had.
* A run of prose: one ordinary multi-line field.
*
* Leaving it is when a `- [ ] ` typed by hand becomes a real checklist item — see
* [promotingTasks] for why blur is the only safe moment to do that.
@@ -128,20 +142,30 @@ fun BlockBody(
private fun ProseBlock(
block: EditorBlock,
readOnly: Boolean,
tags: List<Label>,
requester: FocusRequester,
onChange: (EditorBlock) -> Unit,
onBlur: () -> Unit,
) {
BlockField(
value = block.value,
onValueChange = { onChange(block.copy(value = it)) },
modifier =
Modifier
.focusRequester(requester)
.onFocusChanged { if (!it.isFocused) onBlur() },
enabled = !readOnly,
hint = R.string.editor_body_hint,
)
var focused by remember { mutableStateOf(false) }
Column {
BlockField(
value = block.value,
onValueChange = { onChange(block.copy(value = it)) },
modifier =
Modifier
.focusRequester(requester)
.onFocusChanged {
focused = it.isFocused
if (!it.isFocused) onBlur()
},
enabled = !readOnly,
hint = R.string.editor_body_hint,
)
if (focused && !readOnly) {
TagSuggestions(value = block.value, tags = tags, onPick = { onChange(block.copy(value = it)) })
}
}
}
/**
@@ -155,14 +179,15 @@ private fun ProseBlock(
private fun TaskBlock(
block: EditorBlock,
readOnly: Boolean,
tags: List<Label>,
requester: FocusRequester,
onChange: (EditorBlock) -> Unit,
onEnter: () -> Unit,
onDelete: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
// Material sizes every interactive component to a 48dp touch target, and on a
// checklist that IS the row height — which is why six items filled a phone screen
// even after the field's own padding came off.
// checklist that IS the row height.
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides ROW_TOUCH) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
@@ -173,7 +198,11 @@ private fun TaskBlock(
BlockField(
value = block.value,
onValueChange = { onChange(block.copy(value = it)) },
modifier = Modifier.weight(1f).focusRequester(requester),
modifier =
Modifier
.weight(1f)
.focusRequester(requester)
.onFocusChanged { focused = it.isFocused },
enabled = !readOnly,
singleLine = true,
textStyle =
@@ -195,6 +224,57 @@ private fun TaskBlock(
}
}
}
if (focused && !readOnly) {
// Indented past the box, so the row reads as belonging to this item's text.
TagSuggestions(
value = block.value,
tags = tags,
onPick = { onChange(block.copy(value = it)) },
modifier = Modifier.padding(start = ROW_TOUCH),
)
}
}
/**
* The existing tags that finish the `#tag` the caret is in, as a row of chips under
* the field; nothing when the caret is not in a tag or no tag fits.
*
* So a note is filed under `#groceries` rather than a new `#grocery` beside it. Which
* text counts as a tag being typed, which tags are offered and how one is put in are
* in TagSuggest.kt, where they are tested; this is only the row. A tap swaps the token
* for the whole name and a space, and leaves the caret after it to keep typing.
*
* A row rather than a dropdown, because a popup over the field would sit on the very
* line being typed, and on a phone the keyboard already owns the bottom of the screen.
*/
@Composable
private fun TagSuggestions(
value: TextFieldValue,
tags: List<Label>,
onPick: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier,
) {
val caret = value.selection.end
val at = if (value.selection.collapsed) tagQueryAt(value.text, caret) else null
val matches =
remember(at?.query, tags) {
at?.let { suggestTags(tags, it.query, { t -> t.name }, { t -> t.count ?: 0L }) }.orEmpty()
}
if (at == null || matches.isEmpty()) return
Row(
modifier = modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
matches.forEach { tag ->
SuggestionChip(
onClick = {
val (text, end) = insertTag(value.text, at, tag.name)
onPick(TextFieldValue(text, TextRange(end)))
},
label = { Text("#${tag.name}") },
)
}
}
}
/**
@@ -203,25 +283,23 @@ private fun TaskBlock(
* Material's floor is 48dp and this is deliberately under it. That floor is sized for
* a control somebody has to find; a checklist box sits in a predictable column with an
* identical box directly above and below, and the cost of a near miss is ticking the
* neighbouring item — visible, and undone by tapping again. Trading twelve of those
* dp for a list that fits on a screen is what was asked for, twice.
* neighbouring item — visible, and undone by tapping again. Twelve of those dp buy a
* list that fits on a screen.
*/
private val ROW_TOUCH = 36.dp
/**
* The field a block is typed into.
*
* `BasicTextField`, not the Material one [PlainTextField] wraps, and the reason is
* density. Material's TextField puts 16dp above and below its text — padding that
* makes a FORM field comfortable to hit, and that on a checklist IS the row height. It
* made six items twice as tall as the six items, which is what the operator saw.
* `BasicTextField`, not the Material one [PlainTextField] wraps, for density.
* Material's TextField puts 16dp above and below its text: padding that makes a FORM
* field comfortable to hit, and that on a checklist IS the row height.
*
* Nothing is lost by dropping down a layer. `PlainTextField` exists to strip a
* container and an indicator; `BasicTextField` never had either, so there is no box
* here to drift back into existence. What it does not supply and this must:
* `BasicTextField` has no container or indicator to strip. What it does not supply
* and this must:
*
* - the text COLOUR. It defaults to `Color.Unspecified`, which draws BLACK — the same
* default that made the editor's toolbar invisible in dark mode. Set, not inherited.
* - the text COLOUR. It defaults to `Color.Unspecified`, which draws BLACK and is
* invisible in dark mode. Set, not inherited.
* - the cursor brush, which would otherwise be black for the same reason.
* - the placeholder, which is a plain Text behind the field rather than a slot.
*
@@ -241,6 +319,15 @@ private fun BlockField(
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
val style = textStyle.copy(color = MaterialTheme.colorScheme.onSurface)
// Kept for the arrow keys, which ask which line the caret is on. Read only when
// one is pressed, never while composing.
var layout by remember { mutableStateOf<TextLayoutResult?>(null) }
val line = { offset: Int -> layout?.getLineForOffset(offset) ?: 0 }
val arrows =
Modifier.arrowKeysMoveCaret(
atTop = { line(value.selection.end) == 0 },
atBottom = { line(value.selection.end) == (layout?.lineCount ?: 1) - 1 },
)
Box(modifier = modifier) {
if (hint != null && value.text.isEmpty()) {
Text(
@@ -252,12 +339,13 @@ private fun BlockField(
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().then(arrows),
enabled = enabled,
singleLine = singleLine,
textStyle = style,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
onTextLayout = { layout = it },
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
)
}
@@ -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: 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. 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,168 @@
package com.fabledsword.inkwell.ui
import androidx.compose.foundation.layout.Arrangement
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.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.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).
* 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. A search narrows whichever view is open,
* and on the main board it combines with these.
*/
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 and the search box's [text], or null when there is
* nothing to narrow by. The core ANDs them, the same as the server's query.
*/
fun facets(text: String = ""): NoteFacets? {
val q = text.trim().ifEmpty { null }
return if (count == 0 && q == null) {
null
} else {
NoteFacets(
q = q,
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,
) {
Sheet(
title = R.string.filters,
onDismiss = onDismiss,
modifier = Modifier.verticalScroll(rememberScrollState()),
) {
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),
)
}
@@ -53,16 +53,26 @@ import androidx.compose.material3.pulltorefresh.pullToRefresh
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.rememberDrawerState
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.core.Label
import com.fabledsword.inkwell.core.Note
@@ -77,6 +87,8 @@ fun BoardScreen(
onOpenSync: () -> Unit,
onManageTags: () -> Unit,
onSearch: (String) -> Unit,
onFilter: (BoardFilters) -> Unit,
onReorder: (List<String>) -> Unit,
onCompose: () -> Unit,
onToggleItem: (Note, Int, Boolean) -> Unit,
onNoteAction: (Note, EditorAction) -> Unit,
@@ -85,12 +97,14 @@ fun BoardScreen(
) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val views = rememberTagViews(state.destination)
val snackbars = remember { SnackbarHostState() }
// Held HERE rather than on the card. A card lives in a lazy grid and is disposed
// the moment it scrolls out of view, which would take its dialog down with it —
// and the board can scroll under an open dialog.
var confirmingDelete by remember { mutableStateOf<Note?>(null) }
var filtering by remember { mutableStateOf(false) }
// Resolved in composition, not inside the coroutine: `stringResource` is a
// composable read and cannot be called from a suspend block.
@@ -131,6 +145,7 @@ fun BoardScreen(
NavigationDrawer(
current = state.destination,
labels = state.labels,
recency = tagRecency(state.labels, state.notes, views),
syncSummary = sync.summary,
onOpen = {
onOpen(it)
@@ -183,6 +198,14 @@ fun BoardScreen(
onMenu = { scope.launch { drawerState.open() } },
)
if (state.filterable) {
FilterRow(
filters = state.filters,
onOpen = { filtering = true },
onClear = { onFilter(BoardFilters()) },
)
}
state.error?.let { message ->
ErrorBanner(message = message, onDismiss = onDismissError)
}
@@ -234,6 +257,9 @@ fun BoardScreen(
onToggleItem = onToggleItem,
onNoteAction = onCardAction,
onConfirmDelete = { confirmingDelete = it },
reorderable = state.reorderable,
marksArchived = state.marksArchived,
onReorder = onReorder,
)
}
// `PullToRefreshBox` would be less code, but it takes no
@@ -248,6 +274,15 @@ fun BoardScreen(
}
}
if (filtering) {
FilterSheet(
filters = state.filters,
labels = state.labels,
onChange = onFilter,
onDismiss = { filtering = false },
)
}
confirmingDelete?.let { note ->
ConfirmDeleteDialog(
onConfirm = {
@@ -322,10 +357,9 @@ private fun SearchBar(
) {
Surface(
// No `statusBarsPadding()` here. The Scaffold this sits in already applies
// the system-bar insets to its content padding, so adding them again put
// the whole status bar's height of empty space above the search field —
// roughly a centimetre of nothing at the top of the first screen anyone
// sees. Insets get consumed once, by whichever component owns the edge.
// the system-bar insets to its content padding, so adding them again would
// put a status bar's height of empty space above the search field. Insets
// get consumed once, by whichever component owns the edge.
modifier =
Modifier
.fillMaxWidth()
@@ -370,11 +404,16 @@ private fun SearchBar(
private fun NavigationDrawer(
current: Destination,
labels: List<Label>,
recency: Map<String, Long>,
syncSummary: String?,
onOpen: (Destination) -> Unit,
onOpenSync: () -> Unit,
onManageTags: () -> Unit,
) {
// Every tag, once asked for; until then the few in use.
var allTags by rememberSaveable { mutableStateOf(false) }
val shown = if (allTags) labels else recentTags(labels, { recency[it.id] ?: 0L }, { it.name })
ModalDrawerSheet {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(
@@ -387,6 +426,27 @@ private fun NavigationDrawer(
DrawerRow(destination, current, onOpen)
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
listOf(Destination.Archive, Destination.Trash).forEach { destination ->
DrawerRow(destination, current, onOpen)
}
// Sync sits below the divider with the destinations rather than behind
// a settings gear: it is not a preference, it is where you go to find
// out whether this phone and your desktop are actually in step. The
// subtitle answers that without opening it.
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
NavigationDrawerItem(
label = { Text(stringResource(R.string.sync_title)) },
badge = syncSummary?.let { { Text(it, style = MaterialTheme.typography.labelSmall) } },
selected = false,
onClick = onOpenSync,
modifier = Modifier.padding(horizontal = 12.dp),
)
// Tags last, as on the web: it is the one list here that grows
// without limit, so everything with a fixed place sits above it.
//
// 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
@@ -414,31 +474,42 @@ private fun NavigationDrawer(
)
}
}
labels.forEach { label ->
shown.forEach { label ->
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
listOf(Destination.Archive, Destination.Trash).forEach { destination ->
DrawerRow(destination, current, onOpen)
if (labels.size > RECENT_TAG_COUNT) {
NavigationDrawerItem(
label = {
Text(
text =
if (allTags) {
stringResource(R.string.tags_show_fewer)
} else {
stringResource(R.string.tags_show_all, labels.size)
},
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
selected = false,
onClick = { allTags = !allTags },
modifier = Modifier.padding(horizontal = 12.dp),
)
}
// Sync sits below the divider with the destinations rather than behind
// a settings gear: it is not a preference, it is where you go to find
// out whether this phone and your desktop are actually in step. The
// subtitle answers that without opening it.
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
NavigationDrawerItem(
label = { Text(stringResource(R.string.sync_title)) },
badge = syncSummary?.let { { Text(it, style = MaterialTheme.typography.labelSmall) } },
selected = false,
onClick = onOpenSync,
modifier = Modifier.padding(horizontal = 12.dp),
)
}
}
}
@Composable
private fun Destination.title(): String =
when (this) {
Destination.Notes -> stringResource(R.string.nav_notes)
Destination.Reminders -> stringResource(R.string.nav_reminders)
Destination.Archive -> stringResource(R.string.nav_archive)
Destination.Trash -> stringResource(R.string.nav_trash)
is Destination.WithLabel -> name
}
@Composable
private fun DrawerRow(
destination: Destination,
@@ -446,7 +517,7 @@ private fun DrawerRow(
onOpen: (Destination) -> Unit,
) {
NavigationDrawerItem(
label = { Text(destination.title) },
label = { Text(destination.title()) },
selected = destination == current,
onClick = { onOpen(destination) },
modifier = Modifier.padding(horizontal = 12.dp),
@@ -454,12 +525,12 @@ 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
* — a one-line thought beside a twelve-item checklist — and forcing them to a
* common height either clips the long ones or strands whitespace under the short
* ones. This is the Compose equivalent of the CSS multi-column `NoteGrid.vue` uses.
* ones. `NoteGrid.vue` places cards the same way, each into the shortest column.
*/
@Composable
private fun NoteBoard(
@@ -468,10 +539,68 @@ private fun NoteBoard(
onToggleItem: (Note, Int, Boolean) -> Unit,
onNoteAction: (Note, EditorAction) -> Unit,
onConfirmDelete: (Note) -> Unit,
/** Whether cards can be dragged into a new order: the plain main board only. */
reorderable: Boolean,
/** Whether an archived card carries the Archived chip (BoardState.marksArchived). */
marksArchived: 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(
columns = StaggeredGridCells.Fixed(BOARD_COLUMNS),
modifier = Modifier.fillMaxSize(),
columns = StaggeredGridCells.Fixed(columns),
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.
contentPadding = PaddingValues(start = GUTTER, end = GUTTER, top = 4.dp, bottom = 88.dp),
verticalItemSpacing = 8.dp,
@@ -480,14 +609,39 @@ private fun NoteBoard(
// Keyed by id so Compose reuses cards across a refresh rather than
// rebuilding them — and so a newly captured note slides in instead of
// making every card below it flicker.
items(items = notes, key = { it.id }) { note ->
NoteCard(
note = note,
onOpen = { onOpenNote(note) },
onToggleItem = { index, checked -> onToggleItem(note, index, checked) },
onAction = { onNoteAction(note, it) },
onConfirmDelete = { onConfirmDelete(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(
note = note,
onOpen = { onOpenNote(note) },
onToggleItem = { index, checked -> onToggleItem(note, index, checked) },
onAction = { onNoteAction(note, it) },
onConfirmDelete = { onConfirmDelete(note) },
dragging = carried,
marksArchived = marksArchived,
)
}
DisposableEffect(note.id) { onDispose { bounds.forget(note.id) } }
}
}
}
@@ -506,6 +660,8 @@ private fun EmptyBoard(state: BoardState) {
state.searching ->
stringResource(R.string.empty_search_title) to
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 ->
stringResource(R.string.empty_trash_title) to stringResource(R.string.empty_trash_body)
state.destination == Destination.Archive ->
@@ -528,13 +684,7 @@ private fun EmptyBoard(state: BoardState) {
) {
item {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TitleAndBody(title, body)
}
}
}
@@ -564,20 +714,27 @@ fun StoreUnavailableScreen(reason: String?) {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(R.string.store_unavailable_title),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.height(8.dp))
Text(
text = reason ?: stringResource(R.string.store_unavailable_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
TitleAndBody(
title = stringResource(R.string.store_unavailable_title),
body = reason ?: stringResource(R.string.store_unavailable_body),
)
}
}
private const val BOARD_COLUMNS = 2
/** A whole-screen message: what is the case, then what it means, in the quieter ink. */
@Composable
private fun TitleAndBody(
title: String,
body: String,
) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// 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.
@@ -5,13 +5,13 @@ 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 com.fabledsword.inkwell.core.Note
import com.fabledsword.inkwell.core.NoteDraft
import com.fabledsword.inkwell.core.NoteEdit
import com.fabledsword.inkwell.core.NoteFacets
import com.fabledsword.inkwell.core.NoteQuery
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -27,27 +27,17 @@ import kotlinx.coroutines.withContext
* empty board.
*/
sealed interface Destination {
val title: String
data object Notes : Destination
data object Notes : Destination {
override val title = "Notes"
}
data object Reminders : Destination
data object Reminders : Destination {
override val title = "Reminders"
}
data object Archive : Destination
data object Archive : Destination {
override val title = "Archive"
}
data object Trash : Destination {
override val title = "Trash"
}
data object Trash : Destination
data class WithLabel(
val id: String,
override val title: String,
val name: String,
) : Destination
}
@@ -57,6 +47,8 @@ data class BoardState(
val notes: List<Note> = emptyList(),
val labels: List<Label> = emptyList(),
val query: String = "",
/** The main board's filters. Only [Destination.Notes] applies them. */
val filters: BoardFilters = BoardFilters(),
val loading: Boolean = true,
val saving: Boolean = false,
val error: String? = null,
@@ -80,8 +72,30 @@ data class BoardState(
*/
val editingSession: Long = 0,
) {
/** Search overrides the destination while there is a query to run. */
/**
* A search narrows the board in view rather than replacing it, as on the web,
* where the box writes `?q=` beside the Filters panel's facets and one query
* answers both. "These words, in notes tagged grocery" is the question tags exist
* to help ask.
*/
val searching: Boolean get() = query.isNotBlank()
/** The main board only, as on the web, whose Filters panel is the main board's. */
val filterable: Boolean get() = destination == Destination.Notes
/**
* 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 && !searching
/**
* Whether an archived card says so: anywhere the Archive is not the whole view. A
* search or a tag looks through the Archive as well as the board, so finished
* work can still be looked up, and the chip is what keeps it from reading as
* current.
*/
val marksArchived: Boolean get() = destination != Destination.Archive
}
/**
@@ -137,7 +151,13 @@ class BoardViewModel(
// running should show the archive, not search results filtered by a box
// the user has visually moved on from.
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()
}
@@ -146,27 +166,42 @@ class BoardViewModel(
state = state.copy(loading = true)
state =
try {
val notes = withContext(Dispatchers.IO) { load(state.destination) }
val destination = state.destination
val filters = state.filters
val text = state.query
val notes = withContext(Dispatchers.IO) { load(destination, filters, text) }
state.copy(notes = notes, loading = false, error = null)
} catch (e: Exception) {
// Broad by intent: the board must render something for any
// failure, and the core reports problems as one error type
// carrying a message meant to be shown.
state.copy(loading = false, error = e.message ?: FALLBACK_ERROR)
state.copy(loading = false, error = e.shownAs())
}
}
}
private fun load(destination: Destination): List<Note> =
when (destination) {
Destination.Notes -> core.listNotes(query(VIEW_NOTES))
Destination.Archive -> core.listNotes(query(VIEW_ARCHIVE))
Destination.Trash -> core.listNotes(query(VIEW_TRASH))
/** The notes [destination] shows, narrowed by the board's filters and by [text]. */
private fun load(
destination: Destination,
filters: BoardFilters,
text: String,
): List<Note> {
// Only the main board has filters; every board view takes the search text.
val searchOnly = BoardFilters().facets(text)
// A search or a tag looks through the Archive too, as on the web; Archive and
// Trash keep a search inside themselves.
val notesView = if (text.isNotBlank() || filters.labelIds.isNotEmpty()) VIEW_ALL else VIEW_NOTES
return when (destination) {
Destination.Notes -> core.listNotes(query(notesView, facets = filters.facets(text)))
Destination.Archive -> core.listNotes(query(VIEW_ARCHIVE, facets = searchOnly))
Destination.Trash -> core.listNotes(query(VIEW_TRASH, facets = searchOnly))
// Not a board view: the core models reminders as its own query, since
// "has a reminder" cuts across archived and active alike.
// "has a reminder" cuts across archived and active alike. `search` moves
// a search off this view, so it never has text to apply.
Destination.Reminders -> core.reminderNotes()
is Destination.WithLabel -> core.listNotes(query(VIEW_NOTES, labelId = destination.id))
is Destination.WithLabel -> core.listNotes(query(VIEW_ALL, labelId = destination.id, facets = searchOnly))
}
}
/**
* Reload the drawer's tags, and leave a lens whose tag no longer exists.
@@ -186,6 +221,10 @@ class BoardViewModel(
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
.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)
@@ -201,7 +240,15 @@ class BoardViewModel(
}
fun search(text: String) {
state = state.copy(query = text)
// Reminders is a list, not a board view, so a search there runs over the main
// board — which is where the web sends a search typed off the board.
val destination =
if (text.isNotBlank() && state.destination == Destination.Reminders) {
Destination.Notes
} else {
state.destination
}
state = state.copy(query = text, destination = destination)
searchJob?.cancel()
if (text.isBlank()) {
@@ -217,10 +264,11 @@ class BoardViewModel(
state = state.copy(loading = true)
state =
try {
val hits = withContext(Dispatchers.IO) { core.searchNotes(text) }
val filters = state.filters
val hits = withContext(Dispatchers.IO) { load(destination, filters, text) }
state.copy(notes = hits, loading = false, error = null)
} catch (e: Exception) {
state.copy(loading = false, error = e.message ?: FALLBACK_ERROR)
state.copy(loading = false, error = e.shownAs())
}
}
}
@@ -237,21 +285,28 @@ class BoardViewModel(
fun openNoteById(id: String) {
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { core.getNote(id) } }
.onSuccess { state = state.copy(editing = it, editingSession = state.editingSession + 1) }
.onSuccess { beginSitting(it) }
}
}
fun openNote(note: Note) {
beginSitting(note)
}
/**
* A new sitting in the editor, on [note]: bumps [BoardState.editingSession] so
* the editor's field is re-keyed onto what it now shows. Saving within a
* sitting never comes through here.
*/
private fun beginSitting(note: Note? = state.editing) {
state = state.copy(editing = note, editingSession = state.editingSession + 1)
}
/**
* Open the editor on a note that does not exist yet.
*
* The + button used to raise a separate capture sheet, which meant a note being
* WRITTEN could not be given a colour, a reminder or a checklist — those live on
* the editor's toolbar, and the sheet had none. Writing and editing are now the
* same surface.
* The + button opens this rather than a separate capture sheet, so a note being
* WRITTEN can be given a colour, a reminder or a checklist from the same toolbar.
*
* The draft is a real [Note] carrying [DRAFT_ID] rather than a null, so the
* editor renders it without knowing that "not saved yet" is a state it can be
@@ -259,7 +314,7 @@ class BoardViewModel(
*/
fun compose() {
draftDismissed = false
state = state.copy(editing = blankDraft(), editingSession = state.editingSession + 1)
beginSitting(blankDraft())
}
/**
@@ -287,7 +342,7 @@ class BoardViewModel(
// 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)
beginSitting()
// 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 ->
@@ -375,7 +430,7 @@ class BoardViewModel(
error = null,
)
} catch (e: Exception) {
state.copy(saving = false, error = e.message ?: FALLBACK_ERROR)
state.copy(saving = false, error = e.shownAs())
}
if (!draftDismissed) state.editing?.let(then)
}
@@ -413,7 +468,7 @@ class BoardViewModel(
EditorAction.DismissError -> dismissError()
// Sent on an idle debounce while typing, and again on close. Writing
// this often is affordable because a body write no longer snapshots a
// this often is affordable because a body write doesn't snapshot a
// revision — the core keeps one per editing session, not one per save.
is EditorAction.SaveText ->
mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) }
@@ -446,8 +501,7 @@ class BoardViewModel(
action.name.trim().takeIf { it.isNotEmpty() }?.let { name ->
mutate {
val label = it.createLabel(name)
val manual = note.labels.filterNot { l -> l.viaTag }.map { l -> l.id }
it.setNoteLabels(id, (manual + label.id).distinct())
it.setNoteLabels(id, (note.manualLabelIds + label.id).distinct())
}
// The drawer lists labels with their note counts, and both
// just changed.
@@ -519,12 +573,9 @@ class BoardViewModel(
* lie than showing it one frame stale.
*
* While a search is running the QUERY is re-run rather than the board's
* destination — running `load` here would replace the hits with the whole
* board, which is why this branch exists at all. It used to keep the existing
* list instead, and that was right for a note whose place in the pile changed
* and wrong for one that left it: trashing a hit left the card sitting there,
* with a snackbar saying it was gone, until the query happened to re-run
* (#3111). Re-asking is still the answer to the query, just a current one.
* destination: running `load` here would replace the hits with the whole board,
* and keeping the old list would leave a trashed hit on screen beside a snackbar
* saying it was gone.
*/
private fun mutate(
closeEditor: Boolean = false,
@@ -539,9 +590,7 @@ class BoardViewModel(
try {
val updated = withContext(Dispatchers.IO) { block(core) }
val notes =
withContext(Dispatchers.IO) {
if (state.searching) core.searchNotes(state.query) else load(state.destination)
}
withContext(Dispatchers.IO) { load(state.destination, state.filters, state.query) }
// On IO, not here: re-deriving the alarm reads every note
// that carries a reminder, and this line runs on the main
// thread — the coroutine is back from its withContext by now.
@@ -550,9 +599,8 @@ class BoardViewModel(
notes = notes,
// Only REFRESHES an open editor; it must never open one.
// `editing != null` IS "the editor is on screen", so writing
// the reloaded note in unconditionally meant any mutation
// started from the BOARD threw the editor open on top of it —
// which is exactly what ticking a checkbox on a card did.
// the reloaded note in unconditionally would throw the editor
// open over the board whenever a card's checkbox is ticked.
editing = if (closeEditor) null else state.editing?.let { updated ?: it },
saving = false,
error = notice(),
@@ -561,7 +609,7 @@ class BoardViewModel(
// Broad by intent, as elsewhere: the core reports every failure
// as one error type carrying a message meant to be shown, and a
// half-applied edit must still leave a usable screen.
state.copy(saving = false, error = e.message ?: FALLBACK_ERROR)
state.copy(saving = false, error = e.shownAs())
}
}
}
@@ -573,7 +621,7 @@ class BoardViewModel(
* rather than the pure text helpers the editor uses: nothing here is holding a
* half-typed body, so the reloaded note is simply the truth.
*
* `index` is the item's ordinal, which is what its id is now (M304).
* `index` is the item's ordinal, which is its id.
*/
fun toggleItem(
note: Note,
@@ -581,12 +629,24 @@ class BoardViewModel(
checked: Boolean,
) = 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() {
state = state.copy(error = null)
}
companion object {
private const val FALLBACK_ERROR = "Something went wrong."
private const val SEARCH_DEBOUNCE_MS = 180L
// The core's board vocabulary. "archived", not "archive" — it matches on
@@ -595,16 +655,8 @@ class BoardViewModel(
private const val VIEW_ARCHIVE = "archived"
private const val VIEW_TRASH = "trash"
fun factory(
core: Inkwell,
readFile: (Uri) -> PickedFile,
onRemindersChanged: () -> Unit,
): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
BoardViewModel(core, onRemindersChanged, readFile) as T
}
/** The board and the Archive together — everything not in Trash. */
private const val VIEW_ALL = "all"
}
}
@@ -617,7 +669,8 @@ class BoardViewModel(
private fun query(
view: String,
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 =
// The core names the note from the body's first line, so a captured thought is
@@ -652,7 +705,7 @@ private fun blankDraft(): Note =
previews = emptyList(),
createdAt = null,
updatedAt = null,
permission = "owner",
permission = NoteAccess.OWNER.wire,
shared = false,
sharedBy = null,
)
@@ -0,0 +1,51 @@
package com.fabledsword.inkwell.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
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.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.fabledsword.inkwell.R
/** The corner of every chip on a card: tags, reminders, who shared it. */
internal val CHIP_RADIUS = 6.dp
/** A one-line chip on a card, in the chip colours of the palette entry [tintKey]. */
@Composable
internal fun TintChip(
text: String,
tintKey: String,
) {
val dark = isSystemInDarkTheme()
val tint = noteTint(tintKey)
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),
)
}
/** Marks a tag the note's text carries, beside its name, wherever tags are listed. */
@Composable
internal fun FromTextMark() {
Text(
text = stringResource(R.string.label_from_tag),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
}
@@ -6,11 +6,7 @@ package com.fabledsword.inkwell.ui
// identical grey chips. Hashing the tag's NAME is deterministic, identical on every
// surface, costs no column and no migration, and a tag keeps its colour for life.
//
// THIS WAS THE CARD'S COLOUR TOO, ONCE. It is not any more (M315): a note's fill is
// one neutral and only its tags carry hue. The hash survived that removal because the
// job it still does — give a name a stable colour — was never the job that failed.
// What failed was asking a colour that means "which tag" to also mean nothing at all
// on an untagged note, at which point the board had two vocabularies and neither read.
// A note's fill is one neutral; only its tags carry hue.
//
// THIS IS HALF A MIRRORED PAIR. `frontend/src/notes/colors.ts` computes the same hash
// over the same key order, and the two must agree exactly or a tag is one colour on
@@ -20,9 +16,8 @@ package com.fabledsword.inkwell.ui
//
// NO COMPOSE IN THIS FILE, deliberately. It is the half of the pair that CAN be
// pinned by a host-JVM test, and staying free of `androidx.compose` is what keeps
// `DerivedTintTest` runnable in the Unit tests step rather than on an emulator. The
// web side has no test runner at all, so this test is the only mechanical guard the
// mirror gets — see the fixture comment in colors.ts.
// `DerivedTintTest` runnable in the Unit tests step rather than on an emulator. See
// the fixture comment in colors.ts.
/**
* The colours a derived hue can land on: `NOTE_TINTS`' keys minus `default`, which is
@@ -79,16 +74,14 @@ fun derivedTint(id: String): String {
* `default`: the server mints one as `Label(owner_id=…, name=name)` with no colour,
* so without deriving, a board of tags would be a board of identical grey chips.
*
* DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing #2965's plan. That plan wanted
* a hashed colour written at each of the four places a label can be born — and named
* the risk itself: `find_or_create_label` is "easy to miss, and it is the common one",
* since most tags are born from typing `#grocery`, not from a management screen.
* Deriving has no mint points to miss and no backfill for the tags already out there.
* The cost is that renaming a tag recolours it, which is fair: the name IS the tag.
* Derived rather than stored when a label is created: most tags are born from typing
* `#grocery` through `find_or_create_label`, not from a management screen, and
* deriving has no creation point to miss and nothing to backfill. The cost is that
* renaming a tag recolours it, which is fair: the name IS the tag.
*
* Lowercased because tags dedupe case-insensitively — `#Todo` renamed to `#todo` is
* the same tag and should not change colour. Kotlin's `lowercase()` and the web's
* `toLowerCase()` are both locale-independent, so the mirror holds.
* Folded because `#Todo` renamed to `#todo` is the same tag and should not change
* colour. [foldTag] and the web's are both locale-independent lowercasing, so the
* mirror holds.
*/
fun resolvedLabelColor(
name: String,
@@ -98,5 +91,12 @@ fun resolvedLabelColor(
when {
color.isNotEmpty() && color != "default" && color in known -> color
name.isEmpty() -> "default"
else -> derivedTint(name.lowercase())
else -> derivedTint(foldTag(name))
}
/**
* How two tag names compare: equal once every letter is lowercased, accented ones
* included, so `#Café` and `#CAFÉ` are one tag. The core's `derive::fold`, the server's
* `labeling.named` and the web's `foldTag` fold the same way.
*/
fun foldTag(name: String): String = name.lowercase()
@@ -5,12 +5,10 @@ import android.net.Uri
/**
* Everything the editor can ask for, as one type.
*
* The alternative was a bundle of twenty callbacks, and it was a bad one: twenty
* same-shaped `(String, String) -> Unit` parameters is a place for two of them to
* get swapped, with nothing to catch it. One `(EditorAction) -> Unit` costs a
* `when` at the far end and gets EXHAUSTIVENESS in exchange — adding a variant
* here breaks the dispatcher until it is handled, which is precisely the guarantee
* the callback bundle could not offer.
* One `(EditorAction) -> Unit` rather than twenty same-shaped `(String, String) ->
* Unit` callbacks, where two could get swapped with nothing to catch it. It costs a
* `when` at the far end and buys EXHAUSTIVENESS: adding a variant here breaks the
* dispatcher until it is handled.
*
* No variant carries a note id. The editor is open on exactly one note and the
* dispatcher already has it, so threading it through every action would only
@@ -41,12 +39,10 @@ sealed interface EditorAction {
data object DeleteForever : EditorAction
// No checklist actions at all any more (M304). An item is a `- [ ] ` line of the
// body, so adding, renaming, ticking or deleting one is editing text — which the
// editor already does, through SaveText, with the same autosave and the same
// revision window as any other edit. Routing them through the store would have
// meant the store handing back a note whose body disagreed with the field the
// person was typing in.
// No checklist actions. An item is a `- [ ] ` line of the body, so adding,
// renaming, ticking or deleting one is editing text, through SaveText, with the
// same autosave and revision window as any other edit. Routing them through the
// store would hand back a note whose body disagrees with the field being typed in.
/**
* The note's MANUAL labels, replacing whatever was there.
@@ -80,10 +76,9 @@ sealed interface EditorAction {
/**
* Mark the reminder dealt with.
*
* Distinct from [ClearReminder] even though the core does the same thing to
* the column today: this is where recurrence advancement lands when it is
* built, so a recurring reminder finished through the generic clear would
* silently stop recurring.
* Distinct from [ClearReminder]: a recurring reminder moves on to its next
* occurrence here, and only a one-off is cleared, so a recurring reminder
* finished through the generic clear would stop recurring.
*/
data object CompleteReminder : EditorAction
@@ -114,3 +109,7 @@ sealed interface EditorAction {
val previewId: String,
) : EditorAction
}
/** The two snoozes offered: the editor's buttons, and an hour from the notification. */
internal const val SNOOZE_HOUR = 60L
internal const val SNOOZE_DAY = 1440L
@@ -9,7 +9,7 @@ import com.fabledsword.inkwell.core.checklistRender
/**
* One piece of a note body, as the editor DRAWS it.
*
* The note is still one markdown string underneath (M304) — this is a rendering and
* The note is still one markdown string underneath — this is a rendering and
* input shape, and nothing below the editor can tell it exists. [joinBlocks] puts the
* string back together on every edit.
*
@@ -36,8 +36,8 @@ data class EditorBlock(
/**
* Split a body into blocks, numbering them from [firstId].
*
* Which lines are items comes from the core, not from a pattern here — the grammar is
* written three times already and Kotlin is not going to be the fourth.
* Which lines are items comes from the core, not from a pattern here: the grammar has
* three implementations already, and this file isn't a fourth.
*/
fun splitBlocks(
body: String,
@@ -95,13 +95,11 @@ val blocksSaver: Saver<List<EditorBlock>, String> =
/**
* What the return key does on a checklist item.
*
* `internal` rather than private because BlockBody.kt calls it. These three helpers
* are the block MODEL and the composables are the block UI — one file was doing both,
* which detekt noticed by counting functions before anybody noticed by reading.
* `internal` rather than private because BlockBody.kt calls it: these helpers are the
* block MODEL, and the composables in BlockBody.kt are the block UI.
*
* On one with words in it, a new empty item below. On an EMPTY one, the item becomes
* prose — which is how a list ENDS, and the same rule the plain text field used
* before this: without it a list is impossible to get out of.
* prose, which is how a list ENDS: without it a list is impossible to get out of.
*
* Deliberately appends rather than splitting at the caret. Splitting an item in two is
* a rarity, and the caret is at the end for every ordinary use of this key.
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
@@ -48,17 +47,11 @@ import com.fabledsword.inkwell.core.Note
/**
* The editor's action bar, along the top of the surface.
*
* It sits exactly where the capture sheet's drag handle used to. The handle cost
* this strip of screen and did nothing that a back gesture does not already do, so
* the strip carries the actions instead.
*
* Top rather than bottom, now that this one surface is used for WRITING as well as
* editing: the keyboard owns the bottom of the display for most of a note's life,
* so a bar down there spends its time riding on the IME. That is the right place
* for a send button and the wrong one for a colour picker, which is reached for
* between thoughts rather than at the end of them. The cost is honest — the top of
* a phone is further from a thumb than the bottom — and it buys a bar that does not
* move while you type.
* Top rather than bottom because this surface is used for WRITING: the keyboard owns
* the bottom of the display for most of a note's life, so a bar down there would ride
* on the IME. That suits a send button, not a colour picker reached for between
* thoughts. The top is further from a thumb, and buys a bar that does not move while
* you type.
*
* The three affordances with a permanent slot are the ones reached for while still
* writing — colour, reminder, note-or-list. Everything structural (pin, labels,
@@ -85,23 +78,17 @@ fun EditorTopBar(
onConfirmDelete: () -> Unit,
onAction: (EditorAction) -> Unit,
) {
// Someone else's note (#5175): its text, at `edit`, and this account's own pin
// and archive (#5176) are all that may change here, so the bar keeps the
// Someone else's note: its text, at `edit`, and this account's own pin
// and archive are all that may change here, so the bar keeps the
// checklist button and an overflow of those two, and nothing that is the owner's.
val owner = access == NoteAccess.OWNER
val dark = isSystemInDarkTheme()
TopAppBar(
title = {},
navigationIcon = {
// The only way out, and the only thing that needed a "save" button
// before writes became continuous. Leaving IS saving now, which is what
// the line in the bottom corner is there to say out loud.
IconButton(onClick = onClose) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.editor_back),
)
}
// The only way out. Writes are continuous, so leaving IS saving, which is
// what the line in the bottom corner says out loud.
BackButton(onClose, R.string.editor_back)
},
actions = {
if (!readOnly && owner) {
@@ -114,7 +101,7 @@ fun EditorTopBar(
}
if (!readOnly) {
// 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, so there is no section to be
// already-showing and no reason a second list cannot start further
// down the same note.
IconButton(onClick = onStartChecklist) {
@@ -145,14 +132,11 @@ fun EditorTopBar(
)
}
},
// EXPLICIT, and not optional — the same lesson the old bottom bar learned.
// Material derives a bar's content colour from its container via
// contentColorFor(), which maps a colour-SCHEME ROLE to its `on-` pair and
// returns Unspecified for anything else. The card surface is a plain constant
// and not a role, so the icons drew with no colour filter: black vectors on a
// near-black bar, a toolbar that rendered the whole time and was invisible in
// dark mode. STILL TRUE with one neutral surface — it is the same kind of
// value, so this stays exactly as it is.
// EXPLICIT, and not optional. Material derives a bar's content colour from its
// container via contentColorFor(), which maps a colour-SCHEME ROLE to its `on-`
// pair and returns Unspecified for anything else. The card surface is a plain
// constant, not a role, so without this the icons draw with no colour filter:
// black vectors on a near-black bar, invisible in dark mode.
//
// onSurface for the actions too, not the default onSurfaceVariant: the bar has
// to read against the card rather than the board, and the muted variant does
@@ -170,36 +154,24 @@ fun EditorTopBar(
/**
* The footer: when the note was last written, and the way out.
*
* **Where the note stands.** There is no save button, and there should not be — a
* note is saved continuously, so a button offering to do what already happened is a
* lie with a tap attached. But that left nothing on screen saying the work is safe,
* and "closing this keeps it" is not a thing anyone should have to be told twice. So
* the state says it, as a fact rather than an instruction: Not saved yet → Saving… →
* Edited just now is the whole lifecycle, and someone who watches it once never has
* to wonder again.
* **Where the note stands.** There is no save button: a note is saved continuously,
* so a button offering to do what already happened would be a lie with a tap
* attached. The state says the work is safe instead, as a fact: Not saved yet →
* Saving… → Edited just now is the whole lifecycle.
*
* **The way out.** Down here because of where hands are. Moving the toolbar to the
* top took the back arrow with it, which left the only exit from a full-screen
* editor in the top-left corner — the furthest point on the display from a
* right-handed thumb, and reached over the whole note to get to. The operator hit
* that on the first device pass and was right to. So the exit lives in the bottom
* corner, which with the keyboard up sits directly above it.
* **The way out.** Down here because of where hands are. The top-left corner is the
* furthest point on the display from a right-handed thumb, so the exit lives in the
* bottom corner, which with the keyboard up sits directly above it. The top-left
* arrow stays as well: it is what habit, the system back gesture and TalkBack all
* expect of a full-screen surface.
*
* The top-left arrow stays as well. Two affordances for one action is usually
* clutter, but this is the case that earns it: the arrow is what habit, the system
* back gesture and TalkBack all expect of a full-screen surface, and removing it
* would strand the reflex to strike a duplicate that costs one icon slot.
*
* A checkmark, at the operator's ask. I had shipped the word "Done" here on the
* argument that a tick in a NOTES app reads as a checklist item; overruled, and the
* filled treatment is what settles it — a tonal button in the note's own colour is
* plainly a control, where a bare glyph beside a checklist would not be. It carries
* "Done" as its content description, so the reasoning survives where it actually
* mattered: read aloud.
* A checkmark in a filled tonal button, so it reads plainly as a control rather than
* as a checklist tick. Its content description is "Done", which is what it says read
* aloud.
*
* [DateUtils] rather than a hand-rolled formatter: it is localised, it already
* knows the difference between minutes, hours and yesterday, and getting plurals
* right in every language is not this app's problem to solve twice.
* knows the difference between minutes, hours and yesterday, and it gets plurals
* right in every language.
*/
@Composable
fun EditorFooter(
@@ -212,7 +184,7 @@ fun EditorFooter(
modifier =
modifier
.fillMaxWidth()
// Rides above the keyboard, like the bar that used to be here. The
// Rides above the keyboard. The
// content Column deliberately does not also inset for the IME:
// Scaffold measures this row at its lifted height and passes the
// inset down.
@@ -230,16 +202,10 @@ fun EditorFooter(
)
FilledTonalIconButton(
onClick = onClose,
// The BRAND, matching the board's compose FAB — the app's one existing
// statement of "this is the affirmative action here", now reused rather
// than a second one invented.
//
// This wore the note's own tint until M315, on the argument that it would
// otherwise be the one element on a tinted card ignoring the tint. There is
// no tint to ignore any more, and the alternative — Material's default
// secondaryContainer — is a baseline M3 colour this theme never sets, so
// taking the default would put an off-brand lilac in the corner of the
// editor.
// The BRAND, matching the board's compose FAB: the app's one statement of
// "this is the affirmative action here". Material's default
// secondaryContainer is a baseline M3 colour this theme never sets, so the
// default would put an off-brand lilac in the corner of the editor.
colors =
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
@@ -293,13 +259,9 @@ private fun OverflowMenu(
}
DropdownMenu(expanded = open, onDismissRequest = close) {
if (note.trashed) {
MenuItem(R.string.editor_restore, close) { onAction(EditorAction.Restore) }
MenuItem(R.string.editor_delete_forever, close, onConfirmDelete)
TrashedNoteItems(close, onAction, onConfirmDelete)
} else {
MenuItem(
if (note.pinned) R.string.editor_unpin else R.string.editor_pin,
close,
) { onAction(EditorAction.SetPinned(!note.pinned)) }
PinItem(note, close, onAction)
if (owner) {
MenuItem(R.string.editor_labels, close) { onPicker(Picker.LABELS) }
}
@@ -307,10 +269,7 @@ private fun OverflowMenu(
if (owner && note.id != DRAFT_ID) {
MenuItem(R.string.editor_share, close, onShare)
}
MenuItem(
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
close,
) { onAction(EditorAction.SetArchived(!note.archived)) }
ArchiveItem(note, close, onAction)
if (owner) {
MenuItem(R.string.editor_trash, close) { onAction(EditorAction.Trash) }
}
@@ -319,6 +278,44 @@ private fun OverflowMenu(
}
}
// The rows a note's menu shares between the editor's overflow and the board's
// long-press menu: two ways to the same acts, worded the same way.
/** A trashed note's two ways out: back, or gone for good (which asks first). */
@Composable
internal fun TrashedNoteItems(
close: () -> Unit,
onAction: (EditorAction) -> Unit,
onConfirmDelete: () -> Unit,
) {
MenuItem(R.string.editor_restore, close) { onAction(EditorAction.Restore) }
MenuItem(R.string.editor_delete_forever, close, onConfirmDelete)
}
@Composable
internal fun PinItem(
note: Note,
close: () -> Unit,
onAction: (EditorAction) -> Unit,
) {
MenuItem(
if (note.pinned) R.string.editor_unpin else R.string.editor_pin,
close,
) { onAction(EditorAction.SetPinned(!note.pinned)) }
}
@Composable
internal fun ArchiveItem(
note: Note,
close: () -> Unit,
onAction: (EditorAction) -> Unit,
) {
MenuItem(
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
close,
) { onAction(EditorAction.SetArchived(!note.archived)) }
}
/**
* The note's labels, each removable.
*
@@ -345,7 +342,7 @@ fun EditorLabelRow(
// `#` on every chip, matching the card. This row still shows the
// tags the BODY owns as well — it is the control surface, and the
// "from tag" hint beside one is what says why it has no cross.
text = "#${label.name}",
text = hashtag(label.name),
style = MaterialTheme.typography.labelLarge,
color = tint.tagInk(dark),
modifier =
@@ -356,21 +353,13 @@ fun EditorLabelRow(
.padding(horizontal = 10.dp, vertical = 4.dp),
)
if (label.viaTag) {
Text(
text = stringResource(R.string.label_from_tag),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
FromTextMark()
} else if (!readOnly) {
IconButton(onClick = {
// Only the MANUAL labels are sent: the core replaces
// exactly those, and including a tag label here would ask
// it to own something the body text already owns.
val kept =
note.labels
.filterNot { it.viaTag || it.id == label.id }
.map { it.id }
val kept = note.manualLabelIds - label.id
onAction(EditorAction.SetLabels(kept))
}) {
Icon(
@@ -425,6 +414,3 @@ fun EditorReminderRow(
}
}
}
private const val SNOOZE_HOUR = 60L
private const val SNOOZE_DAY = 1440L
@@ -1,8 +1,10 @@
package com.fabledsword.inkwell.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
@@ -49,10 +51,6 @@ import java.time.temporal.TemporalAdjusters
// The two things you pick rather than type: a set of labels, and a time.
//
// It was three. The colour sheet went with `note.color` in M315 — a card is one neutral
// surface now and colour lives on the tag, so the swatch grid was a control with nothing
// behind it.
//
// All bottom sheets rather than dialogs. A dialog takes the middle of the screen
// and asks to be dismissed; a sheet rises from the bottom, under the thumb, with
// the note still visible above it — which matters when the choice you are making
@@ -74,80 +72,62 @@ fun LabelSheet(
onDismiss: () -> Unit,
) {
var typed by remember { mutableStateOf("") }
val manual =
note.labels
.filterNot { it.viaTag }
.map { it.id }
.toSet()
val manual = note.manualLabelIds.toSet()
val viaTag =
note.labels
.filter { it.viaTag }
.map { it.id }
.toSet()
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.imePadding()
.navigationBarsPadding(),
) {
SheetTitle(R.string.label_picker_title)
Sheet(
title = R.string.label_picker_title,
onDismiss = onDismiss,
modifier = Modifier.imePadding(),
) {
PlainTextField(
value = typed,
onValueChange = { typed = it },
hint = R.string.label_new_hint,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(onDone = {
onAction(EditorAction.CreateLabel(typed))
typed = ""
}),
)
PlainTextField(
value = typed,
onValueChange = { typed = it },
hint = R.string.label_new_hint,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(onDone = {
onAction(EditorAction.CreateLabel(typed))
typed = ""
}),
)
// Capped rather than unbounded: a sheet that grows past the screen
// makes its own scroll fight the sheet's drag gesture.
LazyColumn(modifier = Modifier.heightIn(max = LABEL_LIST_MAX_HEIGHT)) {
items(items = labels, key = { it.id }) { label ->
val fromTag = label.id in viaTag
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = fromTag || label.id in manual,
enabled = !fromTag,
onCheckedChange = { on ->
val next = if (on) manual + label.id else manual - label.id
onAction(EditorAction.SetLabels(next.toList()))
},
)
Text(
text = label.name,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 4.dp),
)
if (fromTag) {
Text(
text = stringResource(R.string.label_from_tag),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
}
}
// Capped rather than unbounded: a sheet that grows past the screen
// makes its own scroll fight the sheet's drag gesture.
LazyColumn(modifier = Modifier.heightIn(max = LABEL_LIST_MAX_HEIGHT)) {
items(items = labels, key = { it.id }) { label ->
val fromTag = label.id in viaTag
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = fromTag || label.id in manual,
enabled = !fromTag,
onCheckedChange = { on ->
val next = if (on) manual + label.id else manual - label.id
onAction(EditorAction.SetLabels(next.toList()))
},
)
Text(
text = label.name,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 4.dp),
)
if (fromTag) FromTextMark()
}
}
}
if (labels.isEmpty()) {
Text(
text = stringResource(R.string.label_none_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 16.dp),
)
}
if (labels.isEmpty()) {
Text(
text = stringResource(R.string.label_none_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 16.dp),
)
}
}
}
@@ -170,60 +150,53 @@ fun ReminderSheet(
) {
var exact by remember { mutableStateOf(false) }
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.navigationBarsPadding(),
) {
SheetTitle(R.string.reminder_title)
reminderPresets().forEach { (labelRes, at) ->
Text(
text = "${stringResource(labelRes)} · ${formatInstant(rfc3339(at))}",
style = MaterialTheme.typography.bodyLarge,
modifier =
Modifier
.fillMaxWidth()
.clickable {
onAction(EditorAction.SetReminder(rfc3339(at)))
onDismiss()
}.padding(vertical = 12.dp),
)
}
Sheet(
title = R.string.reminder_title,
onDismiss = onDismiss,
) {
reminderPresets().forEach { (labelRes, at) ->
Text(
text = stringResource(R.string.reminder_pick),
text = "${stringResource(labelRes)} · ${formatInstant(rfc3339(at))}",
style = MaterialTheme.typography.bodyLarge,
modifier =
Modifier
.fillMaxWidth()
.clickable { exact = true }
.padding(vertical = 12.dp),
.clickable {
onAction(EditorAction.SetReminder(rfc3339(at)))
onDismiss()
}.padding(vertical = 12.dp),
)
}
// Repeat only appears once there IS a reminder — a recurrence rule on
// a note with no time to recur from is a setting that does nothing.
if (note.remindAt != null) {
RecurrenceChips(
current = note.recurrence,
onPick = { onAction(EditorAction.SetRecurrence(it)) },
)
Text(
text = stringResource(R.string.reminder_clear),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error,
modifier =
Modifier
.fillMaxWidth()
.clickable {
onAction(EditorAction.ClearReminder)
onDismiss()
}.padding(vertical = 12.dp),
)
}
Text(
text = stringResource(R.string.reminder_pick),
style = MaterialTheme.typography.bodyLarge,
modifier =
Modifier
.fillMaxWidth()
.clickable { exact = true }
.padding(vertical = 12.dp),
)
// Repeat only appears once there IS a reminder — a recurrence rule on
// a note with no time to recur from is a setting that does nothing.
if (note.remindAt != null) {
RecurrenceChips(
current = note.recurrence,
onPick = { onAction(EditorAction.SetRecurrence(it)) },
)
Text(
text = stringResource(R.string.reminder_clear),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error,
modifier =
Modifier
.fillMaxWidth()
.clickable {
onAction(EditorAction.ClearReminder)
onDismiss()
}.padding(vertical = 12.dp),
)
}
}
@@ -286,7 +259,7 @@ private fun ExactReminderPicker(
) { Text(stringResource(R.string.picker_next)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.editor_cancel)) }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
},
) {
DatePicker(state = state)
@@ -312,7 +285,7 @@ private fun ExactReminderPicker(
}) { Text(stringResource(R.string.picker_set)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.editor_cancel)) }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
},
)
}
@@ -337,6 +310,36 @@ private fun RecurrenceChips(
}
}
/**
* A bottom sheet with its title: full width, the screen's side margin, and clear of
* the navigation bar. [modifier] adds to that, after the inset (ime padding, a
* scroll, extra space at the bottom).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun Sheet(
@StringRes title: Int,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
content: @Composable ColumnScope.() -> Unit,
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.navigationBarsPadding()
.then(modifier),
verticalArrangement = verticalArrangement,
) {
SheetTitle(title)
content()
}
}
}
@Composable
internal fun SheetTitle(labelRes: Int) {
Text(
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -20,17 +21,35 @@ import com.fabledsword.inkwell.R
// Shared by the board and the editor.
//
// A failed save is most likely to happen WHILE the editor is open — that is where
// the writes are — so a banner only the board could render meant the one screen
// that needed it was the one screen without it.
// A failed save is most likely WHILE the editor is open, since that is where the
// writes are, so the editor needs the banner as much as the board does.
@Composable
fun ErrorBanner(
message: String,
onDismiss: () -> Unit,
) {
Banner("red") {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDismiss) { Text(stringResource(R.string.error_dismiss)) }
}
}
/**
* A strip across the top of a screen, tinted from the note palette by [tintKey]: a
* message on the left and what can be done about it on the right.
*/
@Composable
fun Banner(
tintKey: String,
content: @Composable RowScope.() -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = noteTint("red")
val tint = noteTint(tintKey)
Row(
modifier =
Modifier
@@ -42,12 +61,7 @@ fun ErrorBanner(
.padding(start = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDismiss) { Text(stringResource(R.string.error_dismiss)) }
content()
}
}
@@ -0,0 +1,14 @@
package com.fabledsword.inkwell.ui
/** What a failure says when the core gave it no message of its own. */
internal const val FALLBACK_ERROR = "Something went wrong."
/**
* The message to show for a failure.
*
* The core writes its errors for people to read ("notes.example.com responded, but
* not with Inkwell's configuration"), so they are shown as they are rather than
* replaced with a generic line that would throw away the only useful part.
* [fallback] is for the failure that carries no message at all.
*/
internal fun Throwable.shownAs(fallback: String = FALLBACK_ERROR): String = message ?: fallback
@@ -1,36 +0,0 @@
package com.fabledsword.inkwell.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
/**
* Run [flush] when the app goes to the background.
*
* `ON_STOP` rather than `ON_PAUSE`: pause also fires when a dialog opens over the
* activity, which would save mid-sentence for no reason. The lambda goes through
* `rememberUpdatedState` so the observer — registered once — always calls the
* CURRENT one; captured directly it would hold the first composition's empty text
* forever and save that over a full note.
*
* Shared by the editor and the capture sheet. Both are places where text exists
* only in a composable until something writes it down, and the process can be
* killed while backgrounded without either of them being told again.
*/
@Composable
fun FlushOnStop(flush: () -> Unit) {
val current by rememberUpdatedState(flush)
val owner = LocalLifecycleOwner.current
DisposableEffect(owner) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) current()
}
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
}
@@ -2,8 +2,12 @@ package com.fabledsword.inkwell.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -11,28 +15,31 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
/**
* Calls back when the app comes to the front and when it leaves.
*
* `ON_START`/`ON_STOP` and not `ON_RESUME`/`ON_PAUSE`, which is the same choice
* the editor's save-on-leave makes for the same reason: resume and pause fire for
* `ON_START`/`ON_STOP` and not `ON_RESUME`/`ON_PAUSE`: resume and pause fire for
* anything that merely covers the window — a permission dialog, the notification
* shade — and a sync per shade-pull is not automatic sync, it is a stutter.
* shade — and a sync per shade-pull is not automatic sync, it is a stutter, just as
* a save per dialog would save the editor mid-sentence.
*
* A single-Activity app, so the Activity's lifecycle is the app's. If a second
* Activity is ever added this needs `ProcessLifecycleOwner` instead, or rotating
* between them will read as leaving and returning.
*
* Two callers, wanting opposite halves of it: automatic sync uses the return to
* decide whether to fetch, and the reminder notice uses it to re-read a
* permission the person may have just changed in the system settings.
* Callers want different halves of it: automatic sync uses both, the reminder
* notice and the update card use the return to re-read a permission the person may
* have just changed in the system settings, and the editor uses the leaving to
* write down text that otherwise exists only in a composable — the process can be
* killed while backgrounded without it being told again.
*
* Both callbacks go through [rememberUpdatedState]: the observer is registered
* once, and without it the lambda would keep reading the first composition's
* state forever — deciding whether to push unsent notes from a snapshot taken
* before any note existed.
* before any note existed, or saving the first composition's empty text over a
* full note.
*/
@Composable
fun ForegroundTransitions(
onForeground: () -> Unit,
onBackground: () -> Unit,
onForeground: () -> Unit = {},
onBackground: () -> Unit = {},
) {
val forward by rememberUpdatedState(onForeground)
val away by rememberUpdatedState(onBackground)
@@ -50,3 +57,29 @@ fun ForegroundTransitions(
onDispose { owner.lifecycle.removeObserver(observer) }
}
}
/**
* Run [act] once per trip to the front, as soon as [ready] holds.
*
* A trip that arrives before [ready] is held, not dropped: it runs when [ready]
* turns true. [onBackground] is passed through to [ForegroundTransitions].
*
* The trip is consumed inside the effect, so [act] fires once however many times
* the effect restarts. Writing a key from inside the effect does restart it — but
* there is no suspension point between consuming it and the call, so the block
* runs to completion before the recomposition that would cancel it is scheduled.
*/
@Composable
fun OnEachForeground(
ready: Boolean,
onBackground: () -> Unit = {},
act: () -> Unit,
) {
var wanted by remember { mutableStateOf(false) }
ForegroundTransitions(onForeground = { wanted = true }, onBackground = onBackground)
LaunchedEffect(wanted, ready) {
if (!wanted || !ready) return@LaunchedEffect
wanted = false
act()
}
}
@@ -2,31 +2,64 @@ package com.fabledsword.inkwell.ui
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.fabledsword.inkwell.core.LinkPreview
import com.fabledsword.inkwell.core.Note
private val PREVIEW_RADIUS = 8.dp
// The image's width on each size of card, matching the web's `w-24` and `w-12`.
private val FULL_IMAGE_WIDTH = 96.dp
private val COMPACT_IMAGE_WIDTH = 48.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,
* Mirrors `LONE_URL_RE` in the web's `notes/links.ts` 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 sentence's punctuation, not the link's: the server trims it before unfurling. */
private val TRAILING_PUNCTUATION = charArrayOf('.', ',', ';', ':', '!', '?')
/**
* The link a note that is nothing but a link files its preview under, or null.
*
* Trimmed the way the server's `unfurl_queue.detect_urls` trims it, since that is the
* key the preview arrives under. The web's `loneUrl` is the same rule, and
* both run the `urls` cases in `core/testdata/grammar.json` (here, by hand).
*/
fun loneUrl(body: String): String? =
LONE_URL
.matchEntire(body)
?.groupValues
?.get(1)
?.trimEnd { it in TRAILING_PUNCTUATION }
?.ifEmpty { null }
/**
* The preview for a note that is nothing but a URL, or null.
*
@@ -41,13 +74,12 @@ private val LONE_URL = Regex("""^\s*(https?://[^\s<>"'\]\)]+)\s*$""")
* the URL does.
*/
fun loneUrlPreview(note: Note): LinkPreview? {
if (!LONE_URL.matches(note.body)) return null
val url = note.body.trim()
val url = loneUrl(note.body) ?: return null
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)
fun isLoneUrl(note: Note): Boolean = loneUrl(note.body) != null
/**
* A fetched link preview, in one of two sizes.
@@ -57,12 +89,11 @@ fun isLoneUrl(note: Note): Boolean = LONE_URL.matches(note.body)
* 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).
* The image sits in a strip down the left edge, cropped to the card's height, as in
* the web's `LinkPreview.vue`. `image_url` is a REMOTE third-party address — the
* linked page's og:image — so the phone fetches it from whatever host the link
* points at, exactly as a browser does for the web card. Coil's disk cache means a
* card scrolled past twice costs one download, not two.
*/
@Composable
fun LinkPreviewCard(
@@ -74,19 +105,62 @@ fun LinkPreviewCard(
// 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).
// instead.
val shape = RoundedCornerShape(PREVIEW_RADIUS)
val padH = if (compact) 8.dp else 10.dp
val padV = if (compact) 6.dp else 8.dp
Column(
// IntrinsicSize.Min so the image strip can fill exactly the text's height, which
// is what the web's `self-stretch` does.
Row(
modifier =
modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.clip(shape)
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
.padding(horizontal = padH, vertical = padV),
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape),
) {
PreviewImage(url = preview.imageUrl, compact = compact)
PreviewText(
preview = preview,
compact = compact,
modifier = Modifier.weight(1f).padding(horizontal = padH, vertical = padV),
)
}
}
/**
* The preview's image, or nothing at all.
*
* Nothing rather than an empty box when there is no image, AND when it fails to
* load: a page that names an og:image it no longer serves is common, and a grey
* square says "broken" about a card whose words are perfectly good. Same outcome
* as a preview that never had an image.
*/
@Composable
private fun PreviewImage(
url: String?,
compact: Boolean,
) {
var failed by remember(url) { mutableStateOf(false) }
if (url.isNullOrBlank() || failed) return
AsyncImage(
model = url,
// Decoration: the title beside it already says what the link is.
contentDescription = null,
contentScale = ContentScale.Crop,
onError = { failed = true },
modifier = Modifier.width(if (compact) COMPACT_IMAGE_WIDTH else FULL_IMAGE_WIDTH).fillMaxHeight(),
)
}
@Composable
private fun PreviewText(
preview: LinkPreview,
compact: Boolean,
modifier: Modifier,
) {
Column(modifier = modifier) {
preview.siteName?.takeIf { it.isNotBlank() }?.let { site ->
Text(
text = site.uppercase(),
@@ -3,22 +3,24 @@ package com.fabledsword.inkwell.ui
import com.fabledsword.inkwell.core.Note
/**
* How this account holds a note (#5175), read from the core's `permission`.
* How this account holds a note, 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
* not. Either way its pin and archive are this account's own. The core
* refuses the rest anyway — this only keeps the editor from offering what would be
* refused.
*/
enum class NoteAccess { OWNER, EDIT, VIEW }
enum class NoteAccess(
/** The core's `permission` string, which is also the server's and the web's. */
val wire: String,
) {
OWNER("owner"),
EDIT("edit"),
VIEW("view"),
}
val Note.access: NoteAccess
get() =
when (permission) {
"edit" -> NoteAccess.EDIT
"view" -> NoteAccess.VIEW
else -> NoteAccess.OWNER
}
get() = NoteAccess.entries.firstOrNull { it.wire == permission } ?: NoteAccess.OWNER
/** Its content can't change here: it is in the trash, or shared with us to view. */
val Note.readOnlyHere: Boolean
@@ -27,3 +29,11 @@ val Note.readOnlyHere: Boolean
/** The owner's own controls apply: tags, reminder, files, previews, share, trash. */
val Note.ownerControlsHere: Boolean
get() = !readOnlyHere && access == NoteAccess.OWNER
/**
* The tags attached by hand (the picker, or a lifted line), as opposed to the ones
* the body's own `#tags` carry. A label write sets exactly these; a body tag is the
* body's to keep.
*/
val Note.manualLabelIds: List<String>
get() = labels.filterNot { it.viaTag }.map { it.id }
@@ -18,6 +18,7 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -52,16 +53,23 @@ fun NoteCard(
onToggleItem: (Int, Boolean) -> Unit,
onAction: (EditorAction) -> Unit,
onConfirmDelete: () -> Unit,
/** The board is carrying this card (BoardDrag.kt). */
dragging: Boolean = false,
/** Whether an archived note says so — everywhere but the Archive itself. */
marksArchived: Boolean = true,
) {
val dark = isSystemInDarkTheme()
val haptics = LocalHapticFeedback.current
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
// chain-method-continuation wants the next `.` glued to the closing paren of a
// multiline element — `).background(…)` — which is worse to read than a modifier
// with a name. Every other multiline element in this codebase happens to be last
// in its chain, so this is the first place the rule bites.
// with a name.
val opening =
Modifier.combinedClickable(
onClickLabel = stringResource(R.string.board_open_note),
@@ -101,75 +109,11 @@ fun NoteCard(
.border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS))
.padding(12.dp),
) {
// 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)
CardContents(
note = note,
onToggleItem = onToggleItem,
archivedChip = marksArchived && note.archived && !note.trashed,
)
}
NoteMenu(
@@ -182,6 +126,93 @@ fun NoteCard(
}
}
/**
* What a card shows, top to bottom: archived or not, tags, body, links, attachments,
* reminder, sharing.
*/
@Composable
private fun CardContents(
note: Note,
onToggleItem: (Int, Boolean) -> Unit,
archivedChip: Boolean,
) {
// Above everything, tags included: whether a note is put away is the first thing
// to know about a search result or a tag's note, before what it says.
if (archivedChip) {
TintChip(stringResource(R.string.card_archived), "default")
Spacer(Modifier.height(8.dp))
}
// 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. The first line of the
// body IS the note's name, at the same weight as the rest of it.
if (lonePreview != null) {
LinkPreviewCard(preview = lonePreview, compact = false)
} else if (note.body.isNotBlank()) {
// A note shared with us to view has inert boxes: 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 does the same.
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, on a long press.
*
@@ -202,23 +233,16 @@ private fun NoteMenu(
onAction: (EditorAction) -> Unit,
onConfirmDelete: () -> Unit,
) {
// Pin and archive are this account's own on someone else's note too (#5176);
// Pin and archive are this account's own on someone else's note too;
// trash is the owner's. A note shared with us never reaches our Trash, so the
// trashed set is only ever the owner's.
val owner = note.access == NoteAccess.OWNER
DropdownMenu(expanded = expanded && (owner || !note.trashed), onDismissRequest = onDismiss) {
if (note.trashed) {
MenuItem(R.string.editor_restore, onDismiss) { onAction(EditorAction.Restore) }
MenuItem(R.string.editor_delete_forever, onDismiss, onConfirmDelete)
TrashedNoteItems(onDismiss, onAction, onConfirmDelete)
} else {
MenuItem(
if (note.pinned) R.string.editor_unpin else R.string.editor_pin,
onDismiss,
) { onAction(EditorAction.SetPinned(!note.pinned)) }
MenuItem(
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
onDismiss,
) { onAction(EditorAction.SetArchived(!note.archived)) }
PinItem(note, onDismiss, onAction)
ArchiveItem(note, onDismiss, onAction)
if (owner) {
MenuItem(R.string.editor_trash, onDismiss) { onAction(EditorAction.Trash) }
}
@@ -230,12 +254,12 @@ private fun NoteMenu(
* The note's body, with its checklist drawn where it actually sits.
*
* Rendered line by line rather than as one block of text, because an item is a line
* of the body now (M304) and a card that showed the prose and then the list would put
* every list in the wrong place — and, since the body already contains those lines,
* would show each one twice.
* of the body, and a card that showed the prose and then the list would put every
* list in the wrong place — and, since the body already contains those lines, would
* show each one twice.
*
* Which lines are items is asked of the core rather than matched here. The grammar is
* already written three times; a fourth in Compose would be a fourth place for a
* Which lines are items is asked of the core rather than matched here. The grammar has
* three implementations already; a fourth in Compose would be a fourth place for a
* checklist to change shape when it syncs.
*/
@Composable
@@ -324,12 +348,8 @@ private fun ChecklistRow(
/**
* One string of a note's own words, with every `#tag` in it drawn in that tag's colour.
*
* This is what replaced the chip for a tag still living in the prose. The card used to
* print such a tag twice — once where it was typed and once in the row above — and the
* duplicate was the loud copy, which made a tagged note read as "tag, then some text
* that happens to start with the same word". Colouring it in place says the same thing
* with no furniture, and says it more honestly: the token you can see IS the text you
* would delete to remove the tag.
* A tag still living in the prose is coloured in place rather than repeated as a chip:
* the token you can see IS the text you would delete to remove the tag.
*
* WHICH characters are a tag is asked of the core, exactly as [NoteBody] asks it which
* lines are checklist items. The grammar already exists three times (Rust, Python,
@@ -357,11 +377,11 @@ private fun tintTags(
// A tag the note does not carry as a label yet — just typed, not yet
// derived — still gets a colour: `labelTint` falls back to deriving one
// from the name, which is what the chip would have shown anyway.
val picked = note.labels.associate { it.name.lowercase() to it.color }
val picked = note.labels.associate { foldTag(it.name) to it.color }
buildAnnotatedString {
append(text)
spans.forEach { tag ->
val tint = labelTint(tag.name, picked[tag.name.lowercase()].orEmpty())
val tint = labelTint(tag.name, picked[foldTag(tag.name)].orEmpty())
addStyle(
SpanStyle(color = tint.tagInk(dark), fontWeight = FontWeight.Medium),
tag.start.toInt(),
@@ -386,7 +406,7 @@ private fun LabelChips(labels: List<NoteLabel>) {
// this row is a tag — a tag lifted off its own line, or one attached
// through the picker — and the hash is how you would type either. It
// also keeps a lifted chip reading as the `#todo` somebody wrote.
text = "#${label.name}",
text = hashtag(label.name),
style = MaterialTheme.typography.labelSmall,
color = tint.tagInk(dark),
maxLines = 1,
@@ -406,33 +426,19 @@ private fun LabelChips(labels: List<NoteLabel>) {
* The reminder, red once it has passed.
*
* Red for overdue and neutral otherwise, matching the web card exactly — the same
* red-100/red-700 and black/5 pairs, resolved through the shared tint table. It
* used to be blue for every reminder here, which made "you missed this" and
* "coming up on Friday" look identical on a board full of both.
* red-100/red-700 and black/5 pairs, resolved through the shared tint table, so
* "you missed this" and "coming up on Friday" don't look alike on a board of both.
*/
@Composable
private fun ReminderChip(
instant: String,
recurrence: String?,
) {
val dark = isSystemInDarkTheme()
val tint = noteTint(if (isPast(instant)) "red" else "default")
Text(
text = reminderLabel(instant, recurrence),
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),
)
TintChip(reminderLabel(instant, recurrence), if (isPast(instant)) "red" else "default")
}
/**
* Whose note this is, when it is not only ours (#5175): "From Robin" on a note someone
* Whose note this is, when it is not only ours: "From Robin" on a note someone
* shared with us, "Shared" on one of ours that we shared. Nothing on a private note.
*/
@Composable
@@ -440,28 +446,12 @@ 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),
)
stringResource(R.string.share_chip_by, sharerName(note))
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),
)
TintChip(text, "default")
}
/** A box that does nothing when tapped: someone else's note, shared to view. */
@@ -483,31 +473,21 @@ private val CARD_ELEVATION = 1.dp
// too, so a note opened is the same object as the note on the board.
/**
* THE CARD SURFACE — one neutral per theme (M315).
* THE CARD SURFACE — one neutral per theme.
*
* This used to be a function of the note: a palette fill for a tagged one, a colour
* generated from the id for the rest. Both are gone. The operator's verdict after four
* passes — "my coloring attempt has failed and nothing looks right… we've tried a lot
* to make the color work and somehow it never seems to land" — and the diagnosis under
* it is that a card's fill was being asked to carry meaning it could not carry. Nine
* keys is too few to identify anything on a board of any size, and a generated fill
* identifies nothing by construction, so a coloured board taught the eye to read hue
* as significant and then handed it noise. Colour lives on the TAG now, where the
* thing it names is right beside it.
* A card's fill carries no meaning: nine keys are too few to identify anything on a
* board of any size, and a coloured board teaches the eye to read hue as significant.
* Colour lives on the TAG, beside the thing it names.
*
* The values are `neutral-900` on dark and white on light — exactly what the palette's
* `default` always was, and exactly what the web card and both editors already use, so
* this is a collapse onto a surface every surface already had rather than a new colour
* anybody has to like. Mirrored as `NOTE_CARD_SURFACE` in `frontend/src/notes/colors.ts`
* The values are `neutral-900` on dark and white on light, the same as the web card
* and both editors. Mirrored as `NOTE_CARD_SURFACE` in `frontend/src/notes/colors.ts`
* (`bg-white dark:bg-neutral-900`).
*
* NOT a colour-scheme role: `surface` is the BOARD in this theme (neutral-50 / -950),
* and Material's `surfaceContainer` roles are unset here so they would resolve to
* baseline M3 greys rather than to the web's neutrals. Two hexes matching the web beats
* a role that nearly does.
* baseline M3 greys rather than to the web's neutrals.
*
* Measured, against the operator's "not the same color as their background but close
* to it" — the card fill is deliberately the WEAKEST number on the card:
* The card fill is deliberately the WEAKEST contrast on the card:
*
* card vs board light #FFFFFF on #FAFAFA 1.04
* dark #171717 on #0A0A0A 1.10
@@ -518,10 +498,9 @@ private val CARD_ELEVATION = 1.dp
* muted vs card light #404040 on #FFFFFF 10.37
* dark #E5E5E5 on #171717 14.23
*
* A card is not separated from the board by its fill and never was — the edge and the
* shadow do that, which is why 1.04 is enough and why it has to stay near 1. A fill
* that separated on its own would be a panel, and a board of panels is the wall this
* whole line of work started from.
* The edge and the shadow separate a card from the board, not its fill, which is why
* 1.04 is enough and why it has to stay near 1: a fill that separated on its own would
* make a board of panels.
*/
fun noteCardSurface(dark: Boolean): Color = if (dark) CARD_SURFACE_DARK else CARD_SURFACE_LIGHT
@@ -537,4 +516,3 @@ private val CARD_SURFACE_DARK = Color(0xFF171717)
// surfaces stay the same card. Contrast: #B8B8B8 on white 1.98, #404040 on #171717 1.73.
private val CARD_EDGE_LIGHT = Color(0xFFB8B8B8)
private val CARD_EDGE_DARK = Color(0xFF404040)
private val CHIP_RADIUS = 6.dp
@@ -34,16 +34,14 @@ import kotlinx.coroutines.delay
/**
* The one writing surface: a new note and an existing one are the same screen.
*
* Shaped like the capture sheet it replaced — a rounded card that begins below the
* status bar — so opening a note still reads as something rising over the board
* rather than a place you navigated to. It is full height rather than a real
* `ModalBottomSheet`, and that is the whole trade: a sheet spends a writing session
* negotiating with the IME for the bottom half of the display, and the swipe-down it
* buys is a gesture back already does. The shape is what was worth keeping.
* Shaped like a sheet — a rounded card that begins below the status bar — so opening
* a note reads as something rising over the board rather than a place you navigated
* to. It is full height rather than a real `ModalBottomSheet`: a sheet spends a
* writing session negotiating with the IME for the bottom half of the display, and
* the swipe-down it buys is a gesture back already does.
*
* The card's surface paints the WHOLE sheet rather than a panel inside it, so opening
* a note reads as the same object growing to fill the display — the more literally
* true since M315, where the board and the editor became the same one neutral.
* a note reads as the same object growing to fill the display.
*
* No save button, deliberately. Writes are continuous, so a button offering to do
* what already happened would be a lie with a tap attached; [EditorFooter] in the
@@ -69,7 +67,7 @@ fun NoteEditorScreen(
// throwing away every character typed during the write.
//
// BLOCKS rather than one string, because a checklist item is drawn as a real
// checkbox now and a widget cannot live inside a text field. The note is still one
// checkbox and a widget cannot live inside a text field. The note is still one
// markdown body underneath — see EditorBlock.kt — and `bodyText` is what is saved.
//
// Saveable, because a new note has nothing to fall back on if the phone rotates
@@ -93,7 +91,7 @@ fun NoteEditorScreen(
// Restore and Delete forever as the only things to do with it.
//
// 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
// web: nothing about it is ours to change. 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
@@ -125,10 +123,8 @@ fun NoteEditorScreen(
// Idle-debounced autosave. LaunchedEffect cancels and restarts on every
// keystroke, so the delay only ever elapses once typing stops.
//
// Saving this often is affordable because a body write no longer costs a
// revision: history snapshots once per editing session rather than once per
// save. Before that, writing was expensive enough that this editor hoarded
// text until it closed — and an app kill mid-session lost the lot.
// Saving this often is affordable because a body write doesn't cost a revision:
// history snapshots once per editing session rather than once per save.
//
// For a note that does not exist yet this is also what CREATES it, which is why
// every toolbar button works moments after the first keystroke rather than
@@ -142,7 +138,7 @@ fun NoteEditorScreen(
BackHandler(onBack = leave)
// The system picker, for any type: a note can carry a PDF as readily as a photo.
// Leaving for it stops this screen, so FlushOnStop has already saved the text by
// Leaving for it stops this screen, so the flush on leaving has already saved the text by
// the time the files come back.
val pickFiles =
rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
@@ -152,7 +148,7 @@ fun NoteEditorScreen(
// Leaving the APP is not closing the editor, so the text has to be saved
// without the screen being torn down. Losing a paragraph to an incoming call
// is exactly the failure that makes someone stop trusting a notes app.
FlushOnStop(flush)
ForegroundTransitions(onBackground = flush)
// The sheet shape, kept. `windowInsetsPadding` both insets the card below the
// status bar AND consumes that inset, so the bar inside adds no second gap of
@@ -173,8 +169,7 @@ fun NoteEditorScreen(
// container), which returns Unspecified for anything that is not a
// colour-SCHEME ROLE. The card surface is not one, so the default publishes
// Unspecified as LocalContentColor and everything inside that does not
// set its own colour draws black — which is how the last toolbar became
// invisible in dark mode.
// set its own colour draws black, invisible in dark mode.
contentColor = MaterialTheme.colorScheme.onSurface,
) {
Scaffold(
@@ -201,7 +196,7 @@ fun NoteEditorScreen(
onAction = onAction,
)
},
// Where the action bar used to be, carrying the two things that
// The two things that
// belong within reach of a thumb: whether the note is safe, and the
// way out. See [EditorFooter] for why the exit is down here and not
// only in the top-left corner.
@@ -242,20 +237,20 @@ fun NoteEditorScreen(
// A note is its body; its NAME is that body's first line, so there
// is nothing separate to type into and nothing rendered bolder than
// the line beneath it (M13 steps 3 and 4). What 2992 changed is only
// how the body is DRAWN — checklist items as boxes rather than as
// the line beneath it. Checklist items draw as boxes rather than as
// the markup for boxes.
BlockBody(
blocks = blocks,
readOnly = readOnly,
focus = focus,
tags = labels,
onChange = { blocks = it },
onFocus = { focus = it },
)
// No checklist section. The items ARE lines of the field above
// (M304) — rendering them again down here is what would put every
// list on screen twice.
// No checklist section. The items ARE lines of the field above;
// rendering them again down here would put every list on screen
// twice.
if (note.labels.isNotEmpty()) {
EditorLabelRow(note = note, readOnly = !ownerControls, onAction = onAction)
@@ -340,7 +335,7 @@ private fun EditorOverlays(
* How long typing has to stop before the note is written.
*
* Long enough that a normal sentence is one write, short enough that nothing
* meaningful is at risk if the app dies. The flush on close and [FlushOnStop] still
* meaningful is at risk if the app dies. The flush on close and on leaving the app still
* cover the window between the last keystroke and this elapsing.
*/
private const val AUTOSAVE_IDLE_MS = 1_000L
@@ -350,6 +345,6 @@ private const val ANY_TYPE = "*/*"
/**
* The card's top corner radius — Material's extra-large, which is what a bottom
* sheet uses. Same shape as the capture surface this replaced, on purpose.
* sheet uses.
*/
private val SHEET_CORNER = 28.dp
@@ -1,8 +1,10 @@
package com.fabledsword.inkwell.ui
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color
import com.fabledsword.inkwell.R
/**
* The colour palette, matching `frontend/src/notes/colors.ts` VALUE FOR VALUE.
@@ -17,8 +19,8 @@ import androidx.compose.ui.graphics.Color
* a translucent colour over what's beneath exactly as CSS does, so a panel sits on the
* background the same way in both.
*
* This table is the palette of MEANINGFUL colours: a tag's. A NOTE no longer has one
* at all (M315) — the card is one neutral per theme, held in NoteCard.kt beside the
* This table is the palette of MEANINGFUL colours: a tag's. A NOTE has none — the
* card is one neutral per theme, held in NoteCard.kt beside the
* edge, where the palette cannot reach either of them. What is left here is the chip,
* the inline `#tag`, and the panels and banners that borrow a hue to say what they
* are.
@@ -27,7 +29,7 @@ import androidx.compose.ui.graphics.Color
* acid against the neutral surfaces.
*/
data class NoteTint(
val label: String,
@StringRes val label: Int,
val lightBackground: Color,
val lightBorder: Color,
val darkBackground: Color,
@@ -53,8 +55,8 @@ data class NoteTint(
) {
/**
* The pale fill of a PANEL, a banner, an update card or a picker swatch — the
* places that borrow a hue to say what they are. Not a note's: since M315 a card
* has one neutral surface and does not come through this table at all.
* places that borrow a hue to say what they are. Not a note's: a card has one
* neutral surface and does not come through this table at all.
*/
fun background(dark: Boolean): Color = if (dark) darkBackground else lightBackground
@@ -67,19 +69,16 @@ data class NoteTint(
*
* Kept apart from [tagInk] because the reminder pill is not a tag: it borrows the
* chip's shape and its `red-100`/`black-5` fills, and its `red-700`/`neutral-600`
* text is transcribed from NoteCard.vue's literal classes. The two happened to be
* one value; making the tag ink one step deeper (M315) is where they parted, and
* moving the reminder with it would have silently broken that mirror instead.
* text is transcribed from NoteCard.vue's literal classes, so it follows those,
* not [tagInk].
*/
fun chipForeground(dark: Boolean): Color = if (dark) darkChipForeground else lightChipForeground
/**
* The colour a `#tag` is drawn in — in the note's own words, or as a chip.
*
* A tag whose text is in the body is no longer repeated as a chip (the card was
* printing every tag twice — once where it was typed, once at the top). It is
* tinted in place instead, which is both less furniture and a more honest card:
* the thing you see IS the thing you would delete to remove the tag.
* A tag whose text is in the body is tinted in place rather than repeated as a
* chip: the thing you see IS the thing you would delete to remove the tag.
*/
fun tagInk(dark: Boolean): Color = if (dark) darkTagInk else lightTagInk
@@ -92,7 +91,7 @@ data class NoteTint(
* The fill only tints a shape the edge is drawing.
*
* An edge rather than a heavier fill, because a fill loud enough to hold its own
* shape would be the loudest thing on a board whose whole point is now that the tag
* shape would be the loudest thing on a board whose whole point is that the tag
* is the one coloured thing on it.
*/
fun chipBorder(dark: Boolean): Color = tagInk(dark).copy(alpha = CHIP_EDGE_ALPHA)
@@ -100,18 +99,9 @@ data class NoteTint(
// How strongly a chip's edge is drawn, as a fraction of its own ink.
//
// SOLVED FOR, NOT GUESSED — and re-solved once the answer became solvable. 0.60 was
// picked against the worst case of the time: a chip on a card of its OWN colour, back
// when a note took its first tag's fill. It gave 2.32:1 there, missed the 3:1 of WCAG
// 1.4.11, and the comment here reasoned its way out of that on the grounds that a
// chip's information is its text.
//
// M315 removed that worst case. The edge is now the ink at alpha over a KNOWN fill, so
// the smallest alpha clearing 3:1 for all ten hues is arithmetic rather than judgment:
// 0.60 gives 2.75-3.82 in light and misses for six of the ten, 0.65 gives 3.03-4.36 and
// misses for none. Dark runs 4.52-5.76. The old comment named 0.80 as the fallback if
// the judgment were ever overruled; it is not needed, and it draws a hard outline where
// a hairline does the job.
// SOLVED FOR, NOT GUESSED. The edge is the ink at alpha over the known card fill, and
// 0.65 is the smallest alpha clearing WCAG 1.4.11's 3:1 for all ten hues: 3.03-4.36 in
// light, 4.52-5.76 in dark. 0.60 misses for six of the ten in light.
//
// Mirrored on the web as the `/65` in LABEL_CHIP_SHELL's ring.
private const val CHIP_EDGE_ALPHA = 0.65f
@@ -121,7 +111,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
mapOf(
"default" to
NoteTint(
label = "Default",
label = R.string.tint_default,
lightBackground = Color(0xFFFFFFFF),
lightBorder = Color(0xFFE5E5E5),
darkBackground = Color(0xFF171717),
@@ -135,7 +125,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
),
"red" to
NoteTint(
label = "Red",
label = R.string.tint_red,
lightBackground = Color(0xFFFEF2F2),
lightBorder = Color(0xFFFECACA),
darkBackground = Color(0x66450A0A),
@@ -149,7 +139,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
),
"orange" to
NoteTint(
label = "Orange",
label = R.string.tint_orange,
lightBackground = Color(0xFFFFF7ED),
lightBorder = Color(0xFFFED7AA),
darkBackground = Color(0x66431407),
@@ -163,7 +153,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
),
"yellow" to
NoteTint(
label = "Yellow",
label = R.string.tint_yellow,
lightBackground = Color(0xFFFFFBEB),
lightBorder = Color(0xFFFDE68A),
darkBackground = Color(0x66451A03),
@@ -177,7 +167,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
),
"green" to
NoteTint(
label = "Green",
label = R.string.tint_green,
lightBackground = Color(0xFFF0FDF4),
lightBorder = Color(0xFFBBF7D0),
darkBackground = Color(0x66052E16),
@@ -191,7 +181,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
),
"teal" to
NoteTint(
label = "Teal",
label = R.string.tint_teal,
lightBackground = Color(0xFFF0FDFA),
lightBorder = Color(0xFF99F6E4),
darkBackground = Color(0x66042F2E),
@@ -205,7 +195,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
),
"blue" to
NoteTint(
label = "Blue",
label = R.string.tint_blue,
lightBackground = Color(0xFFEFF6FF),
lightBorder = Color(0xFFBFDBFE),
darkBackground = Color(0x66172554),
@@ -219,7 +209,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
),
"purple" to
NoteTint(
label = "Purple",
label = R.string.tint_purple,
lightBackground = Color(0xFFFAF5FF),
lightBorder = Color(0xFFE9D5FF),
darkBackground = Color(0x663B0764),
@@ -233,7 +223,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
),
"pink" to
NoteTint(
label = "Pink",
label = R.string.tint_pink,
lightBackground = Color(0xFFFDF2F8),
lightBorder = Color(0xFFFBCFE8),
darkBackground = Color(0x66500724),
@@ -247,7 +237,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
),
"gray" to
NoteTint(
label = "Gray",
label = R.string.tint_gray,
lightBackground = Color(0xFFF5F5F5),
lightBorder = Color(0xFFD4D4D4),
darkBackground = Color(0xFF262626),
@@ -9,8 +9,12 @@ 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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -85,7 +89,7 @@ fun Notice(
/**
* One row of a dropdown menu, closing the menu before it acts.
*
* Lives here rather than beside either menu because there are two now — the
* Lives here rather than beside either menu because there are two — the
* editor's overflow and the board's long-press menu — and they offer the same
* actions in the same words. A second copy of this would be a second place for the
* closing order to be got wrong.
@@ -126,19 +130,77 @@ fun MenuItem(
fun ConfirmDeleteDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
ConfirmDialog(
title = stringResource(R.string.editor_delete_forever_title),
body = stringResource(R.string.editor_delete_forever_body),
confirm = stringResource(R.string.editor_delete_forever_confirm),
onDismiss = onDismiss,
onConfirm = onConfirm,
)
}
/**
* A confirmation: what is about to happen, what it costs, and one way out.
*
* Every dialog that asks before acting is this one, so they all word their buttons
* and lay out their consequences the same way.
*/
@Composable
fun ConfirmDialog(
title: String,
body: String,
confirm: String,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
footnote: String? = null,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.editor_delete_forever_title)) },
text = { Text(stringResource(R.string.editor_delete_forever_body)) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text(stringResource(R.string.editor_delete_forever_confirm))
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,
)
}
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.editor_cancel)) }
confirmButton = {
TextButton(onClick = onConfirm) { Text(confirm) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
},
)
}
/** The arrow out of a full-screen surface, at the start of its top bar. */
@Composable
fun BackButton(
onClick: () -> Unit,
@StringRes label: Int,
) {
IconButton(onClick = onClick) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(label))
}
}
/** Secondary text under a field or a heading: smaller, and in the quieter ink. */
@Composable
fun Hint(
text: String,
modifier: Modifier = Modifier,
) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = modifier,
)
}
@@ -13,6 +13,8 @@ import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
@@ -27,10 +29,9 @@ import androidx.compose.ui.text.input.VisualTransformation
* container and the indicator at each site independently is how they drift apart, so
* it happens once, here.
*
* The note EDITOR no longer comes through this. It dropped to `BasicTextField`
* (see `EditorBlock.kt`) for density: Material's field puts 16dp above and below its
* text, which is right for a form and is the whole row height on a checklist. Nothing
* about "no box" was lost there — BasicTextField never had one.
* The note EDITOR doesn't use this: it uses `BasicTextField` (see `EditorBlock.kt`)
* for density, since Material's field puts 16dp above and below its text, which is
* right for a form and is the whole row height on a checklist.
*
* The disabled colours are stripped too: a trashed note is shown through this
* field read-only, and Material's disabled treatment would grey out text the user
@@ -54,7 +55,7 @@ fun PlainTextField(
TextField(
value = value,
onValueChange = onValueChange,
modifier = modifier.fillMaxWidth(),
modifier = modifier.fillMaxWidth().arrowKeysMoveCaret(atTop = { singleLine }, atBottom = { singleLine }),
enabled = enabled,
placeholder = hint?.let { { Text(stringResource(it)) } },
singleLine = singleLine,
@@ -86,3 +87,29 @@ private fun plainFieldColors(): TextFieldColors =
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
)
/**
* Arrow keys on a hardware keyboard move the caret, not the focus.
*
* Compose's text field gives the arrow keys of any device that reports D-pad keys
* to focus search before the field sees them, and a physical keyboard with arrow
* keys reports exactly that — so without this, ← and → jump to whatever sits beside
* the field instead of moving through the text. Cancelling the search in a
* direction hands the key back: the search reports that nothing moved, and the
* field takes the key as editing.
*
* ← and → never leave the field. ↑ and ↓ move between its lines, and from the first
* or last line — [atTop], [atBottom] — on to the field above or below, the way a
* caret leaves one paragraph for the next. Both are asked at the moment of the key
* press, so they can read where the caret is now.
*/
fun Modifier.arrowKeysMoveCaret(
atTop: () -> Boolean,
atBottom: () -> Boolean,
): Modifier =
focusProperties {
left = FocusRequester.Cancel
right = FocusRequester.Cancel
if (!atTop()) up = FocusRequester.Cancel
if (!atBottom()) down = FocusRequester.Cancel
}
@@ -0,0 +1,73 @@
package com.fabledsword.inkwell.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.fabledsword.inkwell.TagViews
import com.fabledsword.inkwell.core.Label
import com.fabledsword.inkwell.core.Note
/**
* The drawer lists the few tags in use rather than every tag, so a long
* list never pushes Archive, Trash and Sync out of reach. Mirrors
* `frontend/src/notes/recentTags.ts`.
*/
const val RECENT_TAG_COUNT = 5
/**
* The [count] most recent of [tags], listed by name so a row does not move under
* the thumb that tapped it. Every tag while there are no more than [count].
*/
fun <T> recentTags(
tags: List<T>,
latest: (T) -> Long,
name: (T) -> String,
count: Int = RECENT_TAG_COUNT,
): List<T> {
if (tags.size <= count) return tags
return tags
.sortedWith(compareByDescending(latest).thenBy(String.CASE_INSENSITIVE_ORDER, name))
.take(count)
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, name))
}
/**
* How recently each tag was used or viewed, in epoch milliseconds: the later of its
* newest note in the store ([Label.lastUsed]), its newest note on the board now —
* so a tag applied a moment ago counts before the labels are next listed — and the
* last time its lens was opened here ([views]).
*/
fun tagRecency(
labels: List<Label>,
notes: List<Note>,
views: Map<String, Long>,
): Map<String, Long> {
// A missing or unreadable stamp counts as 0: such a tag simply ranks last.
val latest = labels.associate { it.id to maxOf(it.lastUsed.millis(), views[it.id] ?: 0L) }.toMutableMap()
for (note in notes) {
val at = note.updatedAt.millis()
for (label in note.labels) latest.computeIfPresent(label.id) { _, was -> maxOf(was, at) }
}
return latest
}
private fun String?.millis(): Long = this?.let(::epochMillis) ?: 0L
/**
* When each tag's lens was last opened here. Opening one counts as using it,
* however it was opened: from the drawer, or a tag on a card.
*/
@Composable
internal fun rememberTagViews(destination: Destination): Map<String, Long> {
val context = LocalContext.current
val tagViews = remember(context) { TagViews(context) }
var views by remember { mutableStateOf(tagViews.all()) }
LaunchedEffect(destination) {
if (destination is Destination.WithLabel) views = tagViews.record(destination.id)
}
return views
}
@@ -48,7 +48,6 @@ fun ReminderNotice() {
canNotify = notificationsAllowed(context)
canBeExact = exactAllowed(context)
},
onBackground = {},
)
if (canNotify && canBeExact) return
@@ -7,7 +7,6 @@ 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
@@ -18,7 +17,6 @@ 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
@@ -39,11 +37,16 @@ import com.fabledsword.inkwell.core.NoteShare
import com.fabledsword.inkwell.core.ShareGroup
import com.fabledsword.inkwell.core.ShareTarget
/** Who shared [note] with us, by name, or "Someone" when the server sent none. */
@Composable
internal fun sharerName(note: Note): String =
note.sharedBy?.displayName?.takeIf { it.isNotBlank() } ?: stringResource(R.string.share_someone)
/** "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 who = sharerName(note)
val line =
if (note.access == NoteAccess.EDIT) {
stringResource(R.string.share_by_can_edit, who)
@@ -58,8 +61,8 @@ fun SharedByLine(note: Note) {
)
}
private const val VIEW = "view"
private const val EDIT = "edit"
private val VIEW = NoteAccess.VIEW.wire
private val EDIT = NoteAccess.EDIT.wire
/**
* The Share sheet: who the note is shared with, and adding someone or a group.
@@ -75,23 +78,17 @@ fun ShareSheet(
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)
}
Sheet(
title = R.string.share_title,
onDismiss = onDismiss,
modifier = Modifier.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
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)
}
}
}
@@ -222,11 +219,7 @@ private fun TargetName(
Column(modifier = modifier) {
Text(text = title, style = MaterialTheme.typography.bodyLarge)
detail?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Hint(it)
}
}
}
@@ -4,7 +4,6 @@ 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
@@ -21,7 +20,7 @@ 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. */
/** The groups the admin made; 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. */
@@ -63,7 +62,7 @@ private fun targetOf(
): ShareTarget = group?.let { ShareTarget.Group(it.id) } ?: ShareTarget.Member(member?.id.orEmpty())
/**
* Sharing a note with other people on the linked server (#5175).
* Sharing a note with other people on the linked server.
*
* 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
@@ -99,7 +98,7 @@ class ShareViewModel(
)
}
} catch (e: CoreException) {
state.copy(loading = false, loadError = e.describeShareFailure())
state.copy(loading = false, loadError = e.shownAs())
}
}
}
@@ -126,22 +125,8 @@ class ShareViewModel(
onStoreChanged()
state.copy(busy = false, shares = shares)
} catch (e: CoreException) {
state.copy(busy = false, error = e.describeShareFailure())
state.copy(busy = false, error = e.shownAs())
}
}
}
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."
@@ -201,11 +201,7 @@ private fun SignInFields(
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
)
Text(
text = stringResource(R.string.sync_device_name_help),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Hint(stringResource(R.string.sync_device_name_help))
} else {
PlainTextField(
value = token,
@@ -218,11 +214,7 @@ private fun SignInFields(
imeAction = ImeAction.Done,
),
)
Text(
text = stringResource(R.string.sync_token_help),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Hint(stringResource(R.string.sync_token_help))
}
}
@@ -260,11 +252,7 @@ private fun AddressSection(
Text(stringResource(R.string.sync_check))
}
}
Text(
text = stringResource(R.string.sync_address_help),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Hint(stringResource(R.string.sync_address_help))
}
/** Everything the probe produced: progress, failure, what answered, and the risk. */
@@ -311,11 +299,7 @@ private fun ProbeCard(
fontWeight = FontWeight.SemiBold,
)
version?.let {
Text(
text = stringResource(R.string.sync_server_version, it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Hint(stringResource(R.string.sync_server_version, it))
}
Text(
text = describeCompatibility(compatibility),
@@ -9,14 +9,9 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
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
@@ -77,14 +72,7 @@ fun SyncScreen(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.sync_title)) },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.editor_back),
)
}
},
navigationIcon = { BackButton(onClose, R.string.editor_back) },
)
},
) { padding ->
@@ -130,12 +118,11 @@ fun SyncScreen(
/**
* The build, dim, at the foot of Sync — the same thing the web UI puts at the
* bottom of its rail (#3181).
* bottom of its rail.
*
* 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.
* With no version tags, 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: a blank line looks like a layout bug.
*
* 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
@@ -146,10 +133,8 @@ private fun BuildLine() {
val context = LocalContext.current
val unknown = stringResource(R.string.build_unknown)
val version = remember(context) { context.installedVersionName() ?: unknown }
Text(
Hint(
text = version,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 16.dp),
)
}
@@ -183,11 +168,7 @@ private fun LinkedPanel(
fontWeight = FontWeight.SemiBold,
)
state.linkedAs?.let {
Text(
text = stringResource(R.string.sync_linked_as, it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Hint(stringResource(R.string.sync_linked_as, it))
}
Text(
text =
@@ -200,11 +181,7 @@ private fun LinkedPanel(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (state.pending) {
Text(
text = stringResource(R.string.sync_unsent),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Hint(stringResource(R.string.sync_unsent))
}
}
@@ -225,11 +202,7 @@ private fun LinkedPanel(
state.lastOutcome?.let { outcome ->
if (state.syncError == null) {
Text(
text = syncSummary(outcome),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Hint(syncSummary(outcome))
}
// Rejections are the server refusing a SPECIFIC change. Surfaced, never
// swallowed, because only a person can resolve them.
@@ -269,10 +242,8 @@ private fun LinkedPanel(
onOutcome = onInstallOutcome,
)
Text(
Hint(
text = stringResource(R.string.sync_footer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp),
)
@@ -280,20 +251,14 @@ private fun LinkedPanel(
// Confirmed because it is not obvious what disconnecting does to the notes.
// The copy answers that first — they stay — since the fear it raises is
// "will this delete something?", not "am I sure?".
AlertDialog(
onDismissRequest = { confirmingUnlink = false },
title = { Text(stringResource(R.string.sync_disconnect_title)) },
text = { Text(stringResource(R.string.sync_disconnect_body)) },
confirmButton = {
TextButton(onClick = {
confirmingUnlink = false
onUnlink()
}) { Text(stringResource(R.string.sync_disconnect)) }
},
dismissButton = {
TextButton(onClick = { confirmingUnlink = false }) {
Text(stringResource(R.string.editor_cancel))
}
ConfirmDialog(
title = stringResource(R.string.sync_disconnect_title),
body = stringResource(R.string.sync_disconnect_body),
confirm = stringResource(R.string.sync_disconnect),
onDismiss = { confirmingUnlink = false },
onConfirm = {
confirmingUnlink = false
onUnlink()
},
)
}
@@ -4,7 +4,6 @@ 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.Compatibility
import com.fabledsword.inkwell.core.Inkwell
@@ -25,8 +24,8 @@ enum class LinkMode { PASSWORD, TOKEN }
* A sealed type rather than six loose strings, and not only for the parameter
* count: the two modes genuinely carry different things. `link_with_token` takes
* NO device name — the token was already minted against a named device in the web
* app — so a flat argument list meant collecting one in token mode and silently
* dropping it. Modelling it this way made that impossible to express.
* app — so a flat argument list would collect one in token mode and silently drop
* it.
*/
sealed interface Credentials {
data class Password(
@@ -110,7 +109,7 @@ data class SyncState(
* mid-sync. That is safe by construction rather than by luck: no async path in the
* core holds the store lock across an await, and `last_sync_at` is stamped only
* after both halves of a cycle succeed, so an interrupted sync resumes from the
* stored cursor next time (Scribe #2736).
* stored cursor next time.
*/
class SyncViewModel(
private val core: Inkwell,
@@ -143,7 +142,7 @@ class SyncViewModel(
// A store that won't answer is a real fault, but the screen
// still has to render — showing the unlinked state is honest,
// since without a readable link there is effectively none.
state.copy(loading = false, status = null, syncError = e.describe())
state.copy(loading = false, status = null, syncError = e.shownAs())
}
}
}
@@ -163,7 +162,7 @@ class SyncViewModel(
try {
state.copy(probe = core.probe(url), probing = false)
} catch (e: Exception) {
state.copy(probing = false, probeError = e.describe())
state.copy(probing = false, probeError = e.shownAs())
}
}
}
@@ -217,7 +216,7 @@ class SyncViewModel(
status = withContext(Dispatchers.IO) { core.syncStatus() },
)
} catch (e: Exception) {
state.copy(linking = false, linkError = e.describe())
state.copy(linking = false, linkError = e.shownAs())
}
if (state.linked) syncNow()
}
@@ -259,7 +258,7 @@ class SyncViewModel(
} catch (e: Exception) {
state.copy(
syncing = false,
syncError = if (announce) e.describe() else state.syncError,
syncError = if (announce) e.shownAs() else state.syncError,
)
}
// Only when something actually arrived: a no-op sync must not make the
@@ -293,7 +292,7 @@ class SyncViewModel(
pending = false,
)
} catch (e: Exception) {
state.copy(syncing = false, syncError = e.describe())
state.copy(syncing = false, syncError = e.shownAs())
}
}
}
@@ -313,17 +312,6 @@ class SyncViewModel(
fun dismissSyncError() {
state = state.copy(syncError = null)
}
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 = SyncViewModel(core, onStoreChanged) as T
}
}
}
/**
@@ -339,12 +327,3 @@ private fun SyncOutcome.changedTheStore(): Boolean =
pull.labelsApplied > 0uL ||
pull.labelsDeleted > 0uL ||
pull.blobsDownloaded > 0uL
/**
* The message to show for a failure.
*
* The core writes these for people to read — "notes.example.com responded, but not
* with Inkwell's configuration" — so they are shown as-is rather than
* replaced with a generic string that would throw away the only useful part.
*/
private fun Exception.describe(): String = message ?: "Something went wrong."
@@ -0,0 +1,98 @@
package com.fabledsword.inkwell.ui
/**
* Suggesting existing tags while a `#tag` is typed, so a note is filed under
* `#groceries` rather than a new `#grocery` beside it.
*
* The same rules as the web's `frontend/src/notes/tags.ts` (`tagQueryAt`,
* `suggestTags`, `insertTag`), and the same body-tag grammar as `line_tags` in
* core/src/local/derive.rs: a `#` at the start or after whitespace, a letter, then
* letters, digits, `_` and `-`.
*/
private val TAG_CHAR = Regex("[\\p{IsAlphabetic}\\p{N}_-]")
private val WHOLE_TAG = Regex("^\\p{IsAlphabetic}[\\p{IsAlphabetic}\\p{N}_-]*$")
private fun Char.isTagChar() = TAG_CHAR.matches(toString())
/**
* The `#tag` the caret is in the middle of typing: where its `#` is, where the token
* ends (it may run on past the caret), and the part typed so far.
*/
data class TagQuery(
val start: Int,
val end: Int,
val query: String,
)
/**
* The tag being typed at [caret], or null when the caret is not in one.
*
* A bare `#` counts, with an empty query: that is the moment a suggestion list is most
* use, because it is the moment someone has not yet decided what to type. Anything
* the grammar would not derive (`a#b`, `#2024`, `##x`) is not a tag here either, so
* the row never offers to finish something the core would ignore.
*/
fun tagQueryAt(
text: String,
caret: Int,
): TagQuery? {
var i = caret
while (i > 0 && text[i - 1].isTagChar()) i--
val hash = i - 1
val query = text.substring(i, caret)
val inTag =
hash >= 0 &&
text[hash] == '#' &&
(hash == 0 || text[hash - 1].isWhitespace()) &&
(query.isEmpty() || Character.isAlphabetic(query[0].code))
if (!inTag) return null
var end = caret
while (end < text.length && text[end].isTagChar()) end++
return TagQuery(start = hash, end = end, query = query)
}
/**
* The existing tags worth offering for [query], best first.
*
* Names that start with what was typed come before names that merely contain it;
* within each, the tag on the most notes first. A tag already typed out in full is
* left out, so there is nothing to offer once `#grocery` is finished. A tag whose
* name the body grammar can't hold (one made in the picker as "to do") is left out
* too: inserting `#to do` would derive `#to`.
*/
fun <T> suggestTags(
tags: List<T>,
query: String,
name: (T) -> String,
count: (T) -> Long,
limit: Int = SUGGESTION_LIMIT,
): List<T> {
val q = foldTag(query)
val prefix = { t: T -> if (foldTag(name(t)).startsWith(q)) 0 else 1 }
return tags
.filter { t ->
val f = foldTag(name(t))
WHOLE_TAG.matches(name(t)) && f != q && f.contains(q)
}.sortedWith(
compareBy<T> { prefix(it) }
.thenByDescending { count(it) }
.thenBy { name(it) },
).take(limit)
}
/**
* [text] with the tag at [at] replaced by `#name`, and where the caret goes after it.
* A space follows unless one is already there, so typing simply continues.
*/
fun insertTag(
text: String,
at: TagQuery,
name: String,
): Pair<String, Int> {
val after = text.substring(at.end)
val insertion = "#$name" + if (after.firstOrNull()?.isWhitespace() == true) "" else " "
return (text.substring(0, at.start) + insertion + after) to (at.start + insertion.length)
}
private const val SUGGESTION_LIMIT = 8
@@ -19,7 +19,6 @@ 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
@@ -40,6 +39,7 @@ 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.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
@@ -82,9 +82,9 @@ private sealed interface TagDialog {
* 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.
* It is also the ONLY colour control in the product, which is why the swatch is a
* first-class tap target on every row rather than something behind the overflow
* menu.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -104,14 +104,7 @@ fun TagsScreen(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.tags_title)) },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.tags_back),
)
}
},
navigationIcon = { BackButton(onClose, R.string.tags_back) },
)
},
) { padding ->
@@ -181,8 +174,8 @@ fun TagsScreen(
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)),
title = stringResource(R.string.tags_rename_merges_title, hashtag(open.into.name)),
body = stringResource(R.string.tags_rename_merges_body, hashtag(open.into.name)),
confirm = stringResource(R.string.tags_rename_merges_confirm),
onDismiss = { dialog = null },
onConfirm = {
@@ -204,14 +197,14 @@ fun TagsScreen(
is TagDialog.Delete ->
ConfirmDialog(
title = stringResource(R.string.tags_delete_title, hash(open.tag.name)),
title = stringResource(R.string.tags_delete_title, hashtag(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) }
?.let { pluralStringResource(R.plurals.tags_delete_body_counted, it.toInt(), it.toInt()) }
?: stringResource(R.string.tags_delete_body),
footnote = stringResource(R.string.tags_delete_from_text),
confirm = stringResource(R.string.tags_delete_confirm),
@@ -235,13 +228,31 @@ fun TagsScreen(
}
/**
* `#` on the name, everywhere it is spoken about.
* `#` on the name, everywhere it is spoken about: the chips on a card and in the
* editor (`NoteCard.kt`, `EditorChrome.kt`) and every dialog here.
*
* 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.
* 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"
internal fun hashtag(name: String): String = "#$name"
/** A tag's colour as a dot: the row's at-a-glance colour, and each choice in the picker. */
@Composable
private fun Swatch(
tint: NoteTint,
dark: Boolean,
modifier: Modifier = Modifier,
) {
Box(
modifier =
Modifier
.size(SWATCH)
.clip(CircleShape)
.background(tint.chipBackground(dark))
.border(1.dp, tint.chipBorder(dark), CircleShape)
.then(modifier),
)
}
@Composable
private fun NewTagField(
@@ -278,9 +289,9 @@ private fun NewTagField(
}
/**
* 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.
* Said out loud rather than left as a blank screen, and it names the `#` route:
* typing `#tag` in a note is the easiest way to make a tag, and easy not to know
* about.
*/
@Composable
private fun EmptyTags() {
@@ -321,19 +332,11 @@ private fun TagRow(
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),
)
Swatch(tint, dark, Modifier.clickable(enabled = enabled, onClick = onColour))
Column(modifier = Modifier.weight(1f)) {
Text(
text = hash(tag.name),
text = hashtag(tag.name),
style = MaterialTheme.typography.bodyLarge,
color = tint.tagInk(dark),
)
@@ -376,8 +379,7 @@ 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())
else -> pluralStringResource(R.plurals.tags_count, count.toInt(), count.toInt())
}
/**
@@ -399,7 +401,7 @@ private fun RenameDialog(
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) }
others.firstOrNull { it.id != tag.id && foldTag(it.name) == foldTag(trimmed) }
val submit = {
when {
trimmed.isEmpty() -> Unit
@@ -410,7 +412,7 @@ private fun RenameDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.tags_rename_title, hash(tag.name))) },
title = { Text(stringResource(R.string.tags_rename_title, hashtag(tag.name))) },
text = {
PlainTextField(
value = text,
@@ -427,7 +429,7 @@ private fun RenameDialog(
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
},
)
}
@@ -452,11 +454,11 @@ private fun MergeDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.tags_merge_title, hash(tag.name))) },
title = { Text(stringResource(R.string.tags_merge_title, hashtag(tag.name))) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(R.string.tags_merge_body, hash(tag.name)),
text = stringResource(R.string.tags_merge_body, hashtag(tag.name)),
style = MaterialTheme.typography.bodyMedium,
)
if (others.isEmpty()) {
@@ -469,7 +471,7 @@ private fun MergeDialog(
others.forEach { other ->
val tint = labelTintFor(other.name, other.color)
Text(
text = hash(other.name),
text = hashtag(other.name),
style = MaterialTheme.typography.bodyLarge,
color = tint.tagInk(dark),
modifier =
@@ -483,14 +485,13 @@ private fun MergeDialog(
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
},
)
}
/**
* The palette, and since the per-note picker was removed this is the only place in
* the product a colour is chosen.
* The palette: 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
@@ -507,7 +508,7 @@ private fun ColourDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.tags_colour_of, hash(tag.name))) },
title = { Text(stringResource(R.string.tags_colour_of, hashtag(tag.name))) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
NOTE_TINTS.forEach { (key, tint) ->
@@ -520,16 +521,9 @@ private fun ColourDialog(
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),
)
Swatch(tint, dark)
Text(
text = tint.label,
text = stringResource(tint.label),
style = MaterialTheme.typography.bodyMedium,
color =
if (key == tag.color) {
@@ -544,41 +538,7 @@ private fun ColourDialog(
},
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)) }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
},
)
}
@@ -4,7 +4,6 @@ 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
@@ -45,7 +44,7 @@ data class TagsState(
* ## 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
* already holds, and the OLDER row survives. 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.
@@ -86,7 +85,7 @@ class TagsViewModel(
// 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()) },
onFailure = { state.copy(loading = false, error = it.shownAs()) },
)
}
}
@@ -154,28 +153,11 @@ class TagsViewModel(
state.copy(
tags = tags,
busy = false,
error = failure?.describeTagFailure(),
error = failure?.shownAs(),
)
// 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."
@@ -61,8 +61,8 @@ private val DarkColors =
/**
* Material 3 in Inkwell's own colours, following the system light/dark setting.
*
* DELIBERATELY NOT Material You dynamic colour, which this used until the operator
* saw the first build. Dynamic colour is the more Android-native choice and it
* DELIBERATELY NOT Material You dynamic colour. Dynamic colour is the more
* Android-native choice and it
* makes the app look like a different product on the phone than on the desktop and
* the web — on a stock device with no wallpaper it renders as undifferentiated
* grey. The three surfaces are peers held to one quality bar, so they share one
@@ -1,8 +1,5 @@
package com.fabledsword.inkwell.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -10,7 +7,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
@@ -19,9 +15,11 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -50,6 +48,13 @@ fun UpdateCard(
) {
val context = LocalContext.current
// Re-read on every return to the app, as ReminderNotice does: the grant is given
// in a system screen this app cannot observe. Read once, the warning would still
// be up after someone granted it and came back, and the offer below it would
// look blocked when it is not.
var canInstall by remember { mutableStateOf(AppUpdate.canInstall(context)) }
ForegroundTransitions(onForeground = { canInstall = AppUpdate.canInstall(context) })
// The system answers an install through a BroadcastReceiver, which has no way
// back into a view model. This is the seam.
UpdateOutcome.latest?.let { result ->
@@ -57,11 +62,12 @@ fun UpdateCard(
}
Column(modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) {
Text(
text = stringResource(R.string.update_installed_version, state.installedVersion),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Hint(stringResource(R.string.update_installed_version, state.installedVersion))
if (!state.selfUpdates) {
Hint(stringResource(R.string.update_debug_build))
return@Column
}
val available = state.available
if (available != null) {
@@ -75,11 +81,7 @@ fun UpdateCard(
style = MaterialTheme.typography.bodyMedium,
)
} else if (state.upToDate) {
Text(
text = stringResource(R.string.update_current),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Hint(stringResource(R.string.update_current))
}
if (state.working) {
@@ -101,7 +103,7 @@ fun UpdateCard(
// Android's "install unknown apps" grant is separate from anything in the
// manifest and only the person can give it. Said BEFORE a download rather
// than after, so nobody spends 55 MiB to be told no.
if (available != null && !AppUpdate.canInstall(context)) {
if (available != null && !canInstall) {
Notice(
tone = Tone.WARN,
title = stringResource(R.string.update_permission_title),
@@ -123,11 +125,8 @@ fun UpdateCard(
}
/**
* The nag: an update is waiting, said where someone will actually see it.
*
* Until this existed the only way to learn about a new build was to open the sync
* screen and press Check — so the updates that got installed were the ones somebody
* went looking for, and the rest were simply never found.
* The nag: an update is waiting, said where someone will actually see it rather than
* only on the sync screen.
*
* Only ever shown once the build is DOWNLOADED, so the offer is a single tap rather
* than the start of a wait — and so nothing is said at all until the app has been on
@@ -144,19 +143,7 @@ fun UpdateBanner(
onInstall: () -> Unit,
onDismiss: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = noteTint("blue")
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp)
.clip(RoundedCornerShape(BANNER_RADIUS))
.background(tint.background(dark))
.border(1.dp, tint.border(dark), RoundedCornerShape(BANNER_RADIUS))
.padding(start = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Banner("blue") {
Text(
text = stringResource(R.string.update_banner_ready, version),
style = MaterialTheme.typography.bodyMedium,
@@ -172,7 +159,6 @@ fun UpdateBanner(
}
}
private val BANNER_RADIUS = 12.dp
private val BANNER_SPINNER = 18.dp
/**
@@ -184,12 +170,9 @@ private val BANNER_SPINNER = 18.dp
*/
@Composable
fun UnlinkedUpdateNote() {
Text(
Hint(
text = stringResource(R.string.update_needs_server),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
// Carries the bottom breathing room the connect button used to provide,
// now that it is the last thing on the unlinked screen.
// The bottom breathing room: this is the last thing on the unlinked screen.
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp),
)
}
@@ -5,7 +5,6 @@ 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.AppUpdate
import com.fabledsword.inkwell.UpdateOutcome
@@ -31,6 +30,8 @@ data class UpdateState(
val error: String? = null,
/** The banner has been waved away — until the app next comes forward. */
val nagDismissed: Boolean = false,
/** False on a debug build, which can never install a release update over itself. */
val selfUpdates: Boolean = true,
) {
val busy: Boolean get() = checking || downloading || working
@@ -54,7 +55,7 @@ data class UpdateState(
* **Linked-only, and said out loud.** The app is local-first and completely usable
* having never touched a server, so an unlinked install has no update path at all.
* The card says that rather than offering a Check button that silently finds
* nothing — the same lesson as the desktop's unlink copy (issue 2110).
* nothing.
*
* The core does the network work, not this class: the device token lives in the
* Rust store and pulling it into Kotlin to make an HTTP call would spread the one
@@ -68,7 +69,12 @@ class UpdateViewModel(
*/
private val context: Context,
) : ViewModel() {
var state by mutableStateOf(UpdateState(installedVersion = AppUpdate.installedVersionCode(context)))
var state by mutableStateOf(
UpdateState(
installedVersion = AppUpdate.installedVersionCode(context),
selfUpdates = AppUpdate.selfUpdates(context),
),
)
private set
/** When the last check ran, so coming back to the app twice in a minute is one. */
@@ -80,9 +86,8 @@ class UpdateViewModel(
/**
* The automatic path: look, fetch, then nag.
*
* Called when the app comes forward. Until this existed an update was only ever
* found by someone opening the sync screen and pressing a button — so the ones
* that mattered were the ones nobody went looking for.
* Called when the app comes forward, so an update is found without anyone opening
* the sync screen to look.
*
* Skipped when a check is already in flight, when a build is already waiting, and
* when one ran recently: flicking between two apps is not a request to re-check.
@@ -90,12 +95,14 @@ class UpdateViewModel(
fun checkInBackground() {
val now = System.currentTimeMillis()
when {
// Nothing a debug build could do with what it found.
!state.selfUpdates -> Unit
state.busy -> Unit
// Already fetched and waved away — say so again. "Later" is for that
// sitting, not forever, and without this branch a single dismissal would
// silence the update permanently. Which is precisely the "lost" this whole
// path exists to prevent.
// silence the update permanently.
state.ready -> if (state.nagDismissed) state = state.copy(nagDismissed = false)
// Found one and never fetched it — almost always because the last look
@@ -133,7 +140,7 @@ class UpdateViewModel(
// Broad by intent, as everywhere the core is called: it reports
// every failure as one error type carrying a message written to
// be read, and a failed check must not take the screen down.
state.copy(checking = false, error = e.message ?: FALLBACK)
state.copy(checking = false, error = e.shownAs(FALLBACK))
}
// Fetched before anything is said, so the banner is a one-tap install
// rather than the start of a wait. Off wifi this simply does not happen
@@ -152,7 +159,7 @@ class UpdateViewModel(
core.downloadClientUpdate(AppUpdate.downloadTarget(context).absolutePath)
state.copy(downloading = false, ready = true)
} catch (e: Exception) {
state.copy(downloading = false, error = e.message ?: FALLBACK_DOWNLOAD)
state.copy(downloading = false, error = e.shownAs(FALLBACK_DOWNLOAD))
}
}
@@ -180,7 +187,7 @@ class UpdateViewModel(
// Off the main thread: this streams ~55 MiB into the session.
withContext(Dispatchers.IO) { AppUpdate.install(context, target) }
} catch (e: Exception) {
e.message ?: FALLBACK
e.shownAs(FALLBACK)
}
// `working` stays TRUE on success: the install is still in flight, and
// on a silent update this process is about to be replaced. Clearing it
@@ -204,18 +211,6 @@ class UpdateViewModel(
fun dismissError() {
state = state.copy(error = null)
}
companion object {
fun factory(
core: Inkwell,
context: Context,
): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
UpdateViewModel(core, context.applicationContext) as T
}
}
}
private const val FALLBACK = "The update couldn't be checked."
+45 -10
View File
@@ -7,12 +7,27 @@
<string name="search_clear">Clear search</string>
<string name="nav_open">Open navigation</string>
<string name="nav_labels">Tags</string>
<string name="nav_notes">Notes</string>
<string name="nav_reminders">Reminders</string>
<string name="nav_archive">Archive</string>
<string name="nav_trash">Trash</string>
<string name="tint_default">Default</string>
<string name="tint_red">Red</string>
<string name="tint_orange">Orange</string>
<string name="tint_yellow">Yellow</string>
<string name="tint_green">Green</string>
<string name="tint_teal">Teal</string>
<string name="tint_blue">Blue</string>
<string name="tint_purple">Purple</string>
<string name="tint_pink">Pink</string>
<string name="tint_gray">Gray</string>
<!-- Compose sheet -->
<string name="compose_open">New note</string>
<!-- Board -->
<string name="board_empty_note">Empty note</string>
<string name="card_archived">Archived</string>
<!-- The long-press menu. Its ITEMS are the editor_* strings, deliberately: a
note has one vocabulary of things you can do to it, and a board that said
@@ -28,6 +43,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="empty_search_title">No matches</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_body">Deleted notes wait here before they are removed for good.</string>
<string name="empty_archive_title">Nothing archived</string>
@@ -56,10 +80,10 @@
<string name="editor_unarchive">Unarchive</string>
<string name="editor_trash">Move to trash</string>
<string name="editor_restore">Restore</string>
<string name="editor_cancel">Cancel</string>
<string name="cancel">Cancel</string>
<string name="editor_share">Share…</string>
<!-- Sharing (#5175). Shares live on the server, so the sheet says so when this
<!-- Sharing. 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>
@@ -93,6 +117,10 @@
<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="attach_view">Open full size</string>
<string name="attach_copy">Copy image</string>
<string name="attach_copied">Image copied</string>
<string name="attach_copy_failed">Couldn\'t copy the image: %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
@@ -107,15 +135,19 @@
who it happens in. -->
<string name="capture_process_text">New note</string>
<!-- Tag management. The whole vocabulary is "tag" (see Scribe #2966); the
<!-- Tag management. The whole vocabulary is "tag"; the
schema still says Label, and no string here needs to know that. -->
<string name="tags_manage">Manage tags</string>
<string name="tags_show_all">All tags (%1$d)</string>
<string name="tags_show_fewer">Fewer 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>
<plurals name="tags_count">
<item quantity="one">%d note</item>
<item quantity="other">%d notes</item>
</plurals>
<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>
@@ -126,9 +158,9 @@
<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. -->
<!-- Renaming onto an existing tag merges the two, older survives. A merge
cannot be undone by repeating it and is reachable here by a typo, so it
says so before it happens. -->
<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>
@@ -143,14 +175,16 @@
<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>
<plurals name="tags_delete_body_counted">
<item quantity="one">It is on %d note. It will be removed from it, on every device you sync with. The note itself is kept.</item>
<item quantity="other">It is on %d notes. It will be removed from all of them, on every device you sync with. The notes themselves are kept.</item>
</plurals>
<string name="tags_delete_confirm">Delete</string>
<!-- A tag written as #tag in a note's body is owned by that text. Deleting the
row cannot un-write the word, so it comes back on that note's next edit —
said here rather than left as a surprise. -->
<string name="tags_delete_from_text">Tags written as #tag in a note come back when that note is next edited.</string>
<string name="tags_cancel">Cancel</string>
<!-- Pickers -->
<string name="label_picker_title">Tags</string>
@@ -219,6 +253,7 @@
<string name="update_permission_title">Android needs your permission</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_debug_build">This is a debug build, so it can\'t update itself. Install new builds by hand.</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_failed_title">Sync failed</string>
@@ -0,0 +1,38 @@
package com.fabledsword.inkwell
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertThrows
import org.junit.Test
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
class SealedBoxTest {
private fun aesKey(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
@Test
fun `a sealed token opens with the key that sealed it`() {
val key = aesKey()
val sealed = SealedBox.seal(key, "tok-1")
assertNotEquals("tok-1", sealed)
assertEquals("tok-1", SealedBox.open(key, sealed))
}
@Test
fun `sealing twice gives two different values`() {
// A fresh IV each time, so equal tokens can't be spotted in the file.
val key = aesKey()
assertNotEquals(SealedBox.seal(key, "tok-1"), SealedBox.seal(key, "tok-1"))
}
@Test
fun `another key can't open it, as on a phone the backup was restored to`() {
val sealed = SealedBox.seal(aesKey(), "tok-1")
assertThrows(Exception::class.java) { SealedBox.open(aesKey(), sealed) }
}
@Test
fun `a value that was never sealed is refused`() {
assertThrows(Exception::class.java) { SealedBox.open(aesKey(), "dG9vIHNob3J0") }
}
}
@@ -0,0 +1,62 @@
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 with nothing typed there is no text to search.
assertNull(facets.q)
assertNull(facets.createdAfter)
assertNull(facets.createdBefore)
}
@Test
fun `the search text and the tags go to the core together, so it can AND them`() {
val facets = requireNotNull(BoardFilters(labelIds = setOf("grocery")).facets("milk"))
assertEquals("milk", facets.q)
assertEquals(listOf("grocery"), facets.label)
}
@Test
fun `search text alone is enough to narrow, with no filters on`() {
val facets = requireNotNull(BoardFilters().facets(" milk "))
// Trimmed, as the web trims the box before it writes ?q=.
assertEquals("milk", facets.q)
assertNull(facets.label)
}
@Test
fun `a box holding only spaces narrows nothing`() {
assertNull(BoardFilters().facets(" "))
}
@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"))
}
}
@@ -12,11 +12,8 @@ import org.junit.Test
* writes them out by hand, so changing the hash or the key order means changing the
* fixture and this file together, or one of the two suites goes red.
*
* SMALLER SINCE M315. Half of what this file used to pin — the generated card fill, and
* the resolution order that chose between a picked colour, a tag's and a generated one
* — went with the code it guarded when the card became one neutral. The four UUID
* hashes stay because they are what the hash ITSELF is pinned by; nothing derives a
* colour from an id any more, only from a tag's name.
* The four UUID hashes pin the hash ITSELF; colours are derived only from a tag's
* name.
*/
class DerivedTintTest {
@Test
@@ -0,0 +1,33 @@
package com.fabledsword.inkwell.ui
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Pins [loneUrl] against the `urls` cases in `core/testdata/grammar.json`, written out
* by hand as [DerivedTintTest] does the tint: these JVM tests do not read the fixture.
* The web runs the same cases in `notes/grammar.test.ts`, the server in
* `tests/test_grammar_fixture.py`, so a change to the rule changes the fixture and
* this file together, or a suite goes red.
*/
class LoneUrlTest {
@Test
fun `lone links match the fixture shared with the web and the server`() {
val cases =
listOf(
"https://example.com/a" to "https://example.com/a",
" https://example.com/a \n" to "https://example.com/a",
"https://example.com/a." to "https://example.com/a",
"https://example.com/a?q=1!" to "https://example.com/a?q=1",
"see https://example.com/a, then https://example.com/b." to null,
"<https://example.com/a>" to null,
"https://example.com/a)" to null,
"https://example.com/a https://example.com/a" to null,
"ftp://example.com/a" to null,
"" to null,
)
for ((body, lone) in cases) {
assertEquals("body [$body]", lone, loneUrl(body))
}
}
}
@@ -0,0 +1,42 @@
package com.fabledsword.inkwell.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Test
/** The same cases as `frontend/src/notes/recentTags.test.ts`. */
class RecentTagsTest {
private data class Tag(
val name: String,
val at: Long,
)
private fun recent(tags: List<Tag>) = recentTags(tags, { it.at }, { it.name }).map { it.name }
@Test
fun `shows every tag while there are no more than five`() {
val tags = listOf(Tag("c", 0), Tag("a", 0), Tag("b", 0))
assertSame(tags, recentTags(tags, { it.at }, { it.name }))
}
@Test
fun `keeps the five used most recently, listed by name`() {
val tags = listOf("a", "b", "c", "d", "e", "f", "g").mapIndexed { i, n -> Tag(n, i.toLong()) }
assertEquals(listOf("c", "d", "e", "f", "g"), recent(tags))
}
@Test
fun `fills with tags never used, by name, when fewer than five have been`() {
val tags = listOf(Tag("f", 10), Tag("e", 0), Tag("d", 0), Tag("c", 0), Tag("B", 0), Tag("a", 0))
assertEquals(listOf("a", "B", "c", "d", "f"), recent(tags))
}
@Test
fun `the shared epochMillis reads both stamp forms tag recency is built from`() {
// The core writes `Z` with milliseconds; a pulled server row may carry +00:00.
assertEquals(1_760_000_000_000L, epochMillis("2025-10-09T08:53:20.000Z"))
assertEquals(1_760_000_000_000L, epochMillis("2025-10-09T08:53:20+00:00"))
assertNull(epochMillis("not a time"))
}
}
@@ -0,0 +1,79 @@
package com.fabledsword.inkwell.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Test
/** The same cases as `frontend/src/notes/tags.test.ts`. */
class TagSuggestTest {
private data class Tag(
val name: String,
val count: Long,
)
private val tags =
listOf(
Tag("grocery", 3),
Tag("groceries", 9),
Tag("hungry", 1),
Tag("to do", 50),
Tag("Café", 2),
)
private fun suggest(query: String) = suggestTags(tags, query, { it.name }, { it.count }).map { it.name }
@Test
fun `the tag being typed is found at the caret, with what has been typed so far`() {
assertEquals(TagQuery(start = 9, end = 13, query = "gro"), tagQueryAt("buy milk #gro", 13))
}
@Test
fun `a bare hash counts at the start or after a space`() {
assertEquals(TagQuery(0, 1, ""), tagQueryAt("#", 1))
assertEquals(TagQuery(5, 6, ""), tagQueryAt("milk #", 6))
}
@Test
fun `the tag runs on past the caret to the end of the word`() {
assertEquals(TagQuery(0, 8, "gr"), tagQueryAt("#grocery list", 3))
}
@Test
fun `it is not a tag where the grammar says it is not`() {
assertNull(tagQueryAt("a#b", 3)) // no space before the #
assertNull(tagQueryAt("#2024", 5)) // starts with a digit
assertNull(tagQueryAt("##x", 3))
assertNull(tagQueryAt("# heading", 9)) // a space ends it
assertNull(tagQueryAt("plain words", 5))
}
@Test
fun `names that start with the query come first, busiest first`() {
assertEquals(listOf("groceries", "grocery", "hungry"), suggest("gr"))
}
@Test
fun `matching ignores case`() {
assertEquals(listOf("Café"), suggest("CAF"))
}
@Test
fun `a tag already typed in full, and names the body can't hold, are left out`() {
assertEquals(emptyList<String>(), suggest("grocery"))
assertFalse("to do" in suggest(""))
}
@Test
fun `a picked tag replaces the whole token and leaves the caret after a space`() {
val text = "buy #gr milk"
val at = tagQueryAt(text, 7)!!
assertEquals("buy #groceries milk" to 14, insertTag(text, at, "groceries"))
}
@Test
fun `a picked tag at the end of the text gets a space after it`() {
val at = tagQueryAt("#gr", 3)!!
assertEquals("#grocery " to 9, insertTag("#gr", at, "grocery"))
}
}
+7 -10
View File
@@ -3,20 +3,17 @@ name = "inkwell-uniffi-bindgen"
version = "0.1.0"
description = "Generates the Kotlin bindings for inkwell-ffi"
authors = ["bvandeusen"]
license = "AGPL-3.0-only"
edition = "2021"
# A crate whose ONLY dependency is uniffi itself.
#
# This started life as a `[[bin]]` inside inkwell-ffi, which failed: building
# it compiled that crate and therefore the core, reqwest, native-tls and
# openssl-sys — for the HOST. The vendored-OpenSSL block in core/Cargo.toml is
# scoped to `cfg(target_os = "android")`, so a host build looks for a system
# OpenSSL that ci-rust-android has no reason to carry, and the generator died
# with "failed to run custom build command for openssl-sys".
#
# Adding libssl-dev to the image would have worked and been wrong: a code
# generator should not link the app's TLS stack to emit Kotlin. Splitting it out
# means the generator compiles ~15 small crates and nothing else.
# Not a `[[bin]]` inside inkwell-ffi: building that compiles the core, reqwest,
# native-tls and openssl-sys for the HOST, and the vendored-OpenSSL block in
# core/Cargo.toml is scoped to `cfg(target_os = "android")`, so a host build
# looks for a system OpenSSL that ci-rust-android doesn't carry. A code
# generator should not link the app's TLS stack to emit Kotlin; on its own it
# compiles ~15 small crates.
#
# Still a WORKSPACE MEMBER, deliberately. That is what keeps `uniffi` here and
# `uniffi` linked into the .so on one version from one lockfile — they are two

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