16 Commits
Author SHA1 Message Date
bvandeusen 2bcbea074e Merge pull request 'AGPL-3.0-only, a source link in the app, and everything on dev since #5' (#6) from dev into main
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
Android / Core and FFI clippy and tests (push) Successful in 49s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m6s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 13s
CI & Build / Python tests (push) Successful in 20s
CI & Build / integration (push) Successful in 1m55s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m12s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m0s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m49s
Android / Build the server image (push) Successful in 1s
2026-10-21 21:39:59 -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
bvandeusen b6673c6420 The guard fix, and the APK and image #4 never published (#5)
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python tests (push) Successful in 12s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m43s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m11s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / integration (push) Successful in 17s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m25s
2026-08-29 14:19:50 -04:00
bvandeusen 6c5a45e195 Checklists in the body, colour from tags, and commit-derived CalVer (#4)
CI & Build / Python lint (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
Android / Build, or is the channel already serving this? (push) Failing after 3s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m18s
Desktop (Tauri) / Update manifest (push) Successful in 4s
2026-08-29 13:39:45 -04:00
bvandeusen 1b3e29e4f6 0.2.0 — a notebook in your pocket, ready to be hosted (#3)
Android / Kotlin + Rust (APK) (push) Successful in 7m45s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Successful in 15s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m18s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m18s
Desktop (Tauri) / Update manifest (push) Successful in 5s
2026-08-23 16:38:00 -04:00
bvandeusen 867405fae2 M12 — the Android client, end to end (#2)
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m47s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m8s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m13s
CI & Build / Python tests (push) Successful in 11s
86 commits from dev. Native Kotlin/Compose Android client over the shared Rust
core, the server-served distribution path, signing, and self-update.

Known and recorded rather than fixed: #2810 (release APK carries a debug-profile
.so), allowBackup still true now that the store holds a device token, and
cleartext HTTP enabled app-wide for self-hosted LAN servers.
2026-08-21 08:53:57 -04:00
281 changed files with 3517 additions and 4244 deletions
+16 -31
View File
@@ -9,44 +9,30 @@
# --- required --------------------------------------------------------------- # --- required ---------------------------------------------------------------
# Generate one and keep it: changing it later means also changing it inside the # Generate one and keep it: changing it later means changing it inside the database
# database, or Postgres will reject the app's connection. # too. Letters and digits only — it goes into a URL, where `@ / : #` break it.
# #
# openssl rand -base64 24 | tr -d '/+=' | head -c 32 # 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= POSTGRES_PASSWORD=
# --- optional --------------------------------------------------------------- # --- optional ---------------------------------------------------------------
# Which build to run. # 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.
# 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.
#INKWELL_TAG=latest #INKWELL_TAG=latest
# The host port the app is published on. # The host port the app is published on.
#INKWELL_PORT=5000 #INKWELL_PORT=5000
# Which interface to bind. The default (all interfaces) is what lets desktop # Which interface to bind. Behind a reverse proxy, set 127.0.0.1 so only the proxy
# clients on your network reach the server. Behind a reverse proxy, set this to # can reach the app.
# 127.0.0.1 so only the proxy can talk to it.
#INKWELL_BIND=0.0.0.0 #INKWELL_BIND=0.0.0.0
# NOTE: how many proxies sit in front of this app is a SETTING, not an env var — # How many proxies sit in front is a setting, not an env var: Settings → Security →
# Settings → Security → "Trusted proxy hops" in the admin UI. It defaults to 1 (one # "Trusted proxy hops" (default 1).
# 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 much the app says. Credential events (sign-ins, failures, throttles, new # How much the app logs. Credential events (sign-ins, failures, throttles, device
# accounts, device tokens issued) are logged at INFO and read with # tokens issued) log at INFO; read them with `docker compose logs app`.
# `docker compose logs app`.
#INKWELL_LOG_LEVEL=INFO #INKWELL_LOG_LEVEL=INFO
# Database identity. Changing these AFTER the first start does not rename anything # Database identity. Changing these AFTER the first start does not rename anything
@@ -70,14 +56,13 @@ POSTGRES_PASSWORD=
#POSTGRES_USER=thoughtsync #POSTGRES_USER=thoughtsync
#POSTGRES_DB=thoughtsync #POSTGRES_DB=thoughtsync
# #
# Rename any THOUGHTSYNC_* lines already in your .env to INKWELL_* — the old # Rename any THOUGHTSYNC_* lines already in your .env to INKWELL_*; the old names
# names are no longer read. # aren't read.
# --- a note on HTTPS -------------------------------------------------------- # --- a note on HTTPS --------------------------------------------------------
# #
# The app marks its session cookie Secure automatically when a request arrives over # The session cookie is marked Secure automatically when a request arrives over
# HTTPS, directly or via a proxy setting X-Forwarded-Proto — no setting needed. # 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 # The desktop and Android apps read a bare hostname as https://, so a server on
# https://, deliberately, so a device token never crosses the wire in cleartext by # plain HTTP has to be typed with its `http://`.
# accident. Serving over plain HTTP means typing the `http://` yourself.
+34 -44
View File
@@ -1,11 +1,7 @@
name: Android name: Android
# The native Kotlin/Compose client over the shared Rust core (M12). # 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.
# 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.
# #
# CI can only prove this BUILDS. A Linux runner cannot execute an APK, so anything # CI can only prove this BUILDS. A Linux runner cannot execute an APK, so anything
# about feel, touch or on-device correctness is an operator pass on an emulator or # 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 # 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 # 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 # means uninstalling first — which deletes the app's database and every local note
# with it (Scribe issue 2803). # with it.
on: on:
push: push:
# NO `paths:` FILTER — the `decide` job below reads the real file set instead. # 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] branches: [dev, main]
workflow_dispatch: workflow_dispatch:
@@ -32,15 +28,15 @@ env:
# Silences the JDK 22+ "restricted method in java.lang.System has been called" # 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 # 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 # 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" JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
jobs: jobs:
# Does the APK need rebuilding, or is the channel already serving this source? # 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 # See the equivalent job in desktop.yml — same reasoning, the same one file set in
# hand-kept `paths:` filter with the one file set in `packaging/version.sh`. # `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 server-image job dispatches # NOTE THE COUPLING WITH ci.yml: when this lane builds, its server-image job dispatches
# ci.yml so the image bakes in the APK just published. When it SKIPS, no dispatch # ci.yml so the image bakes in the APK just published. When it SKIPS, no dispatch
@@ -74,12 +70,12 @@ jobs:
# The core ships inside the APK (through android/ffi), so its checks have to gate # 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 # the APK HERE, in this workflow's graph. desktop.yml runs them too, but a red run
# there cannot stop this lane publishing (rule 177, #5237). # there cannot stop this lane publishing.
# #
# On ci-tauri, not ci-rust-android: these are host-target tests, and on Linux the # 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 # core's native-tls is OpenSSL, whose headers only ci-tauri carries. The Android
# image has OpenSSL vendored for the Android targets alone (run 8663 failed at # image has OpenSSL vendored for the Android targets alone, so `openssl-sys` fails
# `openssl-sys` trying this there). Same Rust pin, same Cargo.lock. # there. Same Rust pin, same Cargo.lock.
rust: rust:
name: Core and FFI clippy and tests name: Core and FFI clippy and tests
needs: [decide] needs: [decide]
@@ -100,11 +96,11 @@ jobs:
name: Kotlin + Rust (APK) name: Kotlin + Rust (APK)
needs: [decide, rust] needs: [decide, rust]
if: needs.decide.outputs.build == 'true' if: needs.decide.outputs.build == 'true'
# runs-on is only a scheduling label (Label Model B). flutter-ci is the # runs-on is only a scheduling label. flutter-ci is the
# proven-working label that can pull our container images. # proven-working label that can pull our container images.
runs-on: flutter-ci runs-on: flutter-ci
container: 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. # Android ABIs + cargo-ndk + SDK/NDK + JDK 25 + ktlint + detekt.
image: git.fabledsword.com/bvandeusen/ci-rust-android:1.97 image: git.fabledsword.com/bvandeusen/ci-rust-android:1.97
@@ -119,7 +115,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
# Derives a version, so it needs the whole history — see the note in # 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 fetch-depth: 0
- name: Cache Gradle and Cargo - name: Cache Gradle and Cargo
@@ -143,7 +139,7 @@ jobs:
env: env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: | 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 # 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 # build of one commit read identically. The CODE answers "may this be
# installed over that?" and must be monotonic BY CONSTRUCTION, because # installed over that?" and must be monotonic BY CONSTRUCTION, because
@@ -161,7 +157,7 @@ jobs:
echo "label=release" >> $GITHUB_OUTPUT echo "label=release" >> $GITHUB_OUTPUT
# An optimised .so for the APK people install. The cargoNdk task keeps # An optimised .so for the APK people install. The cargoNdk task keeps
# the symbols uniffi reads its metadata from, which the release # the symbols uniffi reads its metadata from, which the release
# profile would strip (run 4077, Scribe #2810). # profile would strip.
echo "profile=release" >> $GITHUB_OUTPUT echo "profile=release" >> $GITHUB_OUTPUT
echo "keystore=/tmp/inkwell-release.jks" >> $GITHUB_OUTPUT echo "keystore=/tmp/inkwell-release.jks" >> $GITHUB_OUTPUT
echo "apk=android/app/build/outputs/apk/release/app-release.apk" >> $GITHUB_OUTPUT echo "apk=android/app/build/outputs/apk/release/app-release.apk" >> $GITHUB_OUTPUT
@@ -190,7 +186,7 @@ jobs:
run: ./gradlew generateUniffiBindings -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }} run: ./gradlew generateUniffiBindings -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }}
# The image's PINNED CLIs, not Gradle plugins. ci-rust-android carries both # The image's PINNED CLIs, not Gradle plugins. ci-rust-android carries both
# (M12 step 3) precisely so this lane needs no second image, and going # precisely so this lane needs no second image, and going
# through Gradle plugins would mean a second version of each tool resolved # 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. # at build time and kept in lockstep with the image's by hand.
# #
@@ -210,7 +206,7 @@ jobs:
# #
# DEBUG regardless of what is being packaged: AGP creates unit-test tasks # DEBUG regardless of what is being packaged: AGP creates unit-test tasks
# only for `testBuildType`, which is debug, so `testReleaseUnitTest` does # only for `testBuildType`, which is debug, so `testReleaseUnitTest` does
# not exist (run 4082). It costs one extra Kotlin compile and buys the # not exist. It costs one extra Kotlin compile and buys the
# type-check on the debug variant, which is the one an emulator build # type-check on the debug variant, which is the one an emulator build
# would use. # would use.
run: ./gradlew testDebugUnitTest -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }} run: ./gradlew testDebugUnitTest -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }}
@@ -233,16 +229,15 @@ jobs:
# simply refuses to install over the app already on the phone — a failure # 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. # that otherwise only shows up on the device, after the run is green.
# #
# And FAILS unless there is exactly one signer and it is the release key # And FAILS unless there is exactly one signer and it is the release key.
# (family idea #5103, practice 3). Printing alone was not a check: a build # Printing alone is not a check: a build signed with any other key would go on
# signed with any other key would have gone on to be staged and published, # to be staged and published, and every phone would refuse it. The steps that
# and every phone would have refused it. The steps that publish come after # publish come after this one in the same job, so a failure here stops them.
# this one in the same job, so a failure here stops them.
- name: Check the signing certificate - name: Check the signing certificate
if: steps.build.outputs.keystore != '' if: steps.build.outputs.keystore != ''
env: env:
# The release certificate's SHA-256, as apksigner printed it for the # The release certificate's SHA-256, as apksigner prints it for a signed
# signed APK in run 8753 (CN=Bryan Van Deusen, O=fabledsword). Every # APK (CN=Bryan Van Deusen, O=fabledsword). Every
# installed copy carries this certificate. It only changes if the key # installed copy carries this certificate. It only changes if the key
# does, and then every install has to be replaced by hand anyway. # does, and then every install has to be replaced by hand anyway.
RELEASE_CERT_SHA256: 408a5835ea1627f329d9fba4f00712b096faf2d30624ec6f3a8b2b5f8bb15caa RELEASE_CERT_SHA256: 408a5835ea1627f329d9fba4f00712b096faf2d30624ec6f3a8b2b5f8bb15caa
@@ -285,10 +280,9 @@ jobs:
# publishing an unsigned APK would offer people something they cannot install # publishing an unsigned APK would offer people something they cannot install
# over what they already have. # over what they already have.
# #
# `stable` from main is new in M314 step 3, and it is what lets the server image # Publishing `stable` from main is what lets the server image bake in a client
# bake in a client that matches its own channel: a :latest image fetches the APK # that matches its own channel: a :latest image fetches the APK from `stable`, a
# from `stable`, a :dev image from `dev`. Before this, main published no APK at # :dev image from `dev`.
# all and every image — stable included — baked in the dev one.
- name: Publish to the channel for this branch - name: Publish to the channel for this branch
if: (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && steps.build.outputs.keystore != '' if: (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && steps.build.outputs.keystore != ''
working-directory: . working-directory: .
@@ -300,18 +294,17 @@ jobs:
*) channel=dev; RELEASE_PRERELEASE=true ;; *) channel=dev; RELEASE_PRERELEASE=true ;;
esac esac
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling` # 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")" RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
export RELEASE_TAG RELEASE_PRERELEASE export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing the APK to the $RELEASE_TAG channel." echo "Publishing the APK to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh bash desktop/packaging/publish-release.sh
- name: Upload the APK - name: Upload the APK
# Stock action: it works on this forge since the runner moved to # Stock action: it works on this forge because gitea/runner 3.x edits
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal # upload-artifact's client-side GHES refusal out of the action bundle. Never
# out of the action bundle (Scribe snippet #2271). Never @v3 — it reports # @v3 — it reports success while Gitea serves artifacts back only through the
# success while Gitea serves artifacts back only through the v4 API, so the # v4 API, so the upload is stored and invisible.
# upload is stored and invisible (Scribe 2270).
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
# The APK's variant, NOT the Cargo profile. They are the same word for # The APK's variant, NOT the Cargo profile. They are the same word for
@@ -338,11 +331,8 @@ jobs:
# from here — that is the other half of this. # from here — that is the other half of this.
# #
# No `always()`: a red core check or APK build means no image for this # No `always()`: a red core check or APK build means no image for this
# commit (rule 177, #5419). This used to run regardless, on the reasoning that # commit. Building regardless would publish a `:<sha>` holding a client that
# a broken Android lane shouldn't block server delivery, and the image would # isn't from that commit. The previous `:dev` stays where it is; fix forward.
# bake in the previous client. Run 8839 is what that meant: `:<sha>` published
# for 0ecdbfe while its APK lane (8835) was red, holding a client that wasn't
# from that commit. The previous `:dev` stays where it is; fix forward.
# #
# Not skipped on tags: every ref that builds an image needs the call, since # 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. # ci.yml stands down on these pushes and nothing else builds one.
+49 -66
View File
@@ -3,22 +3,21 @@
# Push to dev: typecheck + lint + test + build :dev # Push to dev: typecheck + lint + test + build :dev
# Push to main: typecheck + lint + test + build :latest + :<sha> # 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: # THAT IS THE COMPLETE TAG SET. No version-shaped image tag in any lane: nothing
# nothing pins one — verified by looking for a consumer, not for whether one is # pins one, and the git release tag is a different object in a different system.
# imaginable — 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.
# (step 7). 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, # 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, # and a tag rebuilding that same source would re-push :<sha>, which a published tag
# which rule 145 forbids even when they match. # never allows.
# #
# main is the production line, so a merge moves :latest to its tip (family rule 46) # main is the production line, so a merge moves :latest to its tip — no version
# — no version release required. :<sha> is the immutable rollback unit, and it is # release required. :<sha> is the immutable rollback unit, and it is on main ONLY: a
# on main ONLY: a sha tag per dev push is a rollback target nobody has ever pulled, # sha tag per dev push is a rollback target nobody pulls, accumulating forever, for
# accumulating forever, for a channel whose entire contract is that it moves. # a channel whose entire contract is that it moves.
# #
# Required secret (repo -> Settings -> Secrets -> Actions): # 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 registry username is derived from github.repository_owner (public — it's in
# the image path), so no REGISTRY_USER secret is needed. # the image path), so no REGISTRY_USER secret is needed.
name: CI & Build name: CI & Build
@@ -28,13 +27,12 @@ on:
# NO `paths:` FILTER, and unlike the client lanes this one does not skip either — # NO `paths:` FILTER, and unlike the client lanes this one does not skip either —
# the image ALWAYS builds. Two reasons: # the image ALWAYS builds. Two reasons:
# #
# * Rule 145 promises that every push to `main` publishes a `:<sha>`, so any # * Every push to `main` publishes a `:<sha>`, so any production commit is
# production commit is addressable. A path filter quietly broke that promise # addressable. A path filter would break that for a docs-only merge: no
# for a docs-only merge: no trigger, no image, no sha tag for that commit. # trigger, no image, no sha tag for that commit.
# * It is the artifact most exposed to base-image staleness (`python:3.12-slim` # * 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 # 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 # picks those updates up.
# bite here — the one artifact it would apply to never skips.
# #
# Affordable because it is the cheap one: ~15 seconds, against 6 and 9 minutes # 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. # 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 # 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 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. # 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 # 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 # when it is finished. Exactly one image per commit, containing the client from
# that commit. # that commit.
# #
# The path list below MUST match android.yml's trigger. Two places holding one # The path list below MUST match android's file set in packaging/version.sh. Two
# decision is the recurring failure in this repo (issues 2181-2183); it is here # places holding one decision drift apart; it is here because this gate needs the
# because a workflow cannot read another's filters, and it is a `git diff` rather # answer before the Android lane has decided, and it is a `git diff` rather than a
# than a config so at least it is inspectable in the log. # config so at least it is inspectable in the log.
gate: gate:
name: Build now, or wait for Android? name: Build now, or wait for Android?
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
@@ -125,11 +123,10 @@ jobs:
echo "Changed in this push:" echo "Changed in this push:"
echo "$changed" | sed 's/^/ /' echo "$changed" | sed 's/^/ /'
# MUST match android's file set in packaging/version.sh. `packaging/` was # MUST match android's file set in packaging/version.sh. A path missing here
# missing here after step 4 added it there — so a packaging-only push had # lets a push touching only it rebuild and dispatch from the Android lane
# the Android lane rebuild and dispatch while this gate ALSO let the image # while this gate ALSO lets the image build: two images for one commit and,
# build, producing two images for one commit and, on main, a second push of # on main, a second push of the same :<sha> with different bytes.
# the same :<sha> with different bytes. Rule 145's exact prohibition.
if echo "$changed" | grep -qE '^(android/|core/|packaging/|Cargo\.toml$|Cargo\.lock$|\.forgejo/workflows/android\.yml$)'; then if echo "$changed" | grep -qE '^(android/|core/|packaging/|Cargo\.toml$|Cargo\.lock$|\.forgejo/workflows/android\.yml$)'; then
echo "" echo ""
echo "This push also changes the Android client. Standing down: the" 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 # 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 # 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 # lane. Among other things they guard the web copy of the note grammar, which is
# the note grammar, mirrored in Rust and Python, was guarded by nothing. # mirrored in Rust and Python.
typecheck: typecheck:
name: Web typecheck and unit tests name: Web typecheck and unit tests
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
@@ -203,17 +200,16 @@ jobs:
# and runs in the job below. # and runs in the job below.
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration" 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 # Real-Postgres lane: `alembic upgrade head` runs here before any deployment runs
# for the first time when the operator's container started — 26 revisions, none of # it, and the schema the migrations build is checked against the models that read
# them ever executed by CI — and the schema the migrations build had never been # it.
# checked against the models that read it.
# #
# Gates the build, along with every other lane — see the `build` job's `needs`. # 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 # 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 # 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: integration:
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci runs-on: python-ci
@@ -251,8 +247,8 @@ jobs:
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}' docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
# Only THIS job's services. The runner's docker daemon is shared, so another # 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 # repo's job can be running beside this one, and a job-name filter matches its
# containers too (Steward run 8358 and Inkwell run 8653 each took another repo's # containers too, and would take another repo's Postgres. act names every
# Postgres). act names every container of a task GITEA-ACTIONS-TASK-<n>-...: read # container of a task GITEA-ACTIONS-TASK-<n>-...: read
# <n> from our own container (its id is in the /etc/hostname bind mount) and # <n> from our own container (its id is in the /etc/hostname bind mount) and
# require exactly one match. # require exactly one match.
SELF=$(grep -o '/containers/[0-9a-f]\{64\}/' /proc/self/mountinfo | head -n1 | cut -d/ -f3 || true) SELF=$(grep -o '/containers/[0-9a-f]\{64\}/' /proc/self/mountinfo | head -n1 | cut -d/ -f3 || true)
@@ -264,7 +260,7 @@ jobs:
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG") PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
test -n "$PG_IP" test -n "$PG_IP"
export INKWELL_DATABASE_URL="postgresql+asyncpg://inkwell:ci_integration@${PG_IP}:5432/inkwell_test" export INKWELL_DATABASE_URL="postgresql+asyncpg://inkwell:ci_integration@${PG_IP}:5432/inkwell_test"
# Wait for Postgres to accept connections. `run:` is busybox sh (rule 81) — # Wait for Postgres to accept connections. `run:` is busybox sh —
# no bash /dev/tcp — so use the Python that is always present here. # no bash /dev/tcp — so use the Python that is always present here.
/opt/venv/bin/python - "$PG_IP" <<'PY' /opt/venv/bin/python - "$PG_IP" <<'PY'
import socket, sys, time import socket, sys, time
@@ -277,7 +273,7 @@ jobs:
else: else:
sys.exit("postgres did not become reachable") sys.exit("postgres did not become reachable")
PY 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 # testing a schema no deployment has ever seen would prove nothing. This
# step IS the migration test: a broken revision fails the job here. # step IS the migration test: a broken revision fails the job here.
/opt/venv/bin/alembic upgrade head /opt/venv/bin/alembic upgrade head
@@ -285,15 +281,9 @@ jobs:
build: build:
name: Build & push image name: Build & push image
# Every lane gates the build. This once stopped at lint + typecheck, on the # Every lane gates the build. An image tag is the rollback substrate, and one
# reasoning that DB-backed testing happened manually against the dev image # that can be published from a failing run is not a substrate you can roll back
# rather than on every push — true until 6f21db8 added the integration lane, # TO.
# 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.
needs: [gate, typecheck, lint, test, integration] needs: [gate, typecheck, lint, test, integration]
if: needs.gate.outputs.build == 'true' if: needs.gate.outputs.build == 'true'
runs-on: python-ci runs-on: python-ci
@@ -306,22 +296,20 @@ jobs:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:
# Derives a version — see the note in desktop.yml. Depth-1 sees one commit # 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 fetch-depth: 0
- name: Generate image tags and version - name: Generate image tags and version
id: tags 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 `[[ ]]`. # NOT bash `[[ ]]`.
run: | run: |
# The image's version is DERIVED from its own shipped files — including the # 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 # 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 # it. One value and no ordering key: nothing compares a server image.
# §2 says do not invent one just because the other artifacts have one.
# #
# This was a short sha on main and the literal "dev" elsewhere, which could # Date-shaped, because "how old is this instance?" is the question that
# not answer "how old is this instance?" — the question that actually gets # actually gets asked of a self-hosted app running in several places.
# asked of a self-hosted app running in several places.
BUILD_VERSION="$(sh packaging/version.sh display server)" BUILD_VERSION="$(sh packaging/version.sh display server)"
case "${{ github.ref }}" in case "${{ github.ref }}" in
refs/heads/dev) refs/heads/dev)
@@ -344,17 +332,16 @@ jobs:
docker builder prune --keep-storage 5g -f || true docker builder prune --keep-storage 5g -f || true
# Bake EVERY client in, on every image build, so a self-hoster gets a working # 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 # app for their machine from the server holding their notes, with no trip to a
# account on this forge, which is private (issue 2091) and is why serving them # release page on the forge.
# from a release page was never an option for anybody but the operator.
# #
# ~104 MB on top of the ~85 MB image, almost all of it the AppImage. That is # ~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, # 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. # 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 # Fetched by the JOB, not by the Dockerfile, so the token it may use never lands
# token used inside a build lands in the context or a layer. # in the build context or a layer.
# #
# NEVER fails the build — see the script. A platform with nothing published # 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, # means the server advertises nothing for it and the UI hides that download,
@@ -367,12 +354,8 @@ jobs:
GITHUB_REPOSITORY: ${{ github.repository }} GITHUB_REPOSITORY: ${{ github.repository }}
run: | run: |
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves dev clients; # THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves dev clients;
# :latest serves stable ones. This read `download/dev` unconditionally # :latest serves stable ones. The channel is chosen here rather than inside
# until M314 step 3, on every branch — so every stable server shipped a # the script because the caller is what knows which image it is building.
# dev-channel APK to anyone who downloaded the client from it. Not a
# versioning gap; a plain defect, and the reason the channel is chosen here
# rather than inside the script: the caller is what knows which image it is
# building.
case "${{ github.ref_name }}" in case "${{ github.ref_name }}" in
main) channel=stable ;; main) channel=stable ;;
*) channel=dev ;; *) channel=dev ;;
+55 -74
View File
@@ -4,22 +4,20 @@
# It DOES run on frontend changes. tauri's generate_context! embeds the built # 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 # 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 # 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 # are peer surfaces held to one quality bar, not a primary and its fallbacks. A
# filter was once narrowed to the adapter/bridge directories against a "~20-40 min" # build takes 4-5 minutes, so there is no cost worth narrowing that for.
# build; measured runs are 4-5 minutes, so the cost that justified the narrowing
# isn't there.
# #
# Toolchain comes from the ci-tauri image (Rust + Node + WebKitGTK 4.1 + tauri-cli); # 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. # runner. The frontend is built here because tauri's generate_context! embeds it.
name: Desktop (Tauri) name: Desktop (Tauri)
on: on:
push: push:
# NO `paths:` FILTER. It was a second, independent statement of this artifact's # NO `paths:` FILTER. It would be a second, hand-kept statement of this artifact's
# file set, hand-kept beside the one in `packaging/version.sh`, and it drifted # file set beside the one in `packaging/version.sh`, and the two drift. The
# from it within a day (85ead4d). The `decide` job below reads the real set and # `decide` job below reads the real set and skips in seconds when nothing moved —
# skips in seconds when nothing moved — one definition, one reader (§3). # one definition, one reader.
# #
# The cost is that this workflow starts on every push rather than on a matching # 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 # one. That is a ~15s container for a decision, against a lane that cannot
@@ -40,13 +38,9 @@ permissions:
jobs: jobs:
# Does anything need building at all? # Does anything need building at all?
# #
# ONE reader of ONE definition — the file sets in `packaging/version.sh` — replacing # ONE reader of ONE definition: the file sets in `packaging/version.sh`.
# 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.
# #
# 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 # Skipping because "the channel already serves this version" is indistinguishable
# from "we derived a stale value that happens to match" unless something checks. # from "we derived a stale value that happens to match" unless something checks.
decide: decide:
@@ -60,7 +54,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:
# Derives a version — depth-1 is silently wrong (§6.1). # Derives a version — depth-1 is silently wrong.
fetch-depth: 0 fetch-depth: 0
- name: Decide - name: Decide
@@ -76,13 +70,12 @@ jobs:
echo "build=$(sh packaging/should-build.sh desktop "$channel")" >> $GITHUB_OUTPUT 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 # 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 # As steps inside `build` they would gate the Linux publish and nothing else, and
# else. `windows` needed only `decide`, so on run 8411 clippy failed, the Linux job # the Windows installer would publish beside a failed clippy. An edge from each
# stopped, and the Windows installer built and published to the dev release in the # publisher to this job is the gate; a verdict inside a sibling job is only a
# same minute (#5184). An edge from each publisher to this job is the gate; a # report.
# verdict inside a sibling job is only a report.
# #
# Both publishers need it directly rather than through each other, so a Windows # 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 # failure still never blocks the Linux bundles, and neither waits on the other's
@@ -108,25 +101,20 @@ jobs:
# The web's unit tests, HERE as well as in ci.yml. The installers embed this # 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, # 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 - name: Web unit tests
run: npm test run: npm test
working-directory: frontend working-directory: frontend
# --locked on the FIRST cargo invocation is the lockfile gate: it fails the # --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 # 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. # bundles a commit whose lockfile drifted.
# #
# Run from the REPO ROOT with --workspace, not from desktop/src-tauri. # Run from the REPO ROOT with --workspace, not from desktop/src-tauri. Inside the
# # desktop crate, cargo scopes to the desktop PACKAGE: the core's tests would not
# These three steps used to run inside the desktop crate, which was right when # run and nothing would lint the Android uniffi shim, while both still COMPILE
# it was the only Rust in the repo. After the core was extracted (M12 step 1) # as dependencies — so the run stays green and the gap stays invisible.
# 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.
- name: Clippy - name: Clippy
run: cargo clippy --locked --workspace --all-targets -- -D warnings run: cargo clippy --locked --workspace --all-targets -- -D warnings
@@ -148,8 +136,8 @@ jobs:
name: Tauri desktop (Linux) name: Tauri desktop (Linux)
needs: [decide, verify] needs: [decide, verify]
# A deadline, not a budget: warm builds take ~4 minutes. Without one a hung step # 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 run 8830 did on a rust-lld link whose # runs to the runner's own 30-minute cap, as a rust-lld link whose threads have
# threads had all deadlocked (#5409). Kept under that cap so this one is the limit # all deadlocked will. Kept under that cap so this one is the limit
# that fires, whatever the runner is configured with. # that fires, whatever the runner is configured with.
timeout-minutes: 20 timeout-minutes: 20
if: needs.decide.outputs.build == 'true' if: needs.decide.outputs.build == 'true'
@@ -166,7 +154,7 @@ jobs:
with: with:
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one # 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 # 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 # from. `packaging/version.sh` fails loudly on an empty result rather than
# emitting something plausible, which is what turns this into a red lane # emitting something plausible, which is what turns this into a red lane
# if it is ever dropped. # if it is ever dropped.
@@ -180,10 +168,10 @@ jobs:
# Frontend already built above; skip the beforeBuildCommand rebuild. # Frontend already built above; skip the beforeBuildCommand rebuild.
# #
# createUpdaterArtifacts is applied only when a signing key exists (M10.9): # createUpdaterArtifacts is applied only when a signing key exists: tauri
# tauri FAILS the build if it's asked to produce updater artifacts with no key, # FAILS the build if it's asked to produce updater artifacts with no key, so
# so making it conditional is what lets the pipeline stay green before the # making it conditional is what lets the pipeline stay green without the
# operator has added the secret. With the key present, each bundle gets a # secret. With the key present, each bundle gets a
# `.sig` beside it — the file the updater actually verifies against. # `.sig` beside it — the file the updater actually verifies against.
- name: Tauri build (deb + AppImage) - name: Tauri build (deb + AppImage)
env: env:
@@ -200,10 +188,10 @@ jobs:
# The ORDERING KEY, not the display version: this string is what Tauri's # 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 # updater parses as semver, and what it stamps into bundle FILENAMES that
# `write-manifest.sh` then selects on. The human-readable version is a # `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)" version="$(sh ../../packaging/version.sh key desktop)"
echo "Building desktop ordering key $version" echo "Building desktop ordering key $version"
# The DISPLAY version, baked into the binary by `option_env!` (#3181). # The DISPLAY version, baked into the binary by `option_env!`.
# A different value for a different audience: this is the one a person # 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 # 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 # sees. Exported rather than passed as a flag because the macro that
@@ -219,7 +207,7 @@ jobs:
# Tauri's AppImage bundles the build host's graphics/display libs # Tauri's AppImage bundles the build host's graphics/display libs
# (libEGL/libGL/libdrm/libgbm/libwayland-*), which clash with end-user GPU # (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 # 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 # graphics libs; webkit/gtk stay bundled. Runs from the repo root (the
# script resolves its own paths), overwriting the AppImage in place. # script resolves its own paths), overwriting the AppImage in place.
@@ -253,9 +241,9 @@ jobs:
# control file and cross-checks it against what the ELF actually needs # control file and cross-checks it against what the ELF actually needs
# (dpkg-shlibdeps). # (dpkg-shlibdeps).
# #
# We deliberately do NOT set bundle.linux.deb.depends: run 2872 showed # We deliberately do NOT set bundle.linux.deb.depends: tauri already infers
# tauri already infers exactly libwebkit2gtk-4.1-0 + libgtk-3-0, so # exactly libwebkit2gtk-4.1-0 + libgtk-3-0, so declaring them again only
# declaring them again only produced a control file listing each twice. # produces a control file listing each twice.
# This step is the guard instead — if tauri's inference ever stops # This step is the guard instead — if tauri's inference ever stops
# covering what the binary links, the build fails here. # covering what the binary links, the build fails here.
- name: Verify the .deb - name: Verify the .deb
@@ -271,13 +259,12 @@ jobs:
run: bash desktop/packaging/arch/package-prebuilt.sh run: bash desktop/packaging/arch/package-prebuilt.sh
# Make the built .deb + .AppImage downloadable from the run (for hand-testing). # Make the built .deb + .AppImage downloadable from the run (for hand-testing).
# Stock action: it works on this forge since the runner moved to # Stock action: it works on this forge because gitea/runner 3.x edits
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal # upload-artifact's client-side GHES refusal out of the action bundle. Never
# out of the action bundle (Scribe snippet #2271). Never @v3 — it uploads # @v3 — it uploads something Gitea stores but will never serve back (it returns
# something Gitea stores but will never serve back (it returns artifacts only # artifacts only through the v4 API, which filters on
# through the v4 API, which filters on content_encoding='application/zip'). # content_encoding='application/zip'). No continue-on-error: a swallowed upload
# No continue-on-error: a swallowed upload failure is exactly how 110 # failure lets unreachable artifacts pile up unnoticed. Fail loudly instead.
# unreachable artifacts accumulated here unnoticed. Fail loudly instead.
- name: Upload bundles - name: Upload bundles
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
@@ -292,14 +279,11 @@ jobs:
# The rolling channel for this branch: `dev` from dev, `stable` from main. Both # 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 # 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. # cannot be named in a URL.
# #
# MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until # MAIN PUBLISHING HERE is what makes a `v*` tag optional: a merge to main
# M314 step 3 this job built on main and published nothing, so the stable # reaches stable users with no tag cut.
# 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.
# #
# Gated on the signing key INSIDE the script rather than with an `if:`, because # 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 # the secrets context isn't reliably available to step conditions. Publishing
@@ -315,7 +299,7 @@ jobs:
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish." echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
exit 0 exit 0
fi 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, # `prerelease` is true for dev so it does not read as a supported build,
# and false for stable, which is the real thing. # and false for stable, which is the real thing.
case "$GITHUB_REF_NAME" in case "$GITHUB_REF_NAME" in
@@ -323,7 +307,7 @@ jobs:
*) channel=dev; RELEASE_PRERELEASE=true ;; *) channel=dev; RELEASE_PRERELEASE=true ;;
esac esac
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling` # 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")" RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
export RELEASE_TAG RELEASE_PRERELEASE export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing to the $RELEASE_TAG channel." echo "Publishing to the $RELEASE_TAG channel."
@@ -355,7 +339,7 @@ jobs:
with: with:
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one # 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 # 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 # from. `packaging/version.sh` fails loudly on an empty result rather than
# emitting something plausible, which is what turns this into a red lane # emitting something plausible, which is what turns this into a red lane
# if it is ever dropped. # if it is ever dropped.
@@ -396,10 +380,10 @@ jobs:
# The ORDERING KEY, not the display version: this string is what Tauri's # 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 # updater parses as semver, and what it stamps into bundle FILENAMES that
# `write-manifest.sh` then selects on. The human-readable version is a # `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)" version="$(sh ../../packaging/version.sh key desktop)"
echo "Building desktop ordering key $version" echo "Building desktop ordering key $version"
# The DISPLAY version, baked into the binary by `option_env!` (#3181). # The DISPLAY version, baked into the binary by `option_env!`.
# A different value for a different audience: this is the one a person # 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 # 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 # sees. Exported rather than passed as a flag because the macro that
@@ -430,14 +414,11 @@ jobs:
# The rolling channel for this branch: `dev` from dev, `stable` from main. Both # 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 # 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. # cannot be named in a URL.
# #
# MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until # MAIN PUBLISHING HERE is what makes a `v*` tag optional: a merge to main
# M314 step 3 this job built on main and published nothing, so the stable # reaches stable users with no tag cut.
# 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.
# #
# Gated on the signing key INSIDE the script rather than with an `if:`, because # 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 # the secrets context isn't reliably available to step conditions. Publishing
@@ -453,7 +434,7 @@ jobs:
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish." echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
exit 0 exit 0
fi 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, # `prerelease` is true for dev so it does not read as a supported build,
# and false for stable, which is the real thing. # and false for stable, which is the real thing.
case "$GITHUB_REF_NAME" in case "$GITHUB_REF_NAME" in
@@ -461,7 +442,7 @@ jobs:
*) channel=dev; RELEASE_PRERELEASE=true ;; *) channel=dev; RELEASE_PRERELEASE=true ;;
esac esac
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling` # 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")" RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
export RELEASE_TAG RELEASE_PRERELEASE export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing to the $RELEASE_TAG channel." echo "Publishing to the $RELEASE_TAG channel."
@@ -488,7 +469,7 @@ jobs:
with: with:
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one # 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 # 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 # from. `packaging/version.sh` fails loudly on an empty result rather than
# emitting something plausible, which is what turns this into a red lane # emitting something plausible, which is what turns this into a red lane
# if it is ever dropped. # if it is ever dropped.
@@ -519,7 +500,7 @@ jobs:
# holds the bundles, and the previous build's bundles are dropped once it # holds the bundles, and the previous build's bundles are dropped once it
# points at this one. Nothing can reach them, and they are ~100 MB a push. # points at this one. Nothing can reach them, and they are ~100 MB a push.
# #
# 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. # triggers release.yml, which writes a changelog and builds nothing.
case "${GITHUB_REF_NAME}" in case "${GITHUB_REF_NAME}" in
main) RELEASE_TAG="$(sh packaging/channel-tag.sh stable)" main) RELEASE_TAG="$(sh packaging/channel-tag.sh stable)"
+6 -6
View File
@@ -1,26 +1,26 @@
name: Release 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 # 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` # 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 # 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 # 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 # 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: # the version answers only the first:
# #
# which build is this? the footer, /api/config, the APK's versionName # which build is this? the footer, /api/config, the APK's versionName
# what changed since the one ← this # what changed since the one ← this
# I was running last month? # 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 # 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. # 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. # system.
on: on:
@@ -62,7 +62,7 @@ jobs:
# Through publish-release.sh for its create-or-PATCH-on-409 path: a # 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 # 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 # exist in this workspace, so its asset globs match nothing and it
# uploads none — which is the intended behaviour, not a side effect. # uploads none — which is the intended behaviour, not a side effect.
RELEASE_TAG="$GITHUB_REF_NAME" bash desktop/packaging/publish-release.sh 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: # 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. # 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 # 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] [workspace]
resolver = "2" resolver = "2"
members = ["core", "desktop/src-tauri", "android/ffi", "android/bindgen"] members = ["core", "desktop/src-tauri", "android/ffi", "android/bindgen"]
+7 -8
View File
@@ -45,7 +45,7 @@ COPY client/ src/inkwell/client/
# The desktop installer, served at /install.sh with this server's own address # 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 # written into it (installer.py). The SAME script the forge serves raw, copied rather
# than moved so the operator's documented forge URL keeps working. # than moved so the forge's raw URL keeps working.
COPY desktop/packaging/install.sh src/inkwell/install.sh COPY desktop/packaging/install.sh src/inkwell/install.sh
ENV PYTHONPATH=/app/src ENV PYTHONPATH=/app/src
@@ -55,16 +55,15 @@ ENV APP_VERSION=$BUILD_VERSION
EXPOSE 5000 EXPOSE 5000
# Wait for the database, run migrations, then serve. The DB wait keeps a briefly # Wait for the database, run migrations, then serve. The DB wait keeps a briefly
# slow/unready database from crash-looping the container. Family convention # slow or unready database from crash-looping the container. The schema is built by
# (rule 82): schema is built by real migrations, never metadata.create_all. # real migrations, never metadata.create_all.
# #
# Timeouts (family idea #5105, practice 9; read from hypercorn 0.18's source): # Timeouts, as hypercorn 0.18 implements them:
# - `--keep-alive 120` is the idle timeout. It is also the header timeout: # - `--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 # 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. It was 600, # client dribbling headers is still "idle" and is cut off at this. 120 stays
# ten minutes per held connection. 120 stays above Traefik's 90s idle timeout # above Traefik's 90s idle timeout for backend connections, so the proxy never
# for backend connections, so the proxy never reuses one this server has just # reuses one this server has just closed (a 502).
# closed (a 502).
# - No `--read-timeout`, on purpose. It bounds every socket read, including the # - 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 # 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 # cut off an APK fetched slowly over mobile data. Bodies are capped instead
+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 <h1 align="center">Inkwell</h1>
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).
- **Stack:** Quart (async Python) + Vue 3 + PostgreSQL, Docker, Fabled-Git CI. <p align="center">Capture a thought in under a second, and find it again later.</p>
- **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.
## 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.
``` ## Quick start
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
```
## Development Save this as `docker-compose.yml`, replace both `CHANGE_ME`s with the same password,
and run `docker compose up -d`:
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`:
```yaml ```yaml
services: services:
@@ -65,26 +24,20 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: inkwell POSTGRES_USER: inkwell
POSTGRES_PASSWORD: CHANGE_ME # change this POSTGRES_PASSWORD: CHANGE_ME
POSTGRES_DB: inkwell POSTGRES_DB: inkwell
volumes: volumes:
- inkwell-db:/var/lib/postgresql/data - inkwell-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U inkwell"]
interval: 5s
timeout: 5s
retries: 10
app: app:
image: git.fabledsword.com/bvandeusen/inkwell:latest # :dev for the current dev build image: git.fabledsword.com/bvandeusen/inkwell:latest
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
db: - db
condition: service_healthy
environment: environment:
INKWELL_DATABASE_URL: postgresql+asyncpg://inkwell:CHANGE_ME@db:5432/inkwell INKWELL_DATABASE_URL: postgresql+asyncpg://inkwell:CHANGE_ME@db:5432/inkwell
volumes: volumes:
- inkwell-data:/var/inkwell # uploaded images; omit if you don't use attachments - inkwell-data:/var/inkwell
ports: ports:
- "5000:5000" - "5000:5000"
@@ -93,36 +46,119 @@ volumes:
inkwell-data: 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 The repository's own [docker-compose.yml](docker-compose.yml) is the same stack read
unset, a signing key is generated and persisted in the database (sessions survive restarts). from a `.env` file; copy [.env.example](.env.example) to `.env` to use it.
- 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.
## Milestones ## Configuration
- **M0 — Foundation & Identity** ✅: Quart+Vue+Postgres skeleton, native auth, | Variable | Required | What it is |
sharing-ACL spine, Fabled-Git CI. |---|---|---|
- **M1 — Capture Core** ✅: note model + masonry board (quick-add, colors, pin, | `INKWELL_DATABASE_URL` | yes | The Postgres connection URL. |
edit-in-place, archive, trash+restore). | `INKWELL_SECRET_KEY` | no | Signs sessions. Unset, one is generated and kept in the database. |
- **M1.5 — Roles & DB-backed Settings** ✅: first user is admin, admin Settings UI, | `INKWELL_LOG_LEVEL` | no | `INFO` by default. Sign-ins and other credential events log at INFO. |
`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.
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: Revises:
Create Date: 2026-07-19 Create Date: 2026-07-19
The M0 identity + sharing-ACL spine. No notes yet (those arrive in M1); the Identity and the sharing ACL. The shares table is polymorphic, so any shareable
shares table is polymorphic so notes reuse it without a schema retrofit. entity uses it.
""" """
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
+1 -1
View File
@@ -4,7 +4,7 @@ Revision ID: 0002
Revises: 0001 Revises: 0001
Create Date: 2026-07-19 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 from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
+1 -1
View File
@@ -5,7 +5,7 @@ Revises: 0002
Create Date: 2026-07-19 Create Date: 2026-07-19
Adds the admin role bit and the DB-backed settings table. No rows are seeded — 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. means every setting is at its default.
""" """
from alembic import op from alembic import op
+2 -4
View File
@@ -16,10 +16,8 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
# The note's display NAME: explicit title if set, else the first body line. # 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="")) op.add_column("notes", sa.Column("display_title", sa.Text(), nullable=False, server_default=""))
# Best-effort backfill (kept intentionally simple + unambiguous — CI has no # Best-effort backfill, capped at 200 chars. The app recomputes the exact value
# Postgres lane, so this only runs on a real deploy). The app recomputes the # (derive_display_title, which also skips leading blank lines) on the next save.
# precise value via derive_display_title on the next save (which additionally
# skips leading blank lines). Capped at 200 chars.
op.execute( op.execute(
r""" r"""
UPDATE notes SET display_title = LEFT(btrim( 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 Revision ID: 0015
Revises: 0014 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 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 hard-delete tombstone (distinct from `deleted_at` = recoverable trash) so an
offline client learns a row is gone instead of resurrecting it. 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 from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
+1 -1
View File
@@ -1,4 +1,4 @@
"""device_tokens (M8 sync hub, step 2) """device_tokens
Revision ID: 0016 Revision ID: 0016
Revises: 0015 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 Revision ID: 0017
Revises: 0016 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 Revision ID: 0018
Revises: 0017 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 Revision ID: 0019
Revises: 0018 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 Revision ID: 0020
Revises: 0019 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 Revision ID: 0021
Revises: 0020 Revises: 0020
+1 -1
View File
@@ -1,4 +1,4 @@
"""notes.recurrence (recurring reminders — M6 1908) """notes.recurrence, for recurring reminders
Revision ID: 0022 Revision ID: 0022
Revises: 0021 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 Revision ID: 0023
Revises: 0022 Revises: 0022
Create Date: 2026-08-22 Create Date: 2026-08-22
A wiki-link stored only as normalized TEXT means a note's name IS the edge: rename A wiki-link stored only as normalized text makes a note's name the edge, so renaming
the note and every inbound link stops matching. The old answer was to rewrite the the note breaks every inbound link. Resolving to an id survives a rename.
`[[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).
`target_norm` stays: it is what an UNRESOLVED link carries, since linking to a note `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. 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 Revision ID: 0024
Revises: 0023 Revises: 0023
Create Date: 2026-08-22 Create Date: 2026-08-22
ThoughtSync is an intermediary surface for capture and recall; a linking system is 0023 stays in the chain: removing an applied revision would strand a database's
organization, which is not what it is for. Backlinks, the graph and the name index alembic_version pointer.
went with it.
0023 (which added `note_links.target_id`) is deliberately left in the chain rather note_links was derived from note bodies, so nothing typed is lost; the `[[text]]` is
than deleted. It shipped in an image and may already be applied, and removing an still in every body.
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.
""" """
from alembic import op from alembic import op
import sqlalchemy as sa 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 Revision ID: 0025
Revises: 0024 Revises: 0024
Create Date: 2026-08-22 Create Date: 2026-08-22
`kind` was never a type: a plain TEXT column with no enum and no CHECK, compared A note is a body plus optional checkable items. Items are rows in their own table,
against a hardcoded ("text", "list") tuple in six places. `note_items` was always an so a `kind = 'list'` note keeps every one.
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.
""" """
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
@@ -29,7 +21,6 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
# server_default so existing rows get a value; every note comes back as 'text', # 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 # nothing records which were lists.
# body, and there is no record of which ones were once lists.
op.add_column("notes", sa.Column("kind", sa.Text(), nullable=False, server_default="text")) 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 Revises: 0025
Create Date: 2026-08-22 Create Date: 2026-08-22
M13 step 3. A note is a body plus optional checkable items; its NAME is the first A note's name is its first non-empty body line, carried by `display_title`.
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.
## 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` `display_title` was already derived from the title, so every note keeps its name.
(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`).
""" """
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
@@ -4,64 +4,31 @@ Revision ID: 0027
Revises: 0026 Revises: 0026
Create Date: 2026-08-24 Create Date: 2026-08-24
M304. A checklist item becomes a `- [ ] milk` line of `notes.body`, and `note_items` A checklist item becomes a `- [ ] milk` line of `notes.body`, so a list can sit
goes. The reason is positional, not cosmetic: a row had a position in a table and no between two paragraphs, and `note_items` goes.
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.
## This migration rewrites note bodies ## This migration rewrites note bodies
Every note that has items gets its body appended to. The rules below are strict * Rows are read before the table is dropped, in this one transaction.
because rewriting somebody's text deserves it — not, as an earlier draft of this * The existing body is only appended to.
docstring claimed, because this instance holds imported Google Keep notes. It does * The layout (a blank line between prose and the list, nothing between items) is
not; note 2916's headline is that nothing here is anyone's work but the operator's what the export writes and what `derive::append_item` produces, so the clients
test data. What 2916 actually says about imports is conditional — text arriving from migrate their own stores independently and still agree with the server.
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.
Careful was still the right call. It cost little, and the same care is what the rule The fold is inlined rather than imported from `notes/checklist.py`: a migration must
demands the day someone does import something: keep producing what it produced the day it ran.
* Rows are read BEFORE the table is dropped, in this one transaction. ## `updated_at` is left alone
* 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.
## 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 ## The downgrade is not a true inverse
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.
## `updated_at` is left alone, and that is load-bearing 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
Raw SQL, so SQLAlchemy's `onupdate` never fires. Two reasons, and the second matters table must exist because 0015's downgrade drops a trigger on it.
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.
""" """
import re import re
@@ -100,9 +67,8 @@ def upgrade():
for note_id, items in grouped.items(): for note_id, items in grouped.items():
body = bind.execute(sa.text("SELECT body FROM notes WHERE id = :id"), {"id": note_id}).scalar() 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 # An item whose note is gone has nothing to fold into. The foreign key should
# should make this impossible; skipping costs nothing and failing here would # prevent it; skipping avoids a half-migrated database.
# leave the database half-migrated.
if body is None: if body is None:
continue continue
for text, checked in items: for text, checked in items:
@@ -113,9 +79,8 @@ def upgrade():
def downgrade(): def downgrade():
# Column-for-column as migration 0006 created it, index name included: 0015's # Exactly as 0006 created it, index name included: 0015's downgrade names both
# downgrade names both the table and its trigger, so a near-enough copy is not # the table and its trigger.
# good enough.
op.create_table( op.create_table(
"note_items", "note_items",
sa.Column("id", UUID(as_uuid=True), primary_key=True), sa.Column("id", UUID(as_uuid=True), primary_key=True),
+19 -71
View File
@@ -4,73 +4,27 @@ Revision ID: 0028
Revises: 0027 Revises: 0027
Create Date: 2026-08-26 Create Date: 2026-08-26
M311. A `#tag` was being shown twice — once as the text you typed and once as a chip — A tag standing on its own line shows as a chip, so its text is removed, by the same
and with the chip moved to the top of the card the text is redundant. This removes it, rule as `notes/tags.py:split_body_tags`:
but only from notes where the tag was standing on its own.
## 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` The label rows flip to `via_tag = false` in the same transaction: the text backing
applies from here on: 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. The transform and `_display_title` are inlined: a migration must keep producing what
* Every other line is left exactly as written. 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 Raw SQL, so `onupdate` never fires and a client's unpushed edit still wins
looser reading — also stripping a trailing tag off a prose line — was rejected because last-write-wins. The `sync_revision` trigger fires, so clients pull the server's text.
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.
Two guards, both of which cost a note nothing: 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
* A line inside a ``` fence is never touched. A `#tag` there is a shell comment in a rollback is a database restore.
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.
""" """
import re import re
@@ -152,17 +106,11 @@ def upgrade():
sa.text("UPDATE notes SET body = :body, display_title = :title WHERE id = :id"), sa.text("UPDATE notes SET body = :body, display_title = :title WHERE id = :id"),
{"body": lifted, "title": _display_title(lifted), "id": note_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 # The flip is driven by the tag list, not by whether the text changed: an
# that its line was already removed by an earlier pass — so the flip is driven # earlier pass may already have removed the line.
# by the tag list, not by whether the text changed.
if standalone: if standalone:
bind.execute(flip, {"nid": note_id, "names": [n.lower() for n in standalone]}) bind.execute(flip, {"nid": note_id, "names": [n.lower() for n in standalone]})
def downgrade(): def downgrade():
"""Deliberately empty — see the module docstring. """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.
"""
+9 -42
View File
@@ -4,50 +4,18 @@ Revision ID: 0029
Revises: 0028 Revises: 0028
Create Date: 2026-08-28 Create Date: 2026-08-28
M315 step 3. A note's colour was set by a picker and read by three card renderers. Cards are one neutral surface per theme; the only colour on a board is a tag's.
Steps 1 and 2 stopped every one of those reads: the card is one neutral per theme and `labels.color` is untouched. A colour a note was given is dropped with the column.
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.
`labels.color` is untouched. That is the colour that survived, and the one the whole `saved_filters.params` may carry a `"color"` facet, so stored views are swept too. In
milestone was about keeping. 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 The search vector never named `color`, so it needs no rebuild.
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.
## Downgrade ## Downgrade
Restores the column, empty, at its old default. The values are not recoverable — see Restores the column, empty, at its old default. The values are not recoverable.
above. It is the schema that comes back, not the data.
""" """
import json import json
@@ -71,9 +39,8 @@ def upgrade() -> None:
try: try:
parsed = json.loads(params) parsed = json.loads(params)
except (ValueError, TypeError): except (ValueError, TypeError):
# A blob that does not parse cannot be edited safely. Leaving it is # A blob that doesn't parse can't be edited safely; the app couldn't read
# correct: it was already unreadable by the app, and this migration is not # it either.
# the place to decide what it should have said.
continue continue
if not isinstance(parsed, dict) or "color" not in parsed: if not isinstance(parsed, dict) or "color" not in parsed:
continue continue
+3 -9
View File
@@ -4,15 +4,9 @@ Revision ID: 0030
Revises: 0029 Revises: 0029
Create Date: 2026-10-06 Create Date: 2026-10-06
The product is renamed ThoughtSync → Inkwell (Scribe note 5071), and the `site_name` The app was renamed from ThoughtSync to Inkwell. The Settings page saves every key,
registry default moves with it. On its own that only reaches servers whose settings so most servers have a stored `site_name` of the old default without anyone choosing
table has no `site_name` row — and most servers have one, because the Settings page it. The row is rewritten only when it holds exactly that; a typed name is left alone.
saves EVERY key on Save, not just the one that changed. An admin who opened Settings
to flip registration off has persisted `"ThoughtSync"` without ever choosing it.
So the row is rewritten, but only when it holds exactly the old default. A name the
admin actually typed is theirs and is left alone; the old default is the one value we
can be sure nobody chose.
The match is on the stored JSON text (`settings.value` is `json.dumps(value)`), so The match is on the stored JSON text (`settings.value` is `json.dumps(value)`), so
`'"ThoughtSync"'` is the whole of what it looks for. `'"ThoughtSync"'` is the whole of what it looks for.
@@ -4,16 +4,9 @@ Revision ID: 0031
Revises: 0030 Revises: 0030
Create Date: 2026-10-07 Create Date: 2026-10-07
0015 made every child table bump its parent note's `sync_revision`, so a note syncs A note syncs as a whole, so every child table bumps its note's `sync_revision`. With
as a whole. `note_link_previews` arrived later (0020) and was never added, and two this, a preview fetched after a save, or dismissed on the web, reaches every device.
things have been missing on every linked device since: The trigger is the function 0015 installs on the other child tables.
- A preview fetched in the background after a save (`unfurl_queue.py`) never reached
a device that had already pulled the note. The note's revision was assigned when
the TEXT was saved, before the preview existed, and nothing moved it afterwards.
- A preview dismissed on the web stayed on every other device, for the same reason.
The trigger is the same function 0015 installs on the other child tables.
## Downgrade ## Downgrade
+2 -3
View File
@@ -4,9 +4,8 @@ Revision ID: 0032
Revises: 0031 Revises: 0031
Create Date: 2026-10-07 Create Date: 2026-10-07
Until now the only way to add a second person was to re-open registration to the An invite lets one person register while registration stays closed. Only the token's
whole internet while they signed up (#2939 §1). An invite lets one person register SHA-256 hash is stored.
while registration stays closed. Only the token's SHA-256 hash is stored.
## Downgrade ## Downgrade
+5 -6
View File
@@ -4,20 +4,19 @@ Revision ID: 0033
Revises: 0032 Revises: 0032
Create Date: 2026-10-07 Create Date: 2026-10-07
A forgotten password used to need a hand on the database (#2939 §2), and the app has An admin makes a reset link for the account and hands it over. Only the token's
no mail path to send a reset by. An admin makes a reset link for the account and SHA-256 hash is stored.
hands it over (#5173). Only the token's SHA-256 hash is stored.
`users.session_epoch` is what lets a reset sign the account out everywhere. Sessions `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 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. 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 It starts at 0, the value a cookie without one is read as, so the upgrade signs
is signed out by the upgrade itself. nobody out.
## Downgrade ## Downgrade
Drops the table and the column. Outstanding reset links stop working; sessions keep 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 import sqlalchemy as sa
from alembic import op from alembic import op
+2 -3
View File
@@ -4,11 +4,10 @@ Revision ID: 0034
Revises: 0033 Revises: 0033
Create Date: 2026-10-07 Create Date: 2026-10-07
Nothing wrote a share until #5174, so the table never needed these. Now the Share For the Share dialog:
dialog does:
- A unique index on (resource_type, resource_id, shared_with_user_id) where a user is - 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 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`. - A check that `permission` is `view` or `edit`.
- An index on `shared_with_user_id` for "Shared with me". - 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 Create Date: 2026-10-07
The change feed carries every note a person can see, including notes shared with 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 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, 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 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 Revises: 0035
Create Date: 2026-10-07 Create Date: 2026-10-07
Pin, archive and board order are personal organization, and until now they were Pin, archive and board order are personal. The owner's are columns on `notes`; this
columns on `notes`, so on a shared note they could only ever mean the owner's table holds everyone else's, one row per (note, person it is shared with) who has
(#5176). This table holds them for everyone else: one row per (note, person it is pinned, archived or moved it. A recipient with no row sees the note unpinned,
shared with) who has pinned, archived or moved it. The owner keeps the note's own unarchived, in the owner's order.
columns; a recipient with no row sees the note unpinned, unarchived, in the owner's
order.
`sync_revision` is stamped by the same `ts_set_sync_revision()` trigger the notes `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 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 Revises: 0036
Create Date: 2026-10-07 Create Date: 2026-10-07
Both came with the foundation (0001) for features that were never built (#5178). Nothing set `email_verified` or read `avatar_path`, and a field whose value means
Nothing ever set `email_verified`, so it was false for every account on the server nothing would be trusted by a later feature.
while the offline desktop reported it as true; nothing read or wrote `avatar_path`.
A field whose value means nothing is worse than no field: a later feature would
trust it. If address verification is ever built, it adds its own column, with a
migration that says what the existing accounts are.
## Downgrade ## Downgrade
+1 -3
View File
@@ -4,9 +4,7 @@ Revision ID: 0038
Revises: 0037 Revises: 0037
Create Date: 2026-10-08 Create Date: 2026-10-08
Saved views were removed in #5180 (note 2897: recall first, organizing second). They Saved views are removed; tags in the drawer give one-click recall.
lived only on the web; the desktop kept its own set that never synced, and Android
had none. Tags in the drawer already give one-click recall.
## Downgrade ## Downgrade
+2 -3
View File
@@ -4,9 +4,8 @@ Revision ID: 0039
Revises: 0038 Revises: 0038
Create Date: 2026-10-08 Create Date: 2026-10-08
Credential events were only written to the app log, which is not queryable and is Keeps credential events in the database for `audit_retention_days`, for admins to
gone at the container's log rotation (#2939 §5). This keeps them in the database for read in Settings → Activity; the app log isn't queryable and rotates away.
`audit_retention_days`, for admins to read in Settings → Activity.
## Downgrade ## Downgrade
+3 -5
View File
@@ -4,11 +4,9 @@ Revision ID: 0040
Revises: 0039 Revises: 0039
Create Date: 2026-10-08 Create Date: 2026-10-08
Deleting a tag now leaves a tombstone row (`purged_at` set) so the change feed can Deleting a tag leaves a tombstone row (`purged_at` set) so the change feed tells
tell linked devices about it (#5382). Before, the web deleted the row outright and linked devices. A tombstone must not hold its name, so `(owner_id, name)` is unique
devices never heard. A tombstone must not hold its name: creating `#grocery` again only where `purged_at IS NULL`.
after deleting it has to make a live tag, on the web and from a device alike. So
`(owner_id, name)` stays unique only where `purged_at IS NULL`.
## Downgrade ## Downgrade
+25 -35
View File
@@ -10,17 +10,16 @@ plugins {
val workspaceRoot: Directory = layout.projectDirectory.dir("../..") val workspaceRoot: Directory = layout.projectDirectory.dir("../..")
// The ABIs a release APK carries. arm64 is essentially every real device; armv7 // 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 // covers older 32-bit hardware; the two x86 targets are what emulators run, so the
// dropping them would make the app untestable on a desktop emulator (the reason // app stays testable on a desktop emulator.
// x86_64 was added to the old Tauri lane in task 1864).
val androidAbis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") val androidAbis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
/** /**
* Cross-compile `inkwell-ffi` for each Android ABI and drop the resulting * Cross-compile `inkwell-ffi` for each Android ABI and drop the resulting
* `.so` into jniLibs, where AGP packages it. * `.so` into jniLibs, where AGP packages it.
* *
* `ExecOperations` injected rather than `project.exec`: the latter was REMOVED in * `ExecOperations` injected rather than `project.exec`: Gradle 9 has no
* Gradle 9, and reaching for `project` at execution time is also what breaks the * `project.exec`, and reaching for `project` at execution time breaks the
* configuration cache this build has enabled. * configuration cache this build has enabled.
*/ */
abstract class CargoNdkBuild : DefaultTask() { abstract class CargoNdkBuild : DefaultTask() {
@@ -60,16 +59,15 @@ abstract class CargoNdkBuild : DefaultTask() {
commandLine(listOf("cargo") + args) commandLine(listOf("cargo") + args)
workingDir = workspaceDir.get().asFile workingDir = workspaceDir.get().asFile
if (release) { if (release) {
// The workspace's release profile with two of its settings put back, // The workspace's release profile with two settings put back, for this
// for this build only. Environment overrides rather than a profile of // build only. Environment overrides rather than a profile of our own,
// our own, because cargo-ndk copies its `-o` output from the // because cargo-ndk copies its `-o` output from the `release`
// `release` directory and nothing says it understands any other one; // directory; the desktop's binaries are untouched either way.
// the desktop's binaries are untouched either way.
// //
// Keep the symbol table: `strip = true` removes the symbols uniffi's // Keep the symbol table: `strip = true` removes the symbols uniffi's
// `--library` mode reads the interface out of, and bindgen then fails // `--library` mode reads the interface out of, and bindgen then fails
// with "No UniFFI metadata found" (run 4077). There is no debug info // with "No UniFFI metadata found". A release build has no debug info to
// in a release build to strip, so this keeps symbols and nothing else. // strip, so this keeps symbols and nothing else.
environment("CARGO_PROFILE_RELEASE_STRIP", "debuginfo") environment("CARGO_PROFILE_RELEASE_STRIP", "debuginfo")
// Unwind, so a panic in the core reaches Kotlin as an exception the // 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. // board can show rather than ending the app. See the ffi crate's docs.
@@ -141,17 +139,13 @@ val rustInputs =
/** /**
* Which Cargo profile the `.so` is built with. * Which Cargo profile the `.so` is built with.
* *
* A property rather than a debug/release task PAIR, deliberately. This runner has * A property rather than a debug/release task pair: this runner has no working
* no working Gradle or Cargo cache (`reserveCache failed` on every run), so a cold * Gradle or Cargo cache, so a cold cross-compile of four ABIs costs about four
* cross-compile of four ABIs costs about four minutes — and a lane that both * minutes, and a lane that type-checks and packages with different profiles would
* type-checks and packages would pay that twice if the two used different * pay it twice. `android.yml` picks one profile for every Gradle call in the run.
* profiles. `android.yml` picks one profile and uses it for every Gradle call in
* the run.
* *
* CI passes `release` for a signed APK, so a phone runs an optimised store and sync * CI passes `release` for a signed APK, so a phone runs an optimised store and sync
* engine. Until Scribe #2810 it passed `debug`, because the release profile's * engine; `CargoNdkBuild` keeps the symbols bindgen needs.
* `strip = true` broke bindgen; `CargoNdkBuild` now keeps the symbols for this
* build alone.
*/ */
val rustProfile = val rustProfile =
(project.findProperty("INKWELL_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() } (project.findProperty("INKWELL_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() }
@@ -187,8 +181,7 @@ android {
defaultConfig { defaultConfig {
applicationId = "com.fabledsword.inkwell" applicationId = "com.fabledsword.inkwell"
// 26 (Android 8, 2017) matches Minstrel and clears the NDK's floor with // 26 (Android 8, 2017) clears the NDK's floor with room to spare.
// room to spare.
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
// Injected by CI from the git tag + commit count for a release; "dev" // Injected by CI from the git tag + commit count for a release; "dev"
@@ -212,11 +205,10 @@ android {
} }
// The signing key reaches this build only through the environment: CI decodes // 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 from a secret into a file and points ANDROID_KEYSTORE_FILE at that path. It
// It is never in the repo and never in this file. Generated by the operator // is never in the repo. An Android signing key cannot be rotated without the
// and never seen by an agent session, because an Android signing key cannot be // original (v3 lineage needs it), so a leaked or lost one means every install
// rotated without the original — v3 lineage needs it — so a leaked or lost one // has to be removed and replaced by hand.
// means every install has to be removed and replaced by hand.
val keystoreFile = System.getenv("ANDROID_KEYSTORE_FILE")?.takeIf { it.isNotBlank() } val keystoreFile = System.getenv("ANDROID_KEYSTORE_FILE")?.takeIf { it.isNotBlank() }
val keystorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")?.takeIf { it.isNotBlank() } val keystorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")?.takeIf { it.isNotBlank() }
@@ -225,13 +217,11 @@ android {
create("release") { create("release") {
storeFile = File(keystoreFile) storeFile = File(keystoreFile)
storePassword = keystorePassword storePassword = keystorePassword
// Hardcoded, and NOT a secret: the alias is fixed for the life of // Hardcoded, and NOT a secret: the alias is fixed for the life of this
// this app and is written into the certificate every install // app and written into the certificate every install carries. It says
// already carries. Hiding it would buy nothing and stop this file // `thoughtsync`, the app's earlier name, because it names the key
// describing its own signing setup. It still says `thoughtsync` // inside the existing keystore; renaming it would stop the key being
// after the rename to Inkwell, because it names the key inside the // found.
// existing keystore, and renaming it would only stop that key being
// found. Same key, so the signing certificate did not change either.
keyAlias = "thoughtsync" keyAlias = "thoughtsync"
// PKCS12 cannot hold a key password distinct from the store // PKCS12 cannot hold a key password distinct from the store
// password — keytool refuses to set one — so this is the same // password — keytool refuses to set one — so this is the same
+24 -29
View File
@@ -25,9 +25,8 @@
FOREGROUND_SERVICE used only for expedited work; nothing here asks FOREGROUND_SERVICE used only for expedited work; nothing here asks
for it, and it arrives with the library for it, and it arrives with the library
Verified against the built APK's merged manifest, not assumed. Noted here Listed because all four appear in the app's permission list and nothing
because all four appear in the app's permission list and nothing else in else in this file would explain where they came from.
this file would explain where they came from.
--> -->
<!-- <!--
@@ -64,24 +63,22 @@
refusing costs precision, not the feature — see Reminders.scheduleNext. refusing costs precision, not the feature — see Reminders.scheduleNext.
RECEIVE_BOOT_COMPLETED already arrives via WorkManager (below), but is 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. 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 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 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 the legacy ACTION_VIEW install intent is, and this app uses a PackageInstaller
app uses a PackageInstaller session instead. See AppUpdate.kt and Scribe note session instead (AppUpdate.kt). The person must also grant "install unknown
2437. The person must additionally grant "install unknown apps" in system apps" in system settings; the update card asks before downloading anything.
settings; the update card asks before downloading anything.
UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+) is what removes the install UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+) removes the install confirmation
confirmation on the UPDATE path, and only there — Android will not let an app on the UPDATE path only: Android will not let an app silently put a NEW package
silently put a NEW package on a device, which is correct. It also only applies on a device. It applies only when the new build is signed with the same key as
when the new build is signed with the same key as the installed one, which is the installed one.
why signing had to land before any of this could work.
--> -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" /> <uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
@@ -91,12 +88,11 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- <!--
allowBackup stays true, which family idea #5105 (practice 12) says to turn allowBackup stays true. Inkwell is local-first, so an unlinked phone's notes
off. Inkwell is local-first, so an unlinked phone's notes exist only on the exist only on the phone, and Android's backup is their one other copy. The
phone, and Android's backup is their one other copy. The device token is what device token is stored sealed under a Keystore key that never leaves the phone
the practice protects, and it is stored sealed under a Keystore key that never (KeystoreTokenSeal.kt), so a backup carries the notes and a token nothing can
leaves the phone (KeystoreTokenSeal.kt). A backup therefore carries the notes open, and a restored app asks to sign in again.
and a token nothing can open, and a restored app asks to sign in again.
--> -->
<application <application
android:name=".InkwellApplication" android:name=".InkwellApplication"
@@ -111,12 +107,11 @@
launchMode="singleTop" exists for the SHARE filters below. launchMode="singleTop" exists for the SHARE filters below.
The reminder notification adds FLAG_ACTIVITY_SINGLE_TOP to its own The reminder notification adds FLAG_ACTIVITY_SINGLE_TOP to its own
intent, so onNewIntent already worked for that one. A share intent is intent, but a share intent is built by the OTHER app (Chrome, a reader,
built by the OTHER app — Chrome, a reader, the text-selection toolbar — the text-selection toolbar) and nothing here can add a flag to it.
and nothing here can add a flag to it. Without singleTop declared on the Without singleTop on the activity, every share while the app is running
activity itself, every share while the app is running would stack a would stack a second MainActivity: a second view model, a second board,
second MainActivity on top of the first: a second view model, a second and a back press that lands on a stale copy of the app.
board, and a back press that lands on a stale copy of the same app.
--> -->
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@@ -135,7 +130,7 @@
Text, and images one at a time or several at once. A shared image 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 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 image/* rather than */*: a photo or a screenshot is what people
share into a notes app. Claiming every type would put Inkwell in the share into a notes app. Claiming every type would put Inkwell in the
@@ -180,7 +175,7 @@
Where the system reports what happened to an install we committed. Not Where the system reports what happened to an install we committed. Not
exported: the only sender is the PendingIntent this app handed to exported: the only sender is the PendingIntent this app handed to
PackageInstaller. Without it a failed install would be indistinguishable PackageInstaller. Without it a failed install would be indistinguishable
from someone declining the dialog (Scribe #2438). from someone declining the dialog.
--> -->
<!-- <!--
Hands an attachment to the app that opens its type. Not exported, as a Hands an attachment to the app that opens its type. Not exported, as a
@@ -18,24 +18,20 @@ import java.io.File
* *
* ## A PackageInstaller session, not an install intent * ## A PackageInstaller session, not an install intent
* *
* The obvious route — `ACTION_VIEW` on the APK with * The obvious route, `ACTION_VIEW` on the APK with
* `application/vnd.android.package-archive` — is the one on-device install * `application/vnd.android.package-archive`, is the one on-device install
* heuristics are tuned against, and it is what produces the "bypassing Android * heuristics are tuned against, and it produces a "bypassing Android security"
* security" warning the operator saw on Minstrel (Scribe note 2437). It also never * warning. It also never tells the OS that this app is the legitimate updater of its
* tells the OS that this app is the legitimate updater of its own package, and it * own package, and it returns nothing: a failed install is indistinguishable from a
* returns nothing: a failed install is indistinguishable from a person dismissing * person dismissing the dialog.
* the dialog.
* *
* A session says who is doing what. On Android 12+ it can also declare that no user * 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` — * action is required, which, paired with `UPDATE_PACKAGES_WITHOUT_USER_ACTION`,
* removes the confirmation dialog entirely on the UPDATE path. Not on a first * removes the confirmation dialog on the UPDATE path. Not on a first install: the
* install: the OS will not let an app quietly put a NEW package on a device, which * OS will not let an app quietly put a NEW package on a device.
* is right.
* *
* Two things from that same research that are NOT done here, deliberately: * Neither `setRequestUpdateOwnership` nor `REQUEST_INSTALL_PACKAGES` is what avoids
* `setRequestUpdateOwnership` was chased and turned out to be a red herring, and * the warning; the session is.
* `REQUEST_INSTALL_PACKAGES` is not the differentiator either — Mihon declares it
* too. The mechanism was the whole difference.
* *
* ## The outcome comes back * ## The outcome comes back
* *
@@ -57,8 +53,7 @@ object AppUpdate {
* A debuggable build is signed with a debug key (CI's unsigned path, or a local * 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 * build), and Android refuses the release-signed APK over it with
* INSTALL_FAILED_UPDATE_INCOMPATIBLE. Offering that update would be a download * INSTALL_FAILED_UPDATE_INCOMPATIBLE. Offering that update would be a download
* that can only fail, so a debug build says so instead (family idea #5103, * that can only fail, so a debug build says so instead.
* practice 9).
*/ */
fun selfUpdates(context: Context): Boolean = fun selfUpdates(context: Context): Boolean =
(context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) == 0 (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) == 0
@@ -125,10 +120,9 @@ object AppUpdate {
val params = val params =
PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Only honoured on an UPDATE of an app signed with the same key — // Only honoured on an UPDATE of an app signed with the same key, which
// exactly our case, and the reason the signing work had to land // is this case. Android ignores it for anything else rather than
// first. Android ignores it for anything else rather than failing, // failing, so there is no need to guard on which it is.
// so there is no need to guard on which it is.
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
} }
@@ -150,11 +144,8 @@ object AppUpdate {
} }
/** /**
* Stream the APK into the session. * 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.
* 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.
*/ */
private fun writeApk( private fun writeApk(
session: PackageInstaller.Session, session: PackageInstaller.Session,
@@ -12,10 +12,9 @@ import android.content.Context
* *
* Returns null rather than a fallback string, because the two callers want * Returns null rather than a fallback string, because the two callers want
* different ones: the UI wants a localized "unknown" from string resources, the * 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 — * client header wants the literal the core recognizes. With no version tags, the
* with no version tags, the artifact's self-report is the only answer to "which * artifact's self-report is the only answer to "which build is this?", so a missing
* build is this?", so a missing name must read as missing and never as a plausible * name must read as missing, never as a plausible default.
* default that nothing can contradict.
*/ */
fun Context.installedVersionName(): String? = fun Context.installedVersionName(): String? =
runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull() runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull()
@@ -32,11 +32,9 @@ class InkwellApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// Introduce this app to any server it links to, before anything can sync. // Introduce this app to any server it links to, before anything can sync. The
// The core cannot name us — the same crate is compiled into the desktop app, // core can't name its host app, since the same crate is compiled into the
// and it used to announce every phone as `inkwell-desktop` carrying the // desktop app. "unknown" rather than a guess when the package manager won't say.
// core crate's own version. "unknown" rather than a guess when the package
// manager will not say (note 3127 §5).
setClientAgent("inkwell-android", installedVersionName() ?: "unknown") setClientAgent("inkwell-android", installedVersionName() ?: "unknown")
try { try {
@@ -12,8 +12,8 @@ import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.GCMParameterSpec
/** /**
* Keeps the device token sealed under a key in the Android Keystore (family idea * Keeps the device token sealed under a key in the Android Keystore. The core
* #5105, practice 12). The core stores what this returns, never the token itself. * 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 * 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 * nothing can open. That includes Android's backup, which stays on: an unlinked
@@ -23,8 +23,6 @@ import javax.crypto.spec.GCMParameterSpec
* Both methods answer null rather than throw, because an exception crossing into * 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 * 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". * it as is for now" and from [openToken] as "sign in again".
*
* Shaped after Minstrel's KeystoreSessionVault (Scribe snippet #5025).
*/ */
class KeystoreTokenSeal : TokenSeal { class KeystoreTokenSeal : TokenSeal {
override fun sealToken(token: String): String? = override fun sealToken(token: String): String? =
@@ -205,7 +205,7 @@ private enum class Screen { BOARD, EDITOR, SYNC, TAGS }
* both cover the display completely, so keeping the board's two-column grid * both cover the display completely, so keeping the board's two-column grid
* measuring and recomposing underneath one would be pure waste. * measuring and recomposing underneath one would be pure waste.
* *
* Still no navigation library. 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 * place and left by back — a nav graph would be ceremony around an enum, and the
* state that actually matters (which note is open, whether this device is linked) * state that actually matters (which note is open, whether this device is linked)
* already lives in view models. * already lives in view models.
@@ -257,9 +257,8 @@ private fun App(
// the display, and nothing in the store cares. Saveable so a rotation does not // the display, and nothing in the store cares. Saveable so a rotation does not
// close it. // close it.
// //
// The capture sheet used to keep its own flag here too. It is gone: the + button // There is no separate capture sheet: the + button opens the editor on an unsaved
// opens the editor on an unsaved draft, so writing a note and editing one are the // draft, so writing a note and editing one are the same surface.
// same surface with the same toolbar.
var showingSync by rememberSaveable { mutableStateOf(false) } var showingSync by rememberSaveable { mutableStateOf(false) }
var showingTags by rememberSaveable { mutableStateOf(false) } var showingTags by rememberSaveable { mutableStateOf(false) }
@@ -446,16 +445,12 @@ private fun ReminderAlarms(core: Inkwell) {
} }
/** /**
* Looking for an app update without being asked. * Looking for an app update without being asked, so a new build is found without
* * anyone going to look for one.
* 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.
* *
* On coming forward rather than on a timer: it is the moment the person is present, * 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. * 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 * Unlinked devices are skipped, since updates come from a linked server.
* there is nothing to ask.
*/ */
@Composable @Composable
private fun AutomaticUpdate( private fun AutomaticUpdate(
@@ -26,7 +26,7 @@ import com.fabledsword.inkwell.ui.epochMillis
* ## AlarmManager, not WorkManager * ## AlarmManager, not WorkManager
* *
* The background sync runs on WorkManager and is right to: nobody minds whether it * 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 * 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, * "remind me at 09:00" would routinely arrive at 09:14 — which is not a reminder,
* it is a rebuke. * it is a rebuke.
@@ -3,8 +3,8 @@ package com.fabledsword.inkwell
import android.content.Context import android.content.Context
/** /**
* When each tag's lens was last opened on this phone, for the drawer's recent tags * When each tag's lens was last opened on this phone, for the drawer's recent tags.
* (#5931). Opening a tag counts as using it, alongside the notes that carry it. * 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 * 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. * person's thumb goes on this screen, and losing it costs one tap.
@@ -12,8 +12,7 @@ import android.util.Log
* Without this the app would `commit` and learn nothing — a failed install would * 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 * 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 * sit there claiming an update is available with no explanation of why nothing
* happened. That was the specific complaint recorded against Minstrel's first * happened.
* attempt (Scribe #2438).
* *
* The result is written to [UpdateOutcome] rather than notified: the app is on * 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 * screen when this fires — someone just tapped Update — so the place to say it is
@@ -1,5 +1,7 @@
package com.fabledsword.inkwell.ui package com.fabledsword.inkwell.ui
import android.content.ClipData
import android.content.ClipDescription
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.database.Cursor import android.database.Cursor
@@ -10,6 +12,7 @@ import android.media.ExifInterface
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.util.LruCache import android.util.LruCache
import androidx.annotation.StringRes
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
@@ -40,15 +43,18 @@ sealed interface PickedFile {
) : PickedFile ) : PickedFile
} }
/** What [AttachmentFiles.opener] made: an intent to start, or why there isn't one. */ /**
sealed interface Opener { * What [AttachmentFiles] made to hand a file to another app — an intent to start, a
data class Ready( * clip for the clipboard — or why there isn't one.
val intent: Intent, */
) : Opener sealed interface Handout<out T> {
data class Ready<T>(
val value: T,
) : Handout<T>
data class Failed( data class Failed(
val message: String, 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 * An intent that hands the file to whatever app opens its type, or the reason
* there can't be one. * 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 * 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 * 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 * spreadsheet. The copy lives in the cache, which the system reclaims, and only
* that directory is shared (`res/xml/file_paths.xml`). * 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) 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 dir = File(app.cacheDir, "$OPEN_DIR/${attachment.id}")
val copy = File(dir, safeName(attachment.filename)) val copy = File(dir, safeName(attachment.filename))
return try { return try {
dir.mkdirs() dir.mkdirs()
if (!copy.isFile || copy.length() != source.length()) source.copyTo(copy, overwrite = true) if (!copy.isFile || copy.length() != source.length()) source.copyTo(copy, overwrite = true)
val uri = FileProvider.getUriForFile(app, "${app.packageName}.files", copy) Handout.Ready(make(FileProvider.getUriForFile(app, "${app.packageName}.files", copy)))
val view =
Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, attachment.mime)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
Opener.Ready(view)
} catch (e: IOException) { } 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 * 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`. * The same rule as `rendersInline` in the web's `notes/attachments.ts`.
*/ */
fun rendersInline(mime: String): Boolean { fun rendersInline(mime: String): Boolean {
@@ -1,12 +1,16 @@
package com.fabledsword.inkwell.ui package com.fabledsword.inkwell.ui
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent
import android.os.Build
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -33,8 +37,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -115,6 +121,7 @@ fun EditorAttachments(
onAction: (EditorAction) -> Unit, onAction: (EditorAction) -> Unit,
) { ) {
val open = rememberOpener() val open = rememberOpener()
val copy = rememberCopier()
Column(modifier = Modifier.padding(top = 12.dp)) { Column(modifier = Modifier.padding(top = 12.dp)) {
attachments.forEach { attachment -> attachments.forEach { attachment ->
val remove = { onAction(EditorAction.RemoveAttachment(attachment.id)) } val remove = { onAction(EditorAction.RemoveAttachment(attachment.id)) }
@@ -124,6 +131,7 @@ fun EditorAttachments(
attachment = attachment, attachment = attachment,
maxPx = EDITOR_IMAGE_PX, maxPx = EDITOR_IMAGE_PX,
onClick = { open(attachment) }, onClick = { open(attachment) },
onLongClick = { copy(attachment) },
) { ) {
FileRow(attachment = attachment, onClick = { open(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. * 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 * [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 * 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. * something is attached instead of a gap.
@@ -214,6 +225,7 @@ private fun AttachmentImage(
attachment: Attachment, attachment: Attachment,
maxPx: Int, maxPx: Int,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
fallback: @Composable () -> Unit, fallback: @Composable () -> Unit,
) { ) {
val files = LocalAttachmentFiles.current val files = LocalAttachmentFiles.current
@@ -234,7 +246,15 @@ private fun AttachmentImage(
) )
is ImageLoad.Ready -> { is ImageLoad.Ready -> {
val ratio = (state.bitmap.width.toFloat() / state.bitmap.height).coerceAtLeast(MIN_ASPECT) 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( Image(
bitmap = state.bitmap, bitmap = state.bitmap,
contentDescription = attachment.filename, contentDescription = attachment.filename,
@@ -311,8 +331,39 @@ private fun rememberOpener(): (Attachment) -> Unit {
if (files != null) { if (files != null) {
scope.launch { scope.launch {
when (val opener = withContext(Dispatchers.IO) { files.opener(attachment) }) { when (val opener = withContext(Dispatchers.IO) { files.opener(attachment) }) {
is Opener.Ready -> start(context, opener) is Handout.Ready -> start(context, opener.value)
is Opener.Failed -> toast(context, opener.message) 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( private fun start(
context: Context, context: Context,
opener: Opener.Ready, intent: Intent,
) { ) {
try { try {
context.startActivity(opener.intent) context.startActivity(intent)
} catch (expected: ActivityNotFoundException) { } catch (expected: ActivityNotFoundException) {
toast(context, context.getString(R.string.attach_no_app)) toast(context, context.getString(R.string.attach_no_app))
} }
@@ -20,7 +20,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
@@ -28,6 +31,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
@@ -39,7 +43,7 @@ import com.fabledsword.inkwell.R
* The note's body, as fields and checkboxes rather than as markup. * The note's body, as fields and checkboxes rather than as markup.
* *
* The point of the whole shape: a box you can tick while looking at the note, rather * 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 @Composable
fun BlockBody( fun BlockBody(
@@ -89,8 +93,8 @@ fun BlockBody(
val remaining = blocks.withoutIndex(index) val remaining = blocks.withoutIndex(index)
onChange(remaining) onChange(remaining)
// The row above — or, for the FIRST row, whichever one takes // The row above — or, for the FIRST row, whichever one takes
// its place. `index - 1` alone is -1 there, which left the // its place. `index - 1` alone is -1 there, which would leave
// keyboard up with nothing focused. // the keyboard up with nothing focused.
onFocus(remaining.getOrNull((index - 1).coerceAtLeast(0))?.id) onFocus(remaining.getOrNull((index - 1).coerceAtLeast(0))?.id)
}, },
) )
@@ -114,7 +118,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 * 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. * [promotingTasks] for why blur is the only safe moment to do that.
@@ -161,8 +165,7 @@ private fun TaskBlock(
onDelete: () -> Unit, onDelete: () -> Unit,
) { ) {
// Material sizes every interactive component to a 48dp touch target, and on a // 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 // checklist that IS the row height.
// even after the field's own padding came off.
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides ROW_TOUCH) { CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides ROW_TOUCH) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox( Checkbox(
@@ -203,25 +206,23 @@ private fun TaskBlock(
* Material's floor is 48dp and this is deliberately under it. That floor is sized for * 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 * 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 * 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 * neighbouring item — visible, and undone by tapping again. Twelve of those dp buy a
* dp for a list that fits on a screen is what was asked for, twice. * list that fits on a screen.
*/ */
private val ROW_TOUCH = 36.dp private val ROW_TOUCH = 36.dp
/** /**
* The field a block is typed into. * The field a block is typed into.
* *
* `BasicTextField`, not the Material one [PlainTextField] wraps, and the reason is * `BasicTextField`, not the Material one [PlainTextField] wraps, for density.
* density. Material's TextField puts 16dp above and below its text — padding that * Material's TextField puts 16dp above and below its text: padding that makes a FORM
* makes a FORM field comfortable to hit, and that on a checklist IS the row height. It * field comfortable to hit, and that on a checklist IS the row height.
* made six items twice as tall as the six items, which is what the operator saw.
* *
* Nothing is lost by dropping down a layer. `PlainTextField` exists to strip a * `BasicTextField` has no container or indicator to strip. What it does not supply
* container and an indicator; `BasicTextField` never had either, so there is no box * and this must:
* here to drift back into existence. What it does not supply and this must:
* *
* - the text COLOUR. It defaults to `Color.Unspecified`, which draws BLACK — the same * - the text COLOUR. It defaults to `Color.Unspecified`, which draws BLACK and is
* default that made the editor's toolbar invisible in dark mode. Set, not inherited. * invisible in dark mode. Set, not inherited.
* - the cursor brush, which would otherwise be black for the same reason. * - 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. * - the placeholder, which is a plain Text behind the field rather than a slot.
* *
@@ -241,6 +242,15 @@ private fun BlockField(
keyboardActions: KeyboardActions = KeyboardActions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default,
) { ) {
val style = textStyle.copy(color = MaterialTheme.colorScheme.onSurface) 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) { Box(modifier = modifier) {
if (hint != null && value.text.isEmpty()) { if (hint != null && value.text.isEmpty()) {
Text( Text(
@@ -252,12 +262,13 @@ private fun BlockField(
BasicTextField( BasicTextField(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth().then(arrows),
enabled = enabled, enabled = enabled,
singleLine = singleLine, singleLine = singleLine,
textStyle = style, textStyle = style,
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
onTextLayout = { layout = it },
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
) )
} }
@@ -13,10 +13,10 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.LayoutCoordinates
import com.fabledsword.inkwell.core.Note import com.fabledsword.inkwell.core.Note
// Reordering the board by dragging a card, as on the web (#5176): hold a card, then // Reordering the board by dragging a card, as on the web: hold a card, then move it.
// move it. The hold is the same long press that opens the card's menu, so the menu // The hold is the same long press that opens the card's menu, so the menu appears
// appears first and closes as the card starts to move. Lifting without moving // first and closes as the card starts to move. Lifting without moving leaves the
// leaves the menu open, as before. Moving BEFORE the hold completes is a scroll. // 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 // 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 // click handling. Nothing is consumed until the hold has completed and the finger
@@ -23,9 +23,9 @@ import com.fabledsword.inkwell.core.Label
import com.fabledsword.inkwell.core.NoteFacets import com.fabledsword.inkwell.core.NoteFacets
/** /**
* The board's filters: the same three the web's Filters panel offers (FilterBar.vue, * The board's filters: the same three the web's Filters panel offers (FilterBar.vue).
* trimmed to these in #5180). Tags a note must ALL carry, has an attachment, and * Tags a note must ALL carry, has an attachment, and shared with me. Reminders and
* shared with me. Reminders and dates have their own lenses, here as on the web. * 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 * 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, * the drawer starts that view unfiltered. A search narrows whichever view is open,
@@ -356,10 +356,9 @@ private fun SearchBar(
) { ) {
Surface( Surface(
// No `statusBarsPadding()` here. The Scaffold this sits in already applies // No `statusBarsPadding()` here. The Scaffold this sits in already applies
// the system-bar insets to its content padding, so adding them again put // the system-bar insets to its content padding, so adding them again would
// the whole status bar's height of empty space above the search field — // put a status bar's height of empty space above the search field. Insets
// roughly a centimetre of nothing at the top of the first screen anyone // get consumed once, by whichever component owns the edge.
// sees. Insets get consumed once, by whichever component owns the edge.
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -410,7 +409,7 @@ private fun NavigationDrawer(
onOpenSync: () -> Unit, onOpenSync: () -> Unit,
onManageTags: () -> Unit, onManageTags: () -> Unit,
) { ) {
// Every tag, once asked for; until then the few in use (#5931). // Every tag, once asked for; until then the few in use.
var allTags by rememberSaveable { mutableStateOf(false) } var allTags by rememberSaveable { mutableStateOf(false) }
val shown = if (allTags) labels else recentTags(labels, { recency[it.id] ?: 0L }, { it.name }) val shown = if (allTags) labels else recentTags(labels, { recency[it.id] ?: 0L }, { it.name })
@@ -444,7 +443,7 @@ private fun NavigationDrawer(
modifier = Modifier.padding(horizontal = 12.dp), modifier = Modifier.padding(horizontal = 12.dp),
) )
// Tags last, as on the web (#5915): it is the one list here that grows // 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. // without limit, so everything with a fixed place sits above it.
// //
// The header renders even with no tags, unlike the rows below it: the // The header renders even with no tags, unlike the rows below it: the
@@ -78,8 +78,8 @@ data class BoardState(
/** /**
* A search narrows the board in view rather than replacing it, as on the web, * 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 * where the box writes `?q=` beside the Filters panel's facets and one query
* answers both (Scribe #2942). "These words, in notes tagged grocery" is the * answers both. "These words, in notes tagged grocery" is the question tags exist
* question tags exist to help ask (note 2930). * to help ask.
*/ */
val searching: Boolean get() = query.isNotBlank() val searching: Boolean get() = query.isNotBlank()
@@ -297,10 +297,8 @@ class BoardViewModel(
/** /**
* Open the editor on a note that does not exist yet. * 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 * The + button opens this rather than a separate capture sheet, so a note being
* WRITTEN could not be given a colour, a reminder or a checklist — those live on * WRITTEN can be given a colour, a reminder or a checklist from the same toolbar.
* the editor's toolbar, and the sheet had none. Writing and editing are now the
* same surface.
* *
* The draft is a real [Note] carrying [DRAFT_ID] rather than a null, so the * 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 * editor renders it without knowing that "not saved yet" is a state it can be
@@ -462,7 +460,7 @@ class BoardViewModel(
EditorAction.DismissError -> dismissError() EditorAction.DismissError -> dismissError()
// Sent on an idle debounce while typing, and again on close. Writing // 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. // revision — the core keeps one per editing session, not one per save.
is EditorAction.SaveText -> is EditorAction.SaveText ->
mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) } mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) }
@@ -567,12 +565,9 @@ class BoardViewModel(
* lie than showing it one frame stale. * lie than showing it one frame stale.
* *
* While a search is running the QUERY is re-run rather than the board's * 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 * destination: running `load` here would replace the hits with the whole board,
* board, which is why this branch exists at all. It used to keep the existing * and keeping the old list would leave a trashed hit on screen beside a snackbar
* list instead, and that was right for a note whose place in the pile changed * saying it was gone.
* and wrong for one that left it: trashing a hit left the card sitting there,
* with a snackbar saying it was gone, until the query happened to re-run
* (#3111). Re-asking is still the answer to the query, just a current one.
*/ */
private fun mutate( private fun mutate(
closeEditor: Boolean = false, closeEditor: Boolean = false,
@@ -596,9 +591,8 @@ class BoardViewModel(
notes = notes, notes = notes,
// Only REFRESHES an open editor; it must never open one. // Only REFRESHES an open editor; it must never open one.
// `editing != null` IS "the editor is on screen", so writing // `editing != null` IS "the editor is on screen", so writing
// the reloaded note in unconditionally meant any mutation // the reloaded note in unconditionally would throw the editor
// started from the BOARD threw the editor open on top of it — // open over the board whenever a card's checkbox is ticked.
// which is exactly what ticking a checkbox on a card did.
editing = if (closeEditor) null else state.editing?.let { updated ?: it }, editing = if (closeEditor) null else state.editing?.let { updated ?: it },
saving = false, saving = false,
error = notice(), error = notice(),
@@ -619,7 +613,7 @@ class BoardViewModel(
* rather than the pure text helpers the editor uses: nothing here is holding a * 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. * 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( fun toggleItem(
note: Note, note: Note,
@@ -6,11 +6,7 @@ package com.fabledsword.inkwell.ui
// identical grey chips. Hashing the tag's NAME is deterministic, identical on every // 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. // 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 // A note's fill is one neutral; only its tags carry hue.
// 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.
// //
// THIS IS HALF A MIRRORED PAIR. `frontend/src/notes/colors.ts` computes the same hash // 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 // 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 // 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 // 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 // `DerivedTintTest` runnable in the Unit tests step rather than on an emulator. See
// web side has no test runner at all, so this test is the only mechanical guard the // the fixture comment in colors.ts.
// mirror gets — see the fixture comment in colors.ts.
/** /**
* The colours a derived hue can land on: `NOTE_TINTS`' keys minus `default`, which is * The colours a derived hue can land on: `NOTE_TINTS`' keys minus `default`, which is
@@ -79,12 +74,10 @@ fun derivedTint(id: String): String {
* `default`: the server mints one as `Label(owner_id=…, name=name)` with no colour, * `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. * 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 * Derived rather than stored when a label is created: most tags are born from typing
* a hashed colour written at each of the four places a label can be born — and named * `#grocery` through `find_or_create_label`, not from a management screen, and
* the risk itself: `find_or_create_label` is "easy to miss, and it is the common one", * deriving has no creation point to miss and nothing to backfill. The cost is that
* since most tags are born from typing `#grocery`, not from a management screen. * renaming a tag recolours it, which is fair: the name IS the tag.
* 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.
* *
* Folded because `#Todo` renamed to `#todo` is the same tag and should not change * 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 * colour. [foldTag] and the web's are both locale-independent lowercasing, so the
@@ -104,6 +97,6 @@ fun resolvedLabelColor(
/** /**
* How two tag names compare: equal once every letter is lowercased, accented ones * 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 * 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 (#5385). * `labeling.named` and the web's `foldTag` fold the same way.
*/ */
fun foldTag(name: String): String = name.lowercase() fun foldTag(name: String): String = name.lowercase()
@@ -5,12 +5,10 @@ import android.net.Uri
/** /**
* Everything the editor can ask for, as one type. * Everything the editor can ask for, as one type.
* *
* The alternative was a bundle of twenty callbacks, and it was a bad one: twenty * One `(EditorAction) -> Unit` rather than twenty same-shaped `(String, String) ->
* same-shaped `(String, String) -> Unit` parameters is a place for two of them to * Unit` callbacks, where two could get swapped with nothing to catch it. It costs a
* get swapped, with nothing to catch it. One `(EditorAction) -> Unit` costs a * `when` at the far end and buys EXHAUSTIVENESS: adding a variant here breaks the
* `when` at the far end and gets EXHAUSTIVENESS in exchange — adding a variant * dispatcher until it is handled.
* here breaks the dispatcher until it is handled, which is precisely the guarantee
* the callback bundle could not offer.
* *
* No variant carries a note id. The editor is open on exactly one note and the * 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 * dispatcher already has it, so threading it through every action would only
@@ -41,12 +39,10 @@ sealed interface EditorAction {
data object DeleteForever : EditorAction data object DeleteForever : EditorAction
// No checklist actions at all any more (M304). An item is a `- [ ] ` line of the // No checklist actions. An item is a `- [ ] ` line of the body, so adding,
// body, so adding, renaming, ticking or deleting one is editing text — which the // renaming, ticking or deleting one is editing text, through SaveText, with the
// editor already does, through SaveText, with the same autosave and the same // same autosave and revision window as any other edit. Routing them through the
// revision window as any other edit. Routing them through the store would have // store would hand back a note whose body disagrees with the field being typed in.
// meant the store handing back a note whose body disagreed with the field the
// person was typing in.
/** /**
* The note's MANUAL labels, replacing whatever was there. * The note's MANUAL labels, replacing whatever was there.
@@ -9,7 +9,7 @@ import com.fabledsword.inkwell.core.checklistRender
/** /**
* One piece of a note body, as the editor DRAWS it. * One piece of a note body, as the editor DRAWS it.
* *
* 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 * input shape, and nothing below the editor can tell it exists. [joinBlocks] puts the
* string back together on every edit. * string back together on every edit.
* *
@@ -36,8 +36,8 @@ data class EditorBlock(
/** /**
* Split a body into blocks, numbering them from [firstId]. * 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 * Which lines are items comes from the core, not from a pattern here: the grammar has
* written three times already and Kotlin is not going to be the fourth. * three implementations already, and this file isn't a fourth.
*/ */
fun splitBlocks( fun splitBlocks(
body: String, body: String,
@@ -95,13 +95,11 @@ val blocksSaver: Saver<List<EditorBlock>, String> =
/** /**
* What the return key does on a checklist item. * What the return key does on a checklist item.
* *
* `internal` rather than private because BlockBody.kt calls it. These three helpers * `internal` rather than private because BlockBody.kt calls it: these helpers are the
* are the block MODEL and the composables are the block UI — one file was doing both, * block MODEL, and the composables in BlockBody.kt are the block UI.
* which detekt noticed by counting functions before anybody noticed by reading.
* *
* On one with words in it, a new empty item below. On an EMPTY one, the item becomes * 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 * prose, which is how a list ENDS: without it a list is impossible to get out of.
* before this: without it a list is impossible to get out of.
* *
* Deliberately appends rather than splitting at the caret. Splitting an item in two is * 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. * a rarity, and the caret is at the end for every ordinary use of this key.
@@ -47,17 +47,11 @@ import com.fabledsword.inkwell.core.Note
/** /**
* The editor's action bar, along the top of the surface. * The editor's action bar, along the top of the surface.
* *
* It sits exactly where the capture sheet's drag handle used to. The handle cost * Top rather than bottom because this surface is used for WRITING: the keyboard owns
* this strip of screen and did nothing that a back gesture does not already do, so * the bottom of the display for most of a note's life, so a bar down there would ride
* the strip carries the actions instead. * 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
* Top rather than bottom, now that this one surface is used for WRITING as well as * you type.
* 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.
* *
* The three affordances with a permanent slot are the ones reached for while still * The three affordances with a permanent slot are the ones reached for while still
* writing — colour, reminder, note-or-list. Everything structural (pin, labels, * writing — colour, reminder, note-or-list. Everything structural (pin, labels,
@@ -84,17 +78,16 @@ fun EditorTopBar(
onConfirmDelete: () -> Unit, onConfirmDelete: () -> Unit,
onAction: (EditorAction) -> Unit, onAction: (EditorAction) -> Unit,
) { ) {
// Someone else's note (#5175): its text, at `edit`, and this account's own pin // Someone else's note: its text, at `edit`, and this account's own pin
// and archive (#5176) are all that may change here, so the bar keeps the // 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. // checklist button and an overflow of those two, and nothing that is the owner's.
val owner = access == NoteAccess.OWNER val owner = access == NoteAccess.OWNER
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
TopAppBar( TopAppBar(
title = {}, title = {},
navigationIcon = { navigationIcon = {
// The only way out, and the only thing that needed a "save" button // The only way out. Writes are continuous, so leaving IS saving, which is
// before writes became continuous. Leaving IS saving now, which is what // what the line in the bottom corner says out loud.
// the line in the bottom corner is there to say out loud.
BackButton(onClose, R.string.editor_back) BackButton(onClose, R.string.editor_back)
}, },
actions = { actions = {
@@ -108,7 +101,7 @@ fun EditorTopBar(
} }
if (!readOnly) { if (!readOnly) {
// Inserts `- [ ] ` at the caret. Always available, and never hidden: // Inserts `- [ ] ` at the caret. Always available, and never hidden:
// a checklist is text now (M304), so there is no section to be // a checklist is text, so there is no section to be
// already-showing and no reason a second list cannot start further // already-showing and no reason a second list cannot start further
// down the same note. // down the same note.
IconButton(onClick = onStartChecklist) { IconButton(onClick = onStartChecklist) {
@@ -139,14 +132,11 @@ fun EditorTopBar(
) )
} }
}, },
// EXPLICIT, and not optional — the same lesson the old bottom bar learned. // EXPLICIT, and not optional. Material derives a bar's content colour from its
// Material derives a bar's content colour from its container via // container via contentColorFor(), which maps a colour-SCHEME ROLE to its `on-`
// contentColorFor(), which maps a colour-SCHEME ROLE to its `on-` pair and // pair and returns Unspecified for anything else. The card surface is a plain
// returns Unspecified for anything else. The card surface is a plain constant // constant, not a role, so without this the icons draw with no colour filter:
// and not a role, so the icons drew with no colour filter: black vectors on a // black vectors on a near-black bar, invisible in dark mode.
// 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.
// //
// onSurface for the actions too, not the default onSurfaceVariant: the bar has // 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 // to read against the card rather than the board, and the muted variant does
@@ -164,36 +154,24 @@ fun EditorTopBar(
/** /**
* The footer: when the note was last written, and the way out. * 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 * **Where the note stands.** There is no save button: a note is saved continuously,
* note is saved continuously, so a button offering to do what already happened is a * so a button offering to do what already happened would be a lie with a tap
* lie with a tap attached. But that left nothing on screen saying the work is safe, * attached. The state says the work is safe instead, as a fact: Not saved yet →
* and "closing this keeps it" is not a thing anyone should have to be told twice. So * Saving… → Edited just now is the whole lifecycle.
* 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.
* *
* **The way out.** Down here because of where hands are. Moving the toolbar to the * **The way out.** Down here because of where hands are. The top-left corner is the
* top took the back arrow with it, which left the only exit from a full-screen * furthest point on the display from a right-handed thumb, so the exit lives in the
* editor in the top-left corner — the furthest point on the display from a * bottom corner, which with the keyboard up sits directly above it. The top-left
* right-handed thumb, and reached over the whole note to get to. The operator hit * arrow stays as well: it is what habit, the system back gesture and TalkBack all
* that on the first device pass and was right to. So the exit lives in the bottom * expect of a full-screen surface.
* corner, which with the keyboard up sits directly above it.
* *
* The top-left arrow stays as well. Two affordances for one action is usually * A checkmark in a filled tonal button, so it reads plainly as a control rather than
* clutter, but this is the case that earns it: the arrow is what habit, the system * as a checklist tick. Its content description is "Done", which is what it says read
* back gesture and TalkBack all expect of a full-screen surface, and removing it * aloud.
* 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.
* *
* [DateUtils] rather than a hand-rolled formatter: it is localised, it already * [DateUtils] rather than a hand-rolled formatter: it is localised, it already
* knows the difference between minutes, hours and yesterday, and getting plurals * knows the difference between minutes, hours and yesterday, and it gets plurals
* right in every language is not this app's problem to solve twice. * right in every language.
*/ */
@Composable @Composable
fun EditorFooter( fun EditorFooter(
@@ -206,7 +184,7 @@ fun EditorFooter(
modifier = modifier =
modifier modifier
.fillMaxWidth() .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: // content Column deliberately does not also inset for the IME:
// Scaffold measures this row at its lifted height and passes the // Scaffold measures this row at its lifted height and passes the
// inset down. // inset down.
@@ -224,16 +202,10 @@ fun EditorFooter(
) )
FilledTonalIconButton( FilledTonalIconButton(
onClick = onClose, onClick = onClose,
// The BRAND, matching the board's compose FAB — the app's one existing // The BRAND, matching the board's compose FAB: the app's one statement of
// statement of "this is the affirmative action here", now reused rather // "this is the affirmative action here". Material's default
// than a second one invented. // 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.
// 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.
colors = colors =
IconButtonDefaults.filledTonalIconButtonColors( IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary, containerColor = MaterialTheme.colorScheme.primary,
@@ -51,10 +51,6 @@ import java.time.temporal.TemporalAdjusters
// The two things you pick rather than type: a set of labels, and a time. // 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 // 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 // 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 // the note still visible above it — which matters when the choice you are making
@@ -21,9 +21,8 @@ import com.fabledsword.inkwell.R
// Shared by the board and the editor. // Shared by the board and the editor.
// //
// A failed save is most likely to happen WHILE the editor is open — that is where // A failed save is most likely WHILE the editor is open, since that is where the
// the writes are — so a banner only the board could render meant the one screen // writes are, so the editor needs the banner as much as the board does.
// that needed it was the one screen without it.
@Composable @Composable
fun ErrorBanner( fun ErrorBanner(
@@ -49,7 +49,7 @@ private val TRAILING_PUNCTUATION = charArrayOf('.', ',', ';', ':', '!', '?')
* The link a note that is nothing but a link files its preview under, or null. * 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 * Trimmed the way the server's `unfurl_queue.detect_urls` trims it, since that is the
* key the preview arrives under (#5399). The web's `loneUrl` is the same rule, and * 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). * both run the `urls` cases in `core/testdata/grammar.json` (here, by hand).
*/ */
fun loneUrl(body: String): String? = fun loneUrl(body: String): String? =
@@ -92,9 +92,8 @@ fun isLoneUrl(note: Note): Boolean = loneUrl(note.body) != null
* The image sits in a strip down the left edge, cropped to the card's height, as in * 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 * 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 * 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. The operator chose that * points at, exactly as a browser does for the web card. Coil's disk cache means a
* parity knowingly (Scribe #3307). Coil's disk cache means a card scrolled past * card scrolled past twice costs one download, not two.
* twice costs one download, not two.
*/ */
@Composable @Composable
fun LinkPreviewCard( fun LinkPreviewCard(
@@ -106,7 +105,7 @@ fun LinkPreviewCard(
// and the two paddings are named first. `standard:chain-method-continuation` // and the two paddings are named first. `standard:chain-method-continuation`
// wants a `.` that follows a MULTILINE element glued to its closing paren — // wants a `.` that follows a MULTILINE element glued to its closing paren —
// `).padding(…)` — which is unreadable, so the multiline element is avoided // `).padding(…)` — which is unreadable, so the multiline element is avoided
// instead (Scribe #3110). // instead.
val shape = RoundedCornerShape(PREVIEW_RADIUS) val shape = RoundedCornerShape(PREVIEW_RADIUS)
val padH = if (compact) 8.dp else 10.dp val padH = if (compact) 8.dp else 10.dp
val padV = if (compact) 6.dp else 8.dp val padV = if (compact) 6.dp else 8.dp
@@ -3,10 +3,10 @@ package com.fabledsword.inkwell.ui
import com.fabledsword.inkwell.core.Note 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 * 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 * refuses the rest anyway — this only keeps the editor from offering what would be
* refused. * refused.
*/ */
@@ -67,8 +67,7 @@ fun NoteCard(
// NAMED rather than written inline in the chain below, and not for taste: ktlint's // NAMED rather than written inline in the chain below, and not for taste: ktlint's
// chain-method-continuation wants the next `.` glued to the closing paren of a // chain-method-continuation wants the next `.` glued to the closing paren of a
// multiline element — `).background(…)` — which is worse to read than a modifier // multiline element — `).background(…)` — which is worse to read than a modifier
// with a name. Every other multiline element in this codebase happens to be last // with a name.
// in its chain, so this is the first place the rule bites.
val opening = val opening =
Modifier.combinedClickable( Modifier.combinedClickable(
onClickLabel = stringResource(R.string.board_open_note), onClickLabel = stringResource(R.string.board_open_note),
@@ -147,13 +146,12 @@ private fun CardContents(
// NoteBody, which shows the URL. Never a blank card. // NoteBody, which shows the URL. Never a blank card.
val lonePreview = remember(note.body, note.previews) { loneUrlPreview(note) } val lonePreview = remember(note.body, note.previews) { loneUrlPreview(note) }
// Body then checklist, in order — a note can carry both (M13 step 2). The // Body then checklist, in order — a note can carry both. The first line of the
// first line of the body IS the note's name, at the same weight as the rest of // body IS the note's name, at the same weight as the rest of it.
// it (M13 steps 3 and 4).
if (lonePreview != null) { if (lonePreview != null) {
LinkPreviewCard(preview = lonePreview, compact = false) LinkPreviewCard(preview = lonePreview, compact = false)
} else if (note.body.isNotBlank()) { } else if (note.body.isNotBlank()) {
// A note shared with us to view has inert boxes (#5175): ticking one is // A note shared with us to view has inert boxes: ticking one is
// changing its text, which is not ours to do. // changing its text, which is not ours to do.
NoteBody( NoteBody(
note = note, note = note,
@@ -164,7 +162,7 @@ private fun CardContents(
// Links mentioned INSIDE a note: a compact strip at the foot of the card, // 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 // 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 // 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. // should be. The web does the same.
if (!isLoneUrl(note) && note.previews.isNotEmpty()) { if (!isLoneUrl(note) && note.previews.isNotEmpty()) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
note.previews.forEach { preview -> note.previews.forEach { preview ->
@@ -218,7 +216,7 @@ private fun NoteMenu(
onAction: (EditorAction) -> Unit, onAction: (EditorAction) -> Unit,
onConfirmDelete: () -> 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 // trash is the owner's. A note shared with us never reaches our Trash, so the
// trashed set is only ever the owner's. // trashed set is only ever the owner's.
val owner = note.access == NoteAccess.OWNER val owner = note.access == NoteAccess.OWNER
@@ -239,12 +237,12 @@ private fun NoteMenu(
* The note's body, with its checklist drawn where it actually sits. * 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 * 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 * of the body, and a card that showed the prose and then the list would put every
* every list in the wrong place — and, since the body already contains those lines, * list in the wrong place — and, since the body already contains those lines, would
* would show each one twice. * show each one twice.
* *
* Which lines are items is asked of the core rather than matched here. The grammar is * Which lines are items is asked of the core rather than matched here. The grammar has
* already written three times; a fourth in Compose would be a fourth place for a * three implementations already; a fourth in Compose would be a fourth place for a
* checklist to change shape when it syncs. * checklist to change shape when it syncs.
*/ */
@Composable @Composable
@@ -333,12 +331,8 @@ private fun ChecklistRow(
/** /**
* One string of a note's own words, with every `#tag` in it drawn in that tag's colour. * 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 * A tag still living in the prose is coloured in place rather than repeated as a chip:
* print such a tag twice — once where it was typed and once in the row above — and the * the token you can see IS the text you would delete to remove the tag.
* 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.
* *
* WHICH characters are a tag is asked of the core, exactly as [NoteBody] asks it which * 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, * lines are checklist items. The grammar already exists three times (Rust, Python,
@@ -415,9 +409,8 @@ private fun LabelChips(labels: List<NoteLabel>) {
* The reminder, red once it has passed. * The reminder, red once it has passed.
* *
* Red for overdue and neutral otherwise, matching the web card exactly — the same * 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 * red-100/red-700 and black/5 pairs, resolved through the shared tint table, so
* used to be blue for every reminder here, which made "you missed this" and * "you missed this" and "coming up on Friday" don't look alike on a board of both.
* "coming up on Friday" look identical on a board full of both.
*/ */
@Composable @Composable
private fun ReminderChip( private fun ReminderChip(
@@ -428,7 +421,7 @@ private fun ReminderChip(
} }
/** /**
* 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. * shared with us, "Shared" on one of ours that we shared. Nothing on a private note.
*/ */
@Composable @Composable
@@ -463,31 +456,21 @@ private val CARD_ELEVATION = 1.dp
// too, so a note opened is the same object as the note on the board. // 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 * A card's fill carries no meaning: nine keys are too few to identify anything on a
* generated from the id for the rest. Both are gone. The operator's verdict after four * board of any size, and a coloured board teaches the eye to read hue as significant.
* passes — "my coloring attempt has failed and nothing looks right… we've tried a lot * Colour lives on the TAG, beside the thing it names.
* 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.
* *
* The values are `neutral-900` on dark and white on light — exactly what the palette's * The values are `neutral-900` on dark and white on light, the same as the web card
* `default` always was, and exactly what the web card and both editors already use, so * and both editors. Mirrored as `NOTE_CARD_SURFACE` in `frontend/src/notes/colors.ts`
* 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`
* (`bg-white dark:bg-neutral-900`). * (`bg-white dark:bg-neutral-900`).
* *
* NOT a colour-scheme role: `surface` is the BOARD in this theme (neutral-50 / -950), * 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 * 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 * baseline M3 greys rather than to the web's neutrals.
* a role that nearly does.
* *
* Measured, against the operator's "not the same color as their background but close * The card fill is deliberately the WEAKEST contrast on the card:
* to it" — the card fill is deliberately the WEAKEST number on the card:
* *
* card vs board light #FFFFFF on #FAFAFA 1.04 * card vs board light #FFFFFF on #FAFAFA 1.04
* dark #171717 on #0A0A0A 1.10 * dark #171717 on #0A0A0A 1.10
@@ -498,10 +481,9 @@ private val CARD_ELEVATION = 1.dp
* muted vs card light #404040 on #FFFFFF 10.37 * muted vs card light #404040 on #FFFFFF 10.37
* dark #E5E5E5 on #171717 14.23 * dark #E5E5E5 on #171717 14.23
* *
* A card is not separated from the board by its fill and never was — the edge and the * The edge and the shadow separate a card from the board, not its fill, which is why
* shadow do that, which is why 1.04 is enough and why it has to stay near 1. A fill * 1.04 is enough and why it has to stay near 1: a fill that separated on its own would
* that separated on its own would be a panel, and a board of panels is the wall this * make a board of panels.
* whole line of work started from.
*/ */
fun noteCardSurface(dark: Boolean): Color = if (dark) CARD_SURFACE_DARK else CARD_SURFACE_LIGHT fun noteCardSurface(dark: Boolean): Color = if (dark) CARD_SURFACE_DARK else CARD_SURFACE_LIGHT
@@ -34,16 +34,14 @@ import kotlinx.coroutines.delay
/** /**
* The one writing surface: a new note and an existing one are the same screen. * 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 * Shaped like a sheet — a rounded card that begins below the status bar — so opening
* status bar — so opening a note still reads as something rising over the board * a note reads as something rising over the board rather than a place you navigated
* rather than a place you navigated to. It is full height rather than a real * to. It is full height rather than a real `ModalBottomSheet`: a sheet spends a
* `ModalBottomSheet`, and that is the whole trade: a sheet spends a writing session * writing session negotiating with the IME for the bottom half of the display, and
* negotiating with the IME for the bottom half of the display, and the swipe-down it * the swipe-down it buys is a gesture back already does.
* buys is a gesture back already does. The shape is what was worth keeping.
* *
* The card's surface paints the WHOLE sheet rather than a panel inside it, so opening * 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 * a note reads as the same object growing to fill the display.
* true since M315, where the board and the editor became the same one neutral.
* *
* No save button, deliberately. Writes are continuous, so a button offering to do * 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 * 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. // throwing away every character typed during the write.
// //
// BLOCKS rather than one string, because a checklist item is drawn as a real // 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. // 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 // 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. // 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 // 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. // rows that are the owner's (tags, reminder, files, previews) stay inert.
val access = note.access val access = note.access
val readOnly = note.readOnlyHere val readOnly = note.readOnlyHere
@@ -125,10 +123,8 @@ fun NoteEditorScreen(
// Idle-debounced autosave. LaunchedEffect cancels and restarts on every // Idle-debounced autosave. LaunchedEffect cancels and restarts on every
// keystroke, so the delay only ever elapses once typing stops. // keystroke, so the delay only ever elapses once typing stops.
// //
// Saving this often is affordable because a body write no longer costs a // Saving this often is affordable because a body write doesn't cost a revision:
// revision: history snapshots once per editing session rather than once per // history snapshots once per editing session rather than once per save.
// save. Before that, writing was expensive enough that this editor hoarded
// text until it closed — and an app kill mid-session lost the lot.
// //
// For a note that does not exist yet this is also what CREATES it, which is why // 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 // every toolbar button works moments after the first keystroke rather than
@@ -173,8 +169,7 @@ fun NoteEditorScreen(
// container), which returns Unspecified for anything that is not a // container), which returns Unspecified for anything that is not a
// colour-SCHEME ROLE. The card surface is not one, so the default publishes // colour-SCHEME ROLE. The card surface is not one, so the default publishes
// Unspecified as LocalContentColor and everything inside that does not // Unspecified as LocalContentColor and everything inside that does not
// set its own colour draws black — which is how the last toolbar became // set its own colour draws black, invisible in dark mode.
// invisible in dark mode.
contentColor = MaterialTheme.colorScheme.onSurface, contentColor = MaterialTheme.colorScheme.onSurface,
) { ) {
Scaffold( Scaffold(
@@ -201,7 +196,7 @@ fun NoteEditorScreen(
onAction = onAction, 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 // 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 // way out. See [EditorFooter] for why the exit is down here and not
// only in the top-left corner. // only in the top-left corner.
@@ -242,8 +237,7 @@ fun NoteEditorScreen(
// A note is its body; its NAME is that body's first line, so there // A note is its body; its NAME is that body's first line, so there
// is nothing separate to type into and nothing rendered bolder than // is nothing separate to type into and nothing rendered bolder than
// the line beneath it (M13 steps 3 and 4). What 2992 changed is only // the line beneath it. Checklist items draw as boxes rather than as
// how the body is DRAWN — checklist items as boxes rather than as
// the markup for boxes. // the markup for boxes.
BlockBody( BlockBody(
blocks = blocks, blocks = blocks,
@@ -253,9 +247,9 @@ fun NoteEditorScreen(
onFocus = { focus = it }, onFocus = { focus = it },
) )
// No checklist section. The items ARE lines of the field above // No checklist section. The items ARE lines of the field above;
// (M304) — rendering them again down here is what would put every // rendering them again down here would put every list on screen
// list on screen twice. // twice.
if (note.labels.isNotEmpty()) { if (note.labels.isNotEmpty()) {
EditorLabelRow(note = note, readOnly = !ownerControls, onAction = onAction) EditorLabelRow(note = note, readOnly = !ownerControls, onAction = onAction)
@@ -350,6 +344,6 @@ private const val ANY_TYPE = "*/*"
/** /**
* The card's top corner radius — Material's extra-large, which is what a bottom * The card's top corner radius — Material's extra-large, which is what a bottom
* sheet uses. Same shape as the capture surface this replaced, on purpose. * sheet uses.
*/ */
private val SHEET_CORNER = 28.dp private val SHEET_CORNER = 28.dp
@@ -19,8 +19,8 @@ import com.fabledsword.inkwell.R
* a translucent colour over what's beneath exactly as CSS does, so a panel sits on the * a translucent colour over what's beneath exactly as CSS does, so a panel sits on the
* background the same way in both. * background the same way in both.
* *
* This table is the palette of MEANINGFUL colours: a tag's. A NOTE no longer has one * This table is the palette of MEANINGFUL colours: a tag's. A NOTE has none — the
* at all (M315) — the card is one neutral per theme, held in NoteCard.kt beside 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, * 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 * the inline `#tag`, and the panels and banners that borrow a hue to say what they
* are. * are.
@@ -55,8 +55,8 @@ data class NoteTint(
) { ) {
/** /**
* The pale fill of a PANEL, a banner, an update card or a picker swatch — the * 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 * places that borrow a hue to say what they are. Not a note's: a card has one
* has one neutral surface and does not come through this table at all. * neutral surface and does not come through this table at all.
*/ */
fun background(dark: Boolean): Color = if (dark) darkBackground else lightBackground fun background(dark: Boolean): Color = if (dark) darkBackground else lightBackground
@@ -69,19 +69,16 @@ data class NoteTint(
* *
* Kept apart from [tagInk] because the reminder pill is not a tag: it borrows the * 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` * 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 * text is transcribed from NoteCard.vue's literal classes, so it follows those,
* one value; making the tag ink one step deeper (M315) is where they parted, and * not [tagInk].
* moving the reminder with it would have silently broken that mirror instead.
*/ */
fun chipForeground(dark: Boolean): Color = if (dark) darkChipForeground else lightChipForeground 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. * 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 * A tag whose text is in the body is tinted in place rather than repeated as a
* printing every tag twice — once where it was typed, once at the top). It is * chip: the thing you see IS the thing you would delete to remove the tag.
* 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.
*/ */
fun tagInk(dark: Boolean): Color = if (dark) darkTagInk else lightTagInk fun tagInk(dark: Boolean): Color = if (dark) darkTagInk else lightTagInk
@@ -94,7 +91,7 @@ data class NoteTint(
* The fill only tints a shape the edge is drawing. * 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 * 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. * is the one coloured thing on it.
*/ */
fun chipBorder(dark: Boolean): Color = tagInk(dark).copy(alpha = CHIP_EDGE_ALPHA) fun chipBorder(dark: Boolean): Color = tagInk(dark).copy(alpha = CHIP_EDGE_ALPHA)
@@ -102,18 +99,9 @@ data class NoteTint(
// How strongly a chip's edge is drawn, as a fraction of its own ink. // 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 // SOLVED FOR, NOT GUESSED. The edge is the ink at alpha over the known card fill, and
// picked against the worst case of the time: a chip on a card of its OWN colour, back // 0.65 is the smallest alpha clearing WCAG 1.4.11's 3:1 for all ten hues: 3.03-4.36 in
// when a note took its first tag's fill. It gave 2.32:1 there, missed the 3:1 of WCAG // light, 4.52-5.76 in dark. 0.60 misses for six of the ten in light.
// 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.
// //
// Mirrored on the web as the `/65` in LABEL_CHIP_SHELL's ring. // Mirrored on the web as the `/65` in LABEL_CHIP_SHELL's ring.
private const val CHIP_EDGE_ALPHA = 0.65f private const val CHIP_EDGE_ALPHA = 0.65f
@@ -89,7 +89,7 @@ fun Notice(
/** /**
* One row of a dropdown menu, closing the menu before it acts. * 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 * 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 * actions in the same words. A second copy of this would be a second place for the
* closing order to be got wrong. * closing order to be got wrong.
@@ -13,6 +13,8 @@ import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier 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.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle 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 * container and the indicator at each site independently is how they drift apart, so
* it happens once, here. * it happens once, here.
* *
* The note EDITOR no longer comes through this. It dropped to `BasicTextField` * The note EDITOR doesn't use this: it uses `BasicTextField` (see `EditorBlock.kt`)
* (see `EditorBlock.kt`) for density: Material's field puts 16dp above and below its * for density, since Material's field puts 16dp above and below its text, which is
* text, which is right for a form and is the whole row height on a checklist. Nothing * right for a form and is the whole row height on a checklist.
* about "no box" was lost there — BasicTextField never had one.
* *
* The disabled colours are stripped too: a trashed note is shown through this * 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 * field read-only, and Material's disabled treatment would grey out text the user
@@ -54,7 +55,7 @@ fun PlainTextField(
TextField( TextField(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth().arrowKeysMoveCaret(atTop = { singleLine }, atBottom = { singleLine }),
enabled = enabled, enabled = enabled,
placeholder = hint?.let { { Text(stringResource(it)) } }, placeholder = hint?.let { { Text(stringResource(it)) } },
singleLine = singleLine, singleLine = singleLine,
@@ -86,3 +87,29 @@ private fun plainFieldColors(): TextFieldColors =
unfocusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = 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
}
@@ -12,7 +12,7 @@ import com.fabledsword.inkwell.core.Label
import com.fabledsword.inkwell.core.Note import com.fabledsword.inkwell.core.Note
/** /**
* The drawer lists the few tags in use rather than every tag (#5931), so a long * 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 * list never pushes Archive, Trash and Sync out of reach. Mirrors
* `frontend/src/notes/recentTags.ts`. * `frontend/src/notes/recentTags.ts`.
*/ */
@@ -59,7 +59,7 @@ private fun String?.millis(): Long = this?.let(::epochMillis) ?: 0L
/** /**
* When each tag's lens was last opened here. Opening one counts as using it, * 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 (#5931). * however it was opened: from the drawer, or a tag on a card.
*/ */
@Composable @Composable
internal fun rememberTagViews(destination: Destination): Map<String, Long> { internal fun rememberTagViews(destination: Destination): Map<String, Long> {
@@ -23,7 +23,7 @@ data class ShareState(
val noteId: String? = null, val noteId: String? = null,
val loading: Boolean = false, val loading: Boolean = false,
val members: List<Member> = emptyList(), 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 groups: List<ShareGroup> = emptyList(),
val shares: List<NoteShare> = emptyList(), val shares: List<NoteShare> = emptyList(),
/** This device has no server, and sharing is between people on one. */ /** This device has no server, and sharing is between people on one. */
@@ -65,7 +65,7 @@ private fun targetOf(
): ShareTarget = group?.let { ShareTarget.Group(it.id) } ?: ShareTarget.Member(member?.id.orEmpty()) ): 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 * 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 * here is a network round-trip through the core — `suspend` functions on uniffi's
@@ -118,12 +118,11 @@ fun SyncScreen(
/** /**
* The build, dim, at the foot of Sync — the same thing the web UI puts at the * 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 * With no version tags, an artifact's own self-report is the only answer to "which
* self-report is the only answer to "which build is this?" — so it renders * build is this?", so it renders "unknown" rather than nothing when the name is
* "unknown" rather than nothing when the name is absent, because a blank line looks * absent: a blank line looks like a layout bug.
* like a layout bug and a plausible default cannot be caught by anything.
* *
* The read itself is `installedVersionName()`, shared with the client header the * 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 * app sends its server: one answer to "which build is on this phone", so the line
@@ -27,8 +27,8 @@ enum class LinkMode { PASSWORD, TOKEN }
* A sealed type rather than six loose strings, and not only for the parameter * 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 * 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 * 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 * app — so a flat argument list would collect one in token mode and silently drop
* dropping it. Modelling it this way made that impossible to express. * it.
*/ */
sealed interface Credentials { sealed interface Credentials {
data class Password( data class Password(
@@ -112,7 +112,7 @@ data class SyncState(
* mid-sync. That is safe by construction rather than by luck: no async path in the * 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 * 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 * 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( class SyncViewModel(
private val core: Inkwell, private val core: Inkwell,
@@ -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 * 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. * 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 * It is also the ONLY colour control in the product, which is why the swatch is a
* control in the product. That is why the swatch is a first-class tap target on * first-class tap target on every row rather than something behind the overflow
* every row rather than something behind the overflow menu. * menu.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -289,9 +289,9 @@ private fun NewTagField(
} }
/** /**
* Said out loud rather than left as a blank screen — and it names the `#` route, * 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 * typing `#tag` in a note is the easiest way to make a tag, and easy not to know
* #2949) and this is the natural place to say so. * about.
*/ */
@Composable @Composable
private fun EmptyTags() { private fun EmptyTags() {
@@ -491,8 +491,7 @@ private fun MergeDialog(
} }
/** /**
* The palette, and since the per-note picker was removed this is the only place in * The palette: the only place in the product a colour is chosen.
* the product a colour is chosen.
* *
* Every key from [NOTE_TINTS], `default` included: a tag whose colour is * 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 * `default` gets a hue derived from its name (`DerivedTint.kt`), so "default" here
@@ -47,7 +47,7 @@ data class TagsState(
* ## Renaming can merge * ## Renaming can merge
* *
* `rename_label` folds two tags together when the new name is one another tag * `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 * 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 * existing. The screen asks first; this view model does not, because a
* confirmation belongs to the surface with a person in front of it. * confirmation belongs to the surface with a person in front of it.
@@ -61,8 +61,8 @@ private val DarkColors =
/** /**
* Material 3 in Inkwell's own colours, following the system light/dark setting. * Material 3 in Inkwell's own colours, following the system light/dark setting.
* *
* DELIBERATELY NOT Material You dynamic colour, which this used until the operator * DELIBERATELY NOT Material You dynamic colour. Dynamic colour is the more
* saw the first build. Dynamic colour is the more Android-native choice and it * Android-native choice and it
* makes the app look like a different product on the phone than on the desktop and * 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 * 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 * grey. The three surfaces are peers held to one quality bar, so they share one
@@ -51,7 +51,7 @@ fun UpdateCard(
// Re-read on every return to the app, as ReminderNotice does: the grant is given // 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 // 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 // be up after someone granted it and came back, and the offer below it would
// look blocked when it is not (family idea #5103, practice 9). // look blocked when it is not.
var canInstall by remember { mutableStateOf(AppUpdate.canInstall(context)) } var canInstall by remember { mutableStateOf(AppUpdate.canInstall(context)) }
ForegroundTransitions(onForeground = { canInstall = AppUpdate.canInstall(context) }) ForegroundTransitions(onForeground = { canInstall = AppUpdate.canInstall(context) })
@@ -125,11 +125,8 @@ fun UpdateCard(
} }
/** /**
* The nag: an update is waiting, said where someone will actually see it. * The nag: an update is waiting, said where someone will actually see it rather than
* * only on the sync screen.
* 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.
* *
* Only ever shown once the build is DOWNLOADED, so the offer is a single tap rather * 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 * than the start of a wait — and so nothing is said at all until the app has been on
@@ -175,8 +172,7 @@ private val BANNER_SPINNER = 18.dp
fun UnlinkedUpdateNote() { fun UnlinkedUpdateNote() {
Hint( Hint(
text = stringResource(R.string.update_needs_server), text = stringResource(R.string.update_needs_server),
// Carries the bottom breathing room the connect button used to provide, // The bottom breathing room: this is the last thing on the unlinked screen.
// now that it is the last thing on the unlinked screen.
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp), modifier = Modifier.padding(top = 8.dp, bottom = 24.dp),
) )
} }
@@ -58,7 +58,7 @@ data class UpdateState(
* **Linked-only, and said out loud.** The app is local-first and completely usable * **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. * 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 * 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 * 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 * Rust store and pulling it into Kotlin to make an HTTP call would spread the one
@@ -89,9 +89,8 @@ class UpdateViewModel(
/** /**
* The automatic path: look, fetch, then nag. * The automatic path: look, fetch, then nag.
* *
* Called when the app comes forward. Until this existed an update was only ever * Called when the app comes forward, so an update is found without anyone opening
* found by someone opening the sync screen and pressing a button — so the ones * the sync screen to look.
* that mattered were the ones nobody went looking for.
* *
* Skipped when a check is already in flight, when a build is already waiting, and * 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. * when one ran recently: flicking between two apps is not a request to re-check.
@@ -106,8 +105,7 @@ class UpdateViewModel(
// Already fetched and waved away — say so again. "Later" is for that // Already fetched and waved away — say so again. "Later" is for that
// sitting, not forever, and without this branch a single dismissal would // sitting, not forever, and without this branch a single dismissal would
// silence the update permanently. Which is precisely the "lost" this whole // silence the update permanently.
// path exists to prevent.
state.ready -> if (state.nagDismissed) state = state.copy(nagDismissed = false) state.ready -> if (state.nagDismissed) state = state.copy(nagDismissed = false)
// Found one and never fetched it — almost always because the last look // Found one and never fetched it — almost always because the last look
+9 -5
View File
@@ -82,7 +82,7 @@
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
<string name="editor_share">Share…</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. --> phone isn't linked to one. -->
<string name="share_title">Share</string> <string name="share_title">Share</string>
<string name="share_loading">Loading…</string> <string name="share_loading">Loading…</string>
@@ -116,6 +116,10 @@
<string name="attach_not_here">This file hasn\'t downloaded to this phone yet. Sync, then try again.</string> <string name="attach_not_here">This file hasn\'t downloaded to this phone yet. Sync, then try again.</string>
<string name="attach_no_app">No app on this phone opens this kind of file.</string> <string name="attach_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_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> <string name="preview_remove">Remove link preview</string>
<!-- Deleting for good is the only thing in the app that cannot be undone, so <!-- Deleting for good is the only thing in the app that cannot be undone, so
@@ -130,7 +134,7 @@
who it happens in. --> who it happens in. -->
<string name="capture_process_text">New note</string> <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. --> schema still says Label, and no string here needs to know that. -->
<string name="tags_manage">Manage tags</string> <string name="tags_manage">Manage tags</string>
<string name="tags_show_all">All tags (%1$d)</string> <string name="tags_show_all">All tags (%1$d)</string>
@@ -153,9 +157,9 @@
<string name="tags_rename">Rename</string> <string name="tags_rename">Rename</string>
<string name="tags_rename_title">Rename %1$s</string> <string name="tags_rename_title">Rename %1$s</string>
<string name="tags_rename_confirm">Rename</string> <string name="tags_rename_confirm">Rename</string>
<!-- Renaming onto an existing tag merges the two, older survives (Scribe <!-- Renaming onto an existing tag merges the two, older survives. A merge
#3324). A merge cannot be undone by repeating it and is reachable here by cannot be undone by repeating it and is reachable here by a typo, so it
a typo, so it says so before it happens — same reasoning as #2116. --> says so before it happens. -->
<string name="tags_rename_merges_title">Merge with %1$s?</string> <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_body">A tag called %1$s already exists. Renaming will merge these two into one, carrying every note from both. The notes are kept; one of the two tags stops existing, and that cannot be undone.</string>
<string name="tags_rename_merges_confirm">Merge</string> <string name="tags_rename_merges_confirm">Merge</string>
@@ -12,11 +12,8 @@ import org.junit.Test
* writes them out by hand, so changing the hash or the key order means changing the * 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. * 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 four UUID hashes pin the hash ITSELF; colours are derived only from a tag's
* the resolution order that chose between a picked colour, a tag's and a generated one * name.
* — 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.
*/ */
class DerivedTintTest { class DerivedTintTest {
@Test @Test
+7 -10
View File
@@ -3,20 +3,17 @@ name = "inkwell-uniffi-bindgen"
version = "0.1.0" version = "0.1.0"
description = "Generates the Kotlin bindings for inkwell-ffi" description = "Generates the Kotlin bindings for inkwell-ffi"
authors = ["bvandeusen"] authors = ["bvandeusen"]
license = "AGPL-3.0-only"
edition = "2021" edition = "2021"
# A crate whose ONLY dependency is uniffi itself. # A crate whose ONLY dependency is uniffi itself.
# #
# This started life as a `[[bin]]` inside inkwell-ffi, which failed: building # Not a `[[bin]]` inside inkwell-ffi: building that compiles the core, reqwest,
# it compiled that crate and therefore the core, reqwest, native-tls and # native-tls and openssl-sys for the HOST, and the vendored-OpenSSL block in
# openssl-sys — for the HOST. The vendored-OpenSSL block in core/Cargo.toml is # core/Cargo.toml is scoped to `cfg(target_os = "android")`, so a host build
# scoped to `cfg(target_os = "android")`, so a host build looks for a system # looks for a system OpenSSL that ci-rust-android doesn't carry. A code
# OpenSSL that ci-rust-android has no reason to carry, and the generator died # generator should not link the app's TLS stack to emit Kotlin; on its own it
# with "failed to run custom build command for openssl-sys". # compiles ~15 small crates.
#
# 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.
# #
# Still a WORKSPACE MEMBER, deliberately. That is what keeps `uniffi` here and # 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 # `uniffi` linked into the .so on one version from one lockfile — they are two
+2 -2
View File
@@ -1,8 +1,8 @@
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
// kotlin-android is NOT registered: AGP 9 enables built-in Kotlin, and the // kotlin-android is NOT registered: AGP 9 enables built-in Kotlin, and the
// older plugin can't cast AGP 9's ApplicationExtension to the removed // older plugin can't cast AGP 9's ApplicationExtension to the BaseExtension
// BaseExtension. Same conclusion Minstrel reached on this toolchain pair. // AGP 9 no longer has.
alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.compose.compiler) apply false
// ktlint/detekt are run from the CI image's pinned CLIs, not as Gradle // ktlint/detekt are run from the CI image's pinned CLIs, not as Gradle
// plugins — see the note in gradle/libs.versions.toml. // plugins — see the note in gradle/libs.versions.toml.
+3 -3
View File
@@ -1,8 +1,8 @@
# Per-rule overrides layered on top of detekt's defaults # Per-rule overrides layered on top of detekt's defaults
# (`--build-upon-default-config` on the CLI invocation in the Android lane). # (`--build-upon-default-config` on the CLI invocation in the Android lane).
# #
# The pre-2.0 `build:` top-level was removed; failure is controlled by the CLI's # Failure is controlled by the CLI's exit code; detekt 2 has no `build:`
# exit code instead. # top-level.
naming: naming:
# Composables conventionally use PascalCase function names. Matches every # Composables conventionally use PascalCase function names. Matches every
@@ -49,7 +49,7 @@ complexity:
exceptions: exceptions:
TooGenericExceptionCaught: TooGenericExceptionCaught:
# Catching broadly is DELIBERATE in these two places, and each site says so. # Catching broadly is DELIBERATE in these places, and each site says so.
# #
# * the ViewModel — a note that fails to save must become a visible error # * the ViewModel — a note that fails to save must become a visible error
# banner, never a crash. Narrowing this would mean an unanticipated # banner, never a crash. Narrowing this would mean an unanticipated
+1
View File
@@ -3,6 +3,7 @@ name = "inkwell-ffi"
version = "0.1.0" version = "0.1.0"
description = "uniffi bindings exposing inkwell-core to the native Android client" description = "uniffi bindings exposing inkwell-core to the native Android client"
authors = ["bvandeusen"] authors = ["bvandeusen"]
license = "AGPL-3.0-only"
edition = "2021" edition = "2021"
[lib] [lib]
+16 -21
View File
@@ -3,7 +3,7 @@
//! This crate is to Android what `desktop/src-tauri/src/commands/` is to the desktop //! This crate is to Android what `desktop/src-tauri/src/commands/` is to the desktop
//! — a thin shim over the shared core, holding no logic of its own. If something here //! — a thin shim over the shared core, holding no logic of its own. If something here
//! starts making decisions about notes or sync, it belongs in the core where the //! starts making decisions about notes or sync, it belongs in the core where the
//! desktop gets it too (Scribe note 2730). //! desktop gets it too.
//! //!
//! ## Shape //! ## Shape
//! //!
@@ -30,9 +30,7 @@
//! The Android build overrides it back to `unwind` (`CargoNdkBuild` in //! The Android build overrides it back to `unwind` (`CargoNdkBuild` in
//! `app/build.gradle.kts`), so uniffi catches a panic crossing the FFI boundary and //! `app/build.gradle.kts`), so uniffi catches a panic crossing the FFI boundary and
//! raises it in Kotlin as an exception, which the board shows as an error. Under //! raises it in Kotlin as an exception, which the board shows as an error. Under
//! `abort` the same panic would end the app. Every phone build had unwind before //! `abort` the same panic would end the app. The cost is library size.
//! the release profile was used (debug unwinds), so keeping it changes nothing a
//! phone does. The cost is library size (Scribe #2810).
pub mod models; pub mod models;
@@ -105,7 +103,7 @@ impl CoreError {
} }
/// Seals the device token before it is stored, and opens it when it is read: Kotlin's /// Seals the device token before it is stored, and opens it when it is read: Kotlin's
/// half of [`state::TokenSeal`] (family idea #5105, practice 12). /// half of [`state::TokenSeal`].
/// ///
/// Implemented with a key in the Android Keystore. The key never leaves the phone, /// Implemented with a key in the Android Keystore. The key never leaves the phone,
/// so a copy of the app's files carries a token nothing else can open. That includes /// so a copy of the app's files carries a token nothing else can open. That includes
@@ -255,7 +253,7 @@ impl Inkwell {
/// ///
/// Returns nothing, as the board reloads its list afterwards anyway. The core marks /// Returns nothing, as the board reloads its list afterwards anyway. The core marks
/// each note dirty so the new positions sync, and a note shared with this account /// each note dirty so the new positions sync, and a note shared with this account
/// moves on this board only (#5176). /// moves on this board only.
pub fn reorder_notes(&self, ordered_ids: Vec<String>) -> Result<(), CoreError> { pub fn reorder_notes(&self, ordered_ids: Vec<String>) -> Result<(), CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?; let conn = self.db.conn().map_err(CoreError::store)?;
local::store::reorder(&conn, &ordered_ids).map_err(CoreError::store) local::store::reorder(&conn, &ordered_ids).map_err(CoreError::store)
@@ -600,11 +598,11 @@ impl Inkwell {
// ────────────────────────────── sharing ────────────────────────────── // ────────────────────────────── sharing ──────────────────────────────
// //
// The Share dialog asks the server directly (#5175). Unlinked, each answers // The Share dialog asks the server directly. Unlinked, each answers
// `NotLinked`, which the dialog turns into "sharing needs a server". // `NotLinked`, which the dialog turns into "sharing needs a server".
// //
// Each passes the token `credentials()` OPENED. The stored one is sealed, and the // Each passes the token `credentials()` OPENED. The stored one is sealed, and the
// server refuses `sealed:…` (#5381). // server refuses `sealed:…`.
/// Everyone on the instance a note can be shared with, and every group. /// Everyone on the instance a note can be shared with, and every group.
pub async fn share_directory(&self) -> Result<Directory, CoreError> { pub async fn share_directory(&self) -> Result<Directory, CoreError> {
@@ -682,10 +680,8 @@ pub fn checklist_render(text: String, checked: bool) -> String {
/// Tell the core which app it is running inside, and which build of it. /// Tell the core which app it is running inside, and which build of it.
/// ///
/// Android has to say so because the core cannot: the same crate is compiled into /// Android has to say so because the core cannot: the same crate is compiled into
/// the desktop app, and it used to announce every phone in the field as /// the desktop app. The honest value is the installed package's own `versionName`,
/// `inkwell-desktop` carrying the CORE crate's version — a number no build /// which is what Kotlin passes here.
/// stamps and nobody has seen. The honest value is the installed package's own
/// `versionName`, which is what Kotlin passes here.
/// ///
/// Called once from `InkwellApplication.onCreate`, before anything can sync. /// Called once from `InkwellApplication.onCreate`, before anything can sync.
#[uniffi::export] #[uniffi::export]
@@ -820,7 +816,7 @@ mod tests {
.get_note(created.id.clone()) .get_note(created.id.clone())
.expect("get should succeed"); .expect("get should succeed");
assert_eq!(fetched.id, created.id); assert_eq!(fetched.id, created.id);
// The NAME is the first line — there is no title field to have set (M13 step 3). // The NAME is the first line — there is no title field to have set.
assert_eq!(fetched.display_title, "Groceries"); assert_eq!(fetched.display_title, "Groceries");
std::fs::remove_dir_all(&dir).ok(); std::fs::remove_dir_all(&dir).ok();
@@ -1041,9 +1037,8 @@ mod tests {
/// The path the notification's Done button takes. /// The path the notification's Done button takes.
/// ///
/// Completing a RECURRING reminder must move it, not end it — this is the /// Completing a RECURRING reminder must move it, not end it, as on the web;
/// behaviour the web has had all along and the clients did not, which made /// otherwise "Done" on a daily reminder would be the last time it ever fired.
/// "Done" on a daily reminder quietly the last time it ever fired.
#[test] #[test]
fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() { fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() {
let dir = scratch_dir(); let dir = scratch_dir();
@@ -1103,9 +1098,9 @@ mod tests {
/// Renaming a tag onto a name another tag already holds MERGES the two, and the /// Renaming a tag onto a name another tag already holds MERGES the two, and the
/// OLDER row is the survivor. /// OLDER row is the survivor.
/// ///
/// Before this, the bare UPDATE met `idx_labels_name` — unique on `lower(name)` — /// A bare UPDATE would meet `idx_labels_name` (unique on `lower(name)`) and hand
/// and the user got a raw "UNIQUE constraint failed" from SQLite. Merging is what /// the user a raw "UNIQUE constraint failed"; merging is what a person means by
/// a person means by typing an existing tag's name onto this one. /// typing an existing tag's name onto this one.
#[test] #[test]
fn renaming_onto_an_existing_tag_merges_into_the_older_one() { fn renaming_onto_an_existing_tag_merges_into_the_older_one() {
let dir = scratch_dir(); let dir = scratch_dir();
@@ -1215,8 +1210,8 @@ mod tests {
(url, handle) (url, handle)
} }
/// #5381: the token is stored sealed, so a share call has to send the OPENED one. /// The token is stored sealed, so a share call has to send the OPENED one; the
/// It used to read the store itself and send `sealed:…`, which the server refused. /// server refuses `sealed:…`.
#[test] #[test]
fn sharing_sends_the_opened_token_not_the_stored_one() { fn sharing_sends_the_opened_token_not_the_stored_one() {
let (url, server) = one_reply(r#"{"members":[],"groups":[]}"#); let (url, server) = one_reply(r#"{"members":[],"groups":[]}"#);
+5 -5
View File
@@ -46,7 +46,7 @@ pub struct Note {
pub previews: Vec<LinkPreview>, pub previews: Vec<LinkPreview>,
pub created_at: Option<String>, pub created_at: Option<String>,
pub updated_at: Option<String>, pub updated_at: Option<String>,
/// How this account holds the note (#5175): "owner", or "edit"/"view" for one /// How this account holds the note: "owner", or "edit"/"view" for one
/// someone shared with it. /// someone shared with it.
pub permission: String, pub permission: String,
/// Whether the owner has shared it with anyone. /// Whether the owner has shared it with anyone.
@@ -335,7 +335,7 @@ pub struct Label {
/// matching the REST single-label responses. /// matching the REST single-label responses.
pub count: Option<i64>, pub count: Option<i64>,
/// The newest edit among the untrashed notes carrying it, as the store writes /// The newest edit among the untrashed notes carrying it, as the store writes
/// timestamps; None when nothing does. Ranks the drawer's recent tags (#5931). /// timestamps; None when nothing does. Ranks the drawer's recent tags.
pub last_used: Option<String>, pub last_used: Option<String>,
} }
@@ -424,7 +424,7 @@ impl From<NoteFacets> for core_models::Facets {
#[derive(Debug, Clone, uniffi::Record)] #[derive(Debug, Clone, uniffi::Record)]
pub struct NoteDraft { pub struct NoteDraft {
pub body: String, pub body: String,
/// Checklist lines. A note can carry both a body and items (M13 step 2), so this /// Checklist lines. A note can carry both a body and items, so this
/// is not an alternative to `body` — it is an addition to it. /// is not an alternative to `body` — it is an addition to it.
pub items: Option<Vec<String>>, pub items: Option<Vec<String>>,
} }
@@ -616,7 +616,7 @@ impl From<core_client::Identity> for Identity {
} }
} }
/// Someone on the instance a note can be shared with (#5175). /// Someone on the instance a note can be shared with.
#[derive(Debug, Clone, uniffi::Record)] #[derive(Debug, Clone, uniffi::Record)]
pub struct Member { pub struct Member {
pub id: String, pub id: String,
@@ -639,7 +639,7 @@ impl From<core_client::Member> for Member {
} }
} }
/// A group the admin made, which a note can be shared with (#5177). /// A group the admin made, which a note can be shared with.
#[derive(Debug, Clone, uniffi::Record)] #[derive(Debug, Clone, uniffi::Record)]
pub struct ShareGroup { pub struct ShareGroup {
pub id: String, pub id: String,
+5 -5
View File
@@ -1,7 +1,7 @@
# --enable-native-access=ALL-UNNAMED silences the JDK 22+ "restricted method in # --enable-native-access=ALL-UNNAMED silences the JDK 22+ "restricted method in
# java.lang.System has been called" warning that Gradle 9.1's bundled # java.lang.System has been called" warning that Gradle 9.1's bundled
# native-platform jar trips via System.load(). Same opt-in Minstrel needs on the # native-platform jar trips via System.load(); future JDKs promote the warning
# same Gradle/JDK pair; future JDKs promote the warning to an error. # to an error.
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 --enable-native-access=ALL-UNNAMED org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 --enable-native-access=ALL-UNNAMED
org.gradle.parallel=true org.gradle.parallel=true
org.gradle.caching=true org.gradle.caching=true
@@ -9,8 +9,8 @@ org.gradle.configuration-cache=true
android.useAndroidX=true android.useAndroidX=true
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
# Matches Minstrel: detekt 2.0-alpha and the ktlint Gradle plugin still have # detekt 2.0-alpha and the ktlint Gradle plugin have intermittent
# intermittent configuration-cache holes. Warn rather than fail so the CC speedup # configuration-cache holes. Warn rather than fail so the CC speedup applies
# applies where it can. # where it can.
org.gradle.configuration-cache.problems=warn org.gradle.configuration-cache.problems=warn
kotlin.code.style=official kotlin.code.style=official
+7 -10
View File
@@ -1,5 +1,5 @@
[versions] [versions]
# Pinned as a MATRIX, matching Minstrel's proven combination on the same JDK: # Pinned as a MATRIX:
# - Gradle 9.1.0 supports JDK 25 (see gradle-wrapper.properties) # - Gradle 9.1.0 supports JDK 25 (see gradle-wrapper.properties)
# - AGP 9.0.1 requires Gradle 9.1.0+ # - AGP 9.0.1 requires Gradle 9.1.0+
# - Kotlin 2.3.x is AGP 9's built-in Kotlin path # - Kotlin 2.3.x is AGP 9's built-in Kotlin path
@@ -14,16 +14,13 @@ coroutines = "1.9.0"
# WorkManager runs the background sync. `work-runtime-ktx` is NOT used: as of # WorkManager runs the background sync. `work-runtime-ktx` is NOT used: as of
# 2.11 it is a 6 KB stub and every Kotlin extension (`PeriodicWorkRequestBuilder`, # 2.11 it is a 6 KB stub and every Kotlin extension (`PeriodicWorkRequestBuilder`,
# `CoroutineWorker`) has moved into `work-runtime` itself. Verified by unpacking # `CoroutineWorker`) has moved into `work-runtime` itself.
# both artifacts, not from memory.
work = "2.11.2" work = "2.11.2"
# ktlint and detekt are NOT Gradle plugins here. ci-rust-android already ships # ktlint and detekt are NOT Gradle plugins here. ci-rust-android ships both as
# both as pinned CLIs (M12 step 3), and the CI lane invokes those directly. Adding # pinned CLIs, and the CI lane invokes those directly. The Gradle plugins would be
# the Gradle plugins would mean a SECOND pinned version of each tool, resolved at # a SECOND pinned version of each tool, resolved at build time, that has to be
# build time, that has to be kept in lockstep with the image's by hand — and the # kept in lockstep with the image's by hand.
# first attempt at it failed outright, because the detekt version Minstrel pins
# (2.0.0-alpha.3) is not published to Maven Central or the plugin portal at all.
# JNA is not optional: uniffi's Kotlin bindings call into the .so through it. # JNA is not optional: uniffi's Kotlin bindings call into the .so through it.
# The @aar classifier matters — the plain jar has no Android native payload and # The @aar classifier matters — the plain jar has no Android native payload and
@@ -32,7 +29,7 @@ jna = "5.14.0"
junit = "4.13.2" junit = "4.13.2"
# Coil draws a link preview's image (Scribe #3307), the first image this client # Coil draws a link preview's image, the first image this client
# loads. Coil 3 ships its networking separately; the OkHttp fetcher is the one its # loads. Coil 3 ships its networking separately; the OkHttp fetcher is the one its
# docs name as the default, and it registers itself, so no loader is configured. # docs name as the default, and it registers itself, so no loader is configured.
# Held at 3.5.0: 3.6.0 moved to compileSdk 37 and Compose 1.12, which need AGP 9.1. # Held at 3.5.0: 3.6.0 moved to compileSdk 37 and Compose 1.12, which need AGP 9.1.
+1 -2
View File
@@ -44,8 +44,7 @@ def supplied_args(rest):
"""Count top-level commas in an argument tail. """Count top-level commas in an argument tail.
Kotlin permits a TRAILING comma before the closing paren, which is not an Kotlin permits a TRAILING comma before the closing paren, which is not an
argument — counting it inflated every multi-line call by one the first time argument; counting it would inflate every multi-line call by one. Braces count
this was written, and made three correct call sites look broken. Braces count
toward depth as well as parens, or a comma inside a lambda would be read as toward depth as well as parens, or a comma inside a lambda would be read as
another argument. another argument.
""" """
+5 -11
View File
@@ -1,11 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Flag capitalised identifiers that are neither imported nor declared locally. """Flag capitalised identifiers that are neither imported nor declared locally.
This exists because ktlint and detekt are both structurally blind to it: they ktlint and detekt parse Kotlin without resolving symbols, so a *missing import*
parse Kotlin without resolving symbols, so a *missing import* is invisible to is invisible to them and both pass a file that cannot compile.
them and both pass a file that cannot compile. The first sync-screen push failed
in CI on exactly that (`Unresolved reference 'Build'` — `android.os.Build` was
lost in a file split), after a clean local analyzer run.
Not a type checker and not trying to be. `compileDebugKotlin` in CI is the real Not a type checker and not trying to be. `compileDebugKotlin` in CI is the real
one; this is a cheap pre-push filter for the single mistake that survives every one; this is a cheap pre-push filter for the single mistake that survives every
@@ -13,10 +10,8 @@ other local gate. It errs toward false positives — anything it cannot account
for is reported rather than assumed fine. for is reported rather than assumed fine.
It also checks MEMBERS of this package's own `object` declarations — `Foo.bar()` It also checks MEMBERS of this package's own `object` declarations — `Foo.bar()`
where `Foo` is an object declared here. That case was added after moving a where `Foo` is an object declared here — so a function moved between two objects
function between two objects and forgetting to paste it into the second: the and missed at a call site is caught.
call site read `Other.thing()`, resolved fine as far as the leading token, and
failed in CI (785ebdb).
Still NOT caught, so a clean run is not over-read: members of anything declared Still NOT caught, so a clean run is not over-read: members of anything declared
outside this package, members reached through a variable rather than a type outside this package, members reached through a variable rather than a type
@@ -106,8 +101,7 @@ def object_members(src: str) -> dict:
if depth == 0: if depth == 0:
# Nested TYPES count as members too: `Foo.Bar` where Bar is a # Nested TYPES count as members too: `Foo.Bar` where Bar is a
# data class inside object Foo is an ordinary reference, and # data class inside object Foo is an ordinary reference, and
# leaving them out made the checker report four false positives # leaving them out would report each one as unknown.
# the first time an object held one.
decl = re.match( decl = re.match(
r"\s*(?:@\w+\s+)*(?:public |private |internal |protected )?" r"\s*(?:@\w+\s+)*(?:public |private |internal |protected )?"
r"(?:const |lateinit |inline |suspend |data |sealed |enum |value |abstract |open )*" r"(?:const |lateinit |inline |suspend |data |sealed |enum |value |abstract |open )*"
+258 -374
View File
@@ -3,317 +3,173 @@
> Spec lives in [`docs/process.md`](https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md) > Spec lives in [`docs/process.md`](https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md)
> in the CI-Runner repo. > in the CI-Runner repo.
## Runtime image ## Runtime images
``` | Image | Used by |
git.fabledsword.com/bvandeusen/ci-python:3.14 |---|---|
``` | `git.fabledsword.com/bvandeusen/ci-python:3.14` | every `ci.yml` job; the `decide` jobs in `desktop.yml` and `android.yml` |
| `git.fabledsword.com/bvandeusen/ci-tauri:1.97` | `desktop.yml` `verify`, `build`, `manifest`; `android.yml` `rust` |
| `git.fabledsword.com/bvandeusen/ci-tauri-win:1.97` | `desktop.yml` `windows` |
| `git.fabledsword.com/bvandeusen/ci-rust-android:1.97` | `android.yml` `build`, `server-image` |
| `postgres:16-alpine` | service container for `ci.yml` `integration` |
Selected via `container.image` (not a `runs-on` label) on all four jobs in Each is selected via `container.image`; `runs-on` is only a scheduling label.
`.forgejo/workflows/ci.yml`: typecheck (Vue/TS), lint (ruff), test (pytest),
build (docker buildx). The Rust pin is in LOCKSTEP across `ci-tauri`, `ci-tauri-win` and `ci-rust-android`.
All three build `inkwell-core` from one workspace `Cargo.lock` under `--locked`, so a
mismatched Rust minor would mean divergent resolution for no reason. Bump them
together or not at all.
## Image deps used ## Image deps used
- python 3.12+ (the runtime `Dockerfile` targets python:3.12-slim; tests run on **ci-python**
the image's 3.14 — both >=3.12, so results stay representative) - python 3.14 for the tests. The runtime `Dockerfile` targets `python:3.12-slim`;
- node 24 — `npm ci` + `vue-tsc` in the typecheck job, and the frontend builder both are >=3.12, so results stay representative.
stage inside the production `Dockerfile`. (Also required by the JS-based - node 24 — `npm ci`, `vue-tsc` and the web unit tests. Also required by the JS-based
`actions/checkout` action — a Node-less runner fails every job at checkout.) `actions/checkout`: a Node-less runner fails every job at checkout. The
- ruff — lint job runs `ruff check src/` with zero install overhead `Dockerfile`'s own frontend stage uses `node:22-alpine`.
- uv — test job creates the venv (`uv venv /opt/venv`) and installs the package - ruff — the lint job runs `ruff check src/` with no install.
with dev deps - uv — the test jobs create the venv (`uv venv /opt/venv`) and install the package
- docker CLI + buildx — build job pushes the dev/release image to the Fabled-Git with dev deps.
registry - docker CLI + buildx — the build job pushes the image to the Fabled-Git registry.
- curl — `packaging/fetch-clients.sh` and `packaging/guard-forward.sh`. The guard
refuses to run without it, because every lookup would read as "nothing published".
**ci-tauri** — Rust, Node, WebKitGTK 4.1, the Tauri v2 Linux deps and `tauri-cli`.
- **`libssl-dev` + `pkg-config` are load-bearing.** `inkwell-core`, which the desktop
crate builds in, uses `reqwest` with the **`native-tls`** backend, which on Linux compiles against
OpenSSL. Dropping either fails the build at `openssl-sys`. They are also Tauri's
documented Linux prerequisites. This is why `android.yml`'s host-target `rust` job
runs here and not on `ci-rust-android`, whose OpenSSL is vendored for the Android
targets alone.
- `dpkg-deb`, `dpkg-query`, `apt-cache` and `dpkg-shlibdeps` (from `dpkg-dev`, pulled
in by `build-essential`) for `desktop/packaging/deb/verify.sh`; `tar` and a
compressor for `desktop/packaging/arch/package-prebuilt.sh`.
- Optional, used when present: `bsdtar` (`libarchive-tools`) for the pacman package's
`.MTREE`, `zstd` (compression falls back zstd → xz → gzip), and a docker CLI for the
`.deb` clean-container install test. `ci-tauri` has the first two, so the package
ships as `.pkg.tar.zst` with an `.MTREE`. It has no docker CLI, so the install test
is skipped; adding one would enable it, and its absence does not block a green
build.
**ci-tauri-win** — Rust, Node, `cargo-xwin`, LLVM/`lld` and NSIS. A separate image
per CI-Runner's fork rule: the MSVC CRT/SDK cache alone is >1 GB.
**ci-rust-android** — Rust, the four Android ABIs, `cargo-ndk`, SDK/NDK, JDK 25,
`ktlint` and `detekt`. The NDK binutils symlinks and PATH append are there because NDK
r23 removed the triple-prefixed binutils that autotools, and so vendored OpenSSL,
invokes by bare name. JDK 25 requires **Gradle 9.1+** in the wrapper. The analyzers
are the image's pinned CLIs rather than Gradle plugins, so there is one version of
each.
## Per-job tool installs ## Per-job tool installs
Nothing installed at job time beyond what the image provides — all four jobs run None. Every job runs entirely on its image.
entirely on `ci-python:3.14`.
## The lanes
**`ci.yml`** — on every push to `dev` and `main`, and when dispatched.
`gate`, `typecheck`, `lint`, `test` and `integration` run in parallel, and `build`
needs all five, so nothing publishes on red. `dev` publishes `:dev`; `main` publishes
`:latest` and `:<sha>`. The image always builds: it is ~15 seconds, and building every
push picks up `python:3.12-slim` updates.
`gate` stands the build down when the push touched the Android client's file set.
The Android lane then dispatches `ci.yml` once it has published the APK, so the image
bakes in the client from the same commit.
**`desktop.yml`** — on every push to `dev` and `main`.
- `decide` reads the file set in `packaging/version.sh` and skips everything when the
channel already serves this source. `packaging/guard-forward.sh` runs here, so it
also covers the skip path.
- `verify` builds the frontend, runs the web unit tests, then `cargo clippy --locked
--workspace`, `cargo test --workspace` and `cargo fmt --all --check` from the repo
root. Run from `desktop/src-tauri` instead, cargo would scope to the desktop package
and the core's tests would silently not run. If you add a workspace member, check
that it appears in the `cargo test` output.
- `build` (Linux): `cargo tauri build` for `.deb` and AppImage, de-bundles the
AppImage's graphics libs and re-signs it, verifies the `.deb`, repackages for pacman,
and publishes to the channel. `APPIMAGE_EXTRACT_AND_RUN=1` is set because CI
containers have no `/dev/fuse`.
- `windows`: cross-compiles the NSIS installer. There is no Windows build host, and a
Windows container cannot run on a Linux host. `.msi` needs WiX v3, a Windows
program, so NSIS only.
- `manifest` writes `latest.json` after both, so it describes both platforms.
**`android.yml`** — on every push to `dev` and `main`.
`decide`, then `rust` (the core's clippy and tests, which gate the APK), then `build`
(native library and bindings, ktlint, detekt, host-JVM unit tests, assemble, check
the signing certificate, publish), then `server-image`, which dispatches `ci.yml`.
**`release.yml`** — on a `v*` tag. Builds nothing; it writes the changelog for the
release.
## Notes ## Notes
- **No `actions/cache`.** Deliberately omitted for npm/uv: it's a GitHub-fetched - **No `actions/cache` for npm or uv.** It is a GitHub-fetched JS action, and on a
JS action and on a cold runner concurrent jobs race fetching it. We lean on the cold runner concurrent jobs race fetching it. The pinned images' toolchains carry
pinned `ci-python` image's pre-installed toolchain instead; `npm ci` / `uv pip the load; cold `npm ci` / `uv pip install` cost is a non-blocker.
install` cold cost is a non-blocker. - **Artifacts: stock `actions/upload-artifact@v7`, never `@v3`.** Stock works on this
- Build gates on `typecheck` + `lint` only. The `test` job runs in parallel for forge because gitea/runner 3.x edits the action's client-side `isGhes()` refusal out
visibility but does not block the dev image push. DB-backed / integration tests of its bundle (verified for upload-artifact v4–v7 and download-artifact v4–v8). `@v3`
run against the dev image manually — Inkwell's unit tests are DB-free (no reports success, but Gitea serves artifacts back only through the v4 API
Postgres service lane in CI yet). (`content_encoding = application/zip`), so a v3 upload is stored and invisible to
- `dev` push -> `:dev` + `:<sha>`; `v*` tag -> `:latest` + `:<version>` + `:<sha>` every retrieval path. Upload steps set `if-no-files-found: error` and carry no
(family rule 46). `continue-on-error`, so a failed or empty upload fails the run.
- The production runtime `Dockerfile` tracks python:3.12 so test results stay
representative of the deployed image.
- **Artifacts — stock `actions/upload-artifact@v7`, never `@v3`.**
```yaml
uses: actions/upload-artifact@v7
```
Stock works on this forge since the runner moved to gitea/runner 3.x, which
edits the action's client-side `isGhes()` refusal out of its bundle. Proven on
2026-09-10 for upload-artifact v4–v7 and download-artifact v4–v8 (Scribe spike
#3843). Until then this repo pinned a SHA mirror of the Forgejo project's
fork, because upstream threw on the hostname before it opened a connection.
`@v3` is still broken: it reports success, and Gitea serves artifacts back only Download: `GET /api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts` for the
through the v4 API (`content_encoding = application/zip`), so a v3 upload is id (the global run id, not the repo-scoped run number), then
stored but invisible to every retrieval path. A green job producing nothing `…/actions/artifacts/{id}/zip`. Without `unzip`, use `python3 -m zipfile -e`.
retrievable. - **`libssl3` is covered transitively, on purpose — don't "fix" it.**
`dpkg-shlibdeps` lists `libssl3` among the binary's needs, but the `.deb` declares
Both desktop upload steps also set `if-no-files-found: error` and carry **no** only `libwebkit2gtk-4.1-0` + `libgtk-3-0`. `verify.sh` passes it because webkit's
`continue-on-error`. They previously had both defaults inverted, which is how own dependency closure includes OpenSSL, so apt installs it either way. Declaring it
110 unreachable artifacts accumulated on this repo without anyone noticing — would be *worse*: the name is release-dependent (`libssl3` on bookworm,
the upload could fail or match nothing and the run still went green. Scribe `libssl3t64` after the 64-bit time_t transition in trixie/Ubuntu 24.04). If webkit
issues 2255 / 2270 have the full teardown. ever stops pulling OpenSSL, `verify.sh` fails the build.
- **The Windows TLS backend is chosen for that lane's sake.** `native-tls` on
Download: `GET /api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts` `x86_64-pc-windows-msvc` resolves to `schannel`, pure-Rust bindings to the OS TLS
for the id (global run id, not the repo-scoped run number), then stack, which keeps C and assembly out of the cross-compile. `rustls` would pull in
`…/actions/artifacts/{id}/zip`. Note the workstation has no `unzip` — use `ring`/`aws-lc-rs` and their assembler, the class of dependency that breaks this
`python3 -m zipfile -e`. lane (`libsqlite3-sys` → `llvm-lib`). Treat a TLS-backend change as a change to the
Windows lane.
- **What CI cannot verify.** The runner is Debian, so the pacman package is not
`pacman -U`-tested; the step logs `.PKGINFO` and the file listing instead. A Linux
runner cannot launch a Windows binary or an APK, so green means *built*: a real
Windows machine and a real phone are the check before trusting a release. Windows
installers are unsigned and trip SmartScreen.
## The integration lane ## The integration lane
Added 2026-08-23. Before it, `alembic upgrade head` ran for the first time when the Real Postgres, so the schema the migrations build is checked against the models that
operator's container started — 26 revisions, none of them ever executed by CI — and read it before any deployment runs `alembic upgrade head`.
the schema the migrations build had never been checked against the models that read
it. M13 dropped three columns and rebuilt a STORED GENERATED column with nothing
watching.
Copied from FabledScribe's `integration` job, which had already solved the awkward - **Job key `integration`, no `name:`.** act_runner derives the service container's
parts. Three of them are family rules for a reason: name from the truncated job DISPLAY name, and the discovery step filters `docker ps`
by it. A spaced or underscored name breaks the filter.
- **Job key `integration`, no `name:`** (rule 80). act_runner derives the service - **Service hostnames are not routable** on this runner, so the step resolves the
container's name from the truncated job DISPLAY name, and the discovery step filters Postgres container's bridge IP and builds `INKWELL_DATABASE_URL` from it.
`docker ps` by it. A spaced or underscored name breaks the filter. `postgres:5432` will not connect. The runner's docker daemon is shared, so the
- **Service hostnames are not routable** on this runner (rule 79), so the step resolves lookup is scoped to this task's own containers.
the Postgres container's bridge IP with `docker ps --filter` + `docker inspect` and - **`run:` is busybox sh** — no `/dev/tcp` — so the readiness wait is a small Python
builds `INKWELL_DATABASE_URL` from it. `postgres:5432` will not connect. heredoc. Its terminator must dedent to column 0 after YAML strips the block indent;
- **`run:` is busybox sh** (rule 81) — no `/dev/tcp` — so the readiness wait is a small check with `yaml.safe_load` and print the `run` string if you edit it.
Python heredoc. Its terminator must dedent to column 0 after YAML strips the block - **Real migrations, never `metadata.create_all`.** The `alembic upgrade head` step IS
indent; check with `yaml.safe_load` and print the `run` string if you edit it. the migration test: a broken revision fails the job there, before it can fail a
container start.
`postgres:16-alpine`, matching the production compose, so the schema is proven against
the Postgres it will actually meet. The schema comes from **real migrations, never
`metadata.create_all`** (rule 82): testing a schema no deployment has ever seen proves
nothing, and that `alembic upgrade head` step IS the migration test — a broken revision
fails the job there, before it can fail a container start.
Tests are marked `integration` (registered in `pyproject.toml`); the unit lane runs Tests are marked `integration` (registered in `pyproject.toml`); the unit lane runs
`-m "not integration"` and stays DB-free. Data resets with `TRUNCATE ... CASCADE` `-m "not integration"` and stays DB-free. Data resets with `TRUNCATE ... CASCADE`
BEFORE each test rather than after, so a failure leaves its rows behind to look at. BEFORE each test rather than after, so a failure leaves its rows behind to look at.
Like `test`, it runs for visibility and does **not** gate the build. ## Checking lanes before pushing
There is no local way to run it — that would mean standing up Postgres on the The analyzers and tests below can run locally against the CI images, with the
workstation, which rule 12 reserves for an explicit request. This lane is verified in workflow's exact arguments, because a local check that differs from CI is worse than
CI. none. They reproduce CI exactly: same image, same lockfile, same units. Nothing else —
no bundle builds, no services — is meant to run locally.
## Desktop (Tauri) lane — separate workflow ### Rust
The Tauri desktop client (`desktop/`) builds in its own workflow,
`.forgejo/workflows/desktop.yml`, NOT in `ci.yml` — it's a heavy Rust + AppImage
build (~20–40 min) that should only run on `desktop/**` changes, not on every
backend/frontend push.
- **Image:** `git.fabledsword.com/bvandeusen/ci-tauri:1.97` (Rust + Node +
WebKitGTK 4.1 + Tauri v2 Linux deps + `tauri-cli`). Selected via
`container.image`; `runs-on: python-ci` is only a scheduling label.
- **Steps:** build the shared frontend (embedded by `generate_context!`) →
`cargo tauri icon app-icon.png` (platform icon set from the committed 1024px
source) → `cargo clippy --workspace -D warnings` → `cargo test --workspace` →
`cargo fmt --all --check` → `cargo tauri build` (produces `.deb` + `.AppImage`)
→ de-bundle the AppImage's graphics libs → verify the `.deb` → repackage for
pacman.
- **The three analyzer steps run from the REPO ROOT with `--workspace`**, not
from `desktop/src-tauri`. Scoping them to the desktop package was correct while
it was the only Rust here; after the core was extracted it silently stopped
being — the core's 89 tests stopped running, and a fourth crate would not be
linted at all. The dependency crates still COMPILE either way, which is exactly
why the gap is invisible from a green run. If you add a workspace member, check
that it appears in the `cargo test` output before believing the lane covers it.
- **`APPIMAGE_EXTRACT_AND_RUN=1`** is set: AppImage tooling FUSE-mounts by default
and CI containers have no `/dev/fuse`.
- **Packaging tools used from the image** (none installed at job time, rule 5):
`dpkg-deb` / `dpkg-query` / `apt-cache` and `dpkg-shlibdeps` (from `dpkg-dev`,
pulled in by `build-essential`) for `desktop/packaging/deb/verify.sh`; `tar` +
a compressor for `desktop/packaging/arch/package-prebuilt.sh`. Both scripts
degrade gracefully rather than hard-failing on an absent optional tool:
`bsdtar` (`libarchive-tools`) is used for the pacman package's `.MTREE` when
present and skipped when not, compression falls back zstd → xz → gzip, and the
`.deb` clean-container install test runs only if a docker CLI is available.
Run 2872 confirmed all three optional tools are ABSENT today, so the current
build takes every fallback: the pacman package ships as `.pkg.tar.xz` with no
`.MTREE`, and the `.deb` clean-container install test is skipped. All three are
functional outcomes — pacman installs an `.xz` package fine, and only
`pacman -Qkk` file verification needs `.MTREE`. Adding `libarchive-tools` +
`zstd` + a docker CLI to `ci-tauri` would upgrade these paths; none of them
block a green build.
- **`libssl-dev` + `pkg-config` are load-bearing** (both already in `ci-tauri`).
Since M10.6 the desktop crate depends on `reqwest` with the **`native-tls`**
backend, which on Linux compiles against OpenSSL. Do NOT drop either package
from `ci-tauri` in a future slim-down — the Rust build fails at `openssl-sys`.
(They're part of Tauri's own documented Linux prerequisites, so they should
stay regardless.)
- **`libssl3` is covered transitively, on purpose — don't "fix" it.** Since
M10.6 `dpkg-shlibdeps` lists `libssl3` among the binary's needs, but the
`.deb` declares only `libwebkit2gtk-4.1-0` + `libgtk-3-0`. `verify.sh` passes
it because webkit's own recursive dependency closure includes OpenSSL, so apt
installs it either way. Declaring it explicitly would be *worse*: the package
name is release-dependent (`libssl3` on bookworm, `libssl3t64` after the
64-bit-time_t transition in trixie/Ubuntu 24.04), so a hardcoded name freezes
the package to the build distro. Leaning on webkit's closure adapts. If webkit
ever stops pulling OpenSSL, `verify.sh` fails the build loudly — that guard is
what makes the indirection safe.
- **Not verifiable in CI:** the runner is Debian, so the pacman package cannot be
`pacman -U`-tested here. That step logs `.PKGINFO` + the full file listing so
the package is auditable from the run log; a real Arch install is the operator's
confirm.
### Windows lane — second job, second image
`desktop.yml` also runs a `windows` job that cross-compiles the NSIS installer.
- **Image:** `git.fabledsword.com/bvandeusen/ci-tauri-win:1.97` (Rust + Node +
`cargo-xwin` + LLVM/`lld` + NSIS). A separate image from `ci-tauri` per
CI-Runner's `docs/process.md` fork rule — the MSVC CRT/SDK cache alone is >1 GB.
Its pins are held in lockstep with `ci-tauri`; bump them together, since both
lanes compile the same source.
- **Why cross-compile:** there is no Windows build host, and a Windows container
cannot run on a Linux host (containers share the host kernel). `cargo-xwin`,
`lld-link` and `makensis` are Linux programs that emit Windows PE output.
- **NSIS only.** `.msi` requires WiX v3, a Windows program — per Tauri, "`.msi`
installers can only be created on Windows."
- **Separate job on purpose:** a Windows failure must not block the Linux
artifacts, which are the primary product today.
- **Weakest verification of any lane.** Tauri documents this path as "not as
straight forward as compiling on Windows directly and is not tested as much",
to be used "only as a last resort" — and a Linux runner cannot execute a
Windows binary. Green means it *built*. A real Windows machine check is
mandatory before trusting a release.
- **Unsigned.** Installers will trip SmartScreen until a code-signing
certificate exists; that is a purchasing decision, not a CI one.
- **TLS backend is chosen for this lane's sake.** The desktop crate pins
`reqwest` to `native-tls`, which on `x86_64-pc-windows-msvc` resolves to
`schannel` — pure-Rust bindings to the OS TLS stack. That keeps C/assembly out
of the cross-compile entirely. Switching to `rustls` would pull in
`ring`/`aws-lc-rs` and their assembler, which is exactly the class of
dependency that broke this lane before (`libsqlite3-sys` → `llvm-lib`). Treat
a TLS-backend change as a change to *this lane*, not just a dependency bump.
- No Postgres lane (unchanged): the desktop app's local store + sync behavior is
verified on the operator's machine, not in CI.
## Android lane — being rebuilt (M12)
The Tauri-mobile Android lane is gone. Android is a native Kotlin/Compose client
over the shared `inkwell-core` crate instead — see Scribe note 2730 for the
decision and milestone M12 for the arc.
The image it will run on already exists: **`ci-rust-android:1.97`**, repurposed
from `ci-tauri-android` rather than deleted (CI-runner `dc802f2`, Scribe #2732).
`tauri-cli` is out and `cargo-ndk` is in; the NDK binutils symlinks and the PATH
append stayed, because they were never Tauri problems — NDK r23 removed the
triple-prefixed binutils that autotools, and so vendored OpenSSL, invokes by bare
name. It also carries `ktlint` + `detekt` so the Kotlin analyzer lane needs no
second image, and JDK 25 (which requires **Gradle 9.1+** in this repo's wrapper —
the old JDK 17 pin existed only because Tauri generated a Gradle 8.x project).
The Rust pin is in LOCKSTEP with `ci-tauri` and `ci-tauri-win`. All three build
`inkwell-core` from one workspace `Cargo.lock` under `--locked`, so a
mismatched Rust minor across the lanes would mean divergent resolution for no
reason. Bump the three together or not at all.
## Checking the Kotlin lane before pushing
Same authorisation and same reasoning as the Rust section below — analyzers, run
in the CI image, with the workflow's exact arguments. From `android/`:
```
IMG=git.fabledsword.com/bvandeusen/ci-rust-android:1.97
DOCK="docker run --rm --user $(id -u):$(id -g) -e HOME=/tmp -v $PWD:/w -w /w"
$DOCK $IMG ktlint "app/src/main/**/*.kt"
$DOCK $IMG detekt --build-upon-default-config --config config/detekt.yml \
--input app/src/main/java
```
`HOME=/tmp` because both tools want a writable home for their caches and
`--user` has taken the image's away.
**Neither of these can see a missing import.** They parse Kotlin without
resolving symbols, so a file that cannot possibly compile passes both. That is
not a gap to work around — it is what these tools are — but it means a clean
local run says nothing about whether the code builds. It cost a red CI run on
`750d11d`, where `android.os.Build` was lost in a file split and both analyzers
were happy.
So there are two more local checks, each covering one blind spot:
```
python3 android/tools/check-symbols.py
python3 android/tools/check-strings.py
```
`check-symbols.py` flags any capitalised identifier that is neither imported,
declared in the same package, a type parameter, nor implicitly available — and
members of this package's own `object` declarations, so that `Foo.bar()` fails
here when `Foo` has no `bar`. That second case exists because moving a function
between two objects and forgetting to paste it into the second cost a red run
(785ebdb): the call site was correctly qualified and every other gate passed.
Not a type checker — `compileDebugKotlin` in CI remains the only real one, and it is
also the ONLY lane that type-checks at all, since there is no Android SDK on the
workstation.
`check-strings.py` covers resources, where the compiler is no help either: `R`
is generated, so `R.string.whatever` type-checks whether or not the string
exists. It catches a missing name, `stringResource` used on a plural or the
reverse, and a format string that takes more arguments than the call passes —
the last of which renders `%2$s` as literal text rather than failing.
Run all four before a push that touches Kotlin.
A caution worth keeping, because it bit twice: a checker of this shape is itself
easy to get vacuously right. The first version stripped line comments with
`re.sub(r'//.*', src, flags=re.S)`, and DOTALL makes `//.*` swallow each file
from its first comment to EOF — so it reported everything clean by examining
almost nothing. **Test a checker against a known-bad tree before trusting a
green from it**. `check-symbols.py` is verified by deleting the `Build` import
from a copy of the source; `check-strings.py` by introducing one of each of its
three fault kinds. Its own first version counted Kotlin's trailing commas as
arguments and reported three correct call sites as broken — the opposite failure,
and the one that teaches you to ignore the tool.
## A fourth Kotlin check: read the artifact, don't recall the API
Compose comes from a BOM (`compose-bom` in `libs.versions.toml`), so no file in
this repo states which `material3` a build actually gets. Guessing its API and
finding out from CI costs eight minutes a try. Resolve and read it instead:
```
# androidx is on Google's Maven, NOT Maven Central — repo1 returns 404
BOM=https://dl.google.com/dl/android/maven2/androidx/compose/compose-bom
curl -sS $BOM/2026.05.01/compose-bom-2026.05.01.pom | grep -A3 'material3</artifactId>'
M3=https://dl.google.com/dl/android/maven2/androidx/compose/material3/material3-android
curl -sS -o m3-src.jar $M3/1.4.0/material3-android-1.4.0-sources.jar
```
The sources jar answers what javap cannot: default arguments, parameter names,
and whether a declaration carries `@ExperimentalMaterial3Api`. That last one is
not optional trivia — an unnecessary `@OptIn` is itself a Kotlin warning, so
guessing "safely" breaks the build's zero-warning record just as surely as
omitting a required one breaks the build.
Same technique for any dependency. It is how `work-runtime-ktx` was found to be
an empty 6 KB stub as of 2.11, with `CoroutineWorker` and
`PeriodicWorkRequestBuilder` moved into `work-runtime` itself.
## Checking the Rust lane before pushing
There is no Rust toolchain on the workstation (rule 10) and the desktop lane is
verified entirely in CI — but the CI image is pullable, so the three analyzer
steps can be run against it locally first. **The operator authorised this on
2026-08-18** for `fmt`, `clippy` and `test`; it is not licence to run the bundle
build or stand up anything.
Run all three, in this order, before any push that touches Rust: Run all three, in this order, before any push that touches Rust:
@@ -326,70 +182,109 @@ $DOCK $IMG cargo clippy --locked --workspace --all-targets -- -D warnings
$DOCK $IMG cargo test --locked --workspace $DOCK $IMG cargo test --locked --workspace
``` ```
Drop `--check` from the first to apply it. `--user` keeps the container from Drop `--check` from the first to apply it. `--user` keeps the container from leaving
leaving root-owned files behind; `CARGO_HOME` points somewhere writable for that root-owned files behind; `CARGO_HOME` points somewhere writable for that user.
user. Commands are IDENTICAL to the workflow's, deliberately — a local check that `target/` persists between runs, so after the first cold build these take seconds; it
differs from CI is worse than none. is gitignored and reaches ~1.4 GB.
**This reproduces CI exactly, not approximately.** On the 2026-08-18 run the Run them on every Rust-touching push, not just the risky-looking ones. A removal looks
local test binary hashes (`thoughtsync_core-bbaae79723888ad1`, safe and often isn't — a private `fn` deleted along with the `pub fn` above it, an
`thoughtsync_desktop_lib-9d162263f8d0aca3`, `thoughtsync_ffi-fc557b96dc795e27`, orphaned `#[serde]` attribute — and nothing in the Python or TypeScript lanes compiles
named for the crates as they were before the rename to Inkwell) matched CI run Rust, so a break can travel several commits before a lane that does gets to it.
3931's byte for byte. Same image, same lockfile, same units.
`target/` persists on the host between runs, so after the first cold build these **Don't infer formatting from existing code.** Some lines in `local/store.rs` exceed
take seconds (~30s for clippy). It is gitignored and reaches ~1.4 GB; delete it 100 characters and survive only because rustfmt cannot break a string literal.
whenever the space is wanted.
**Run these on every Rust-touching push, not just the ones that feel risky.** Four ### Kotlin
consecutive failures across M13's removals — a private `fn` deleted along with the
`pub fn` above it, an orphaned `#[serde]` attribute left where a field was removed,
and a test pinning a protocol version literal — were all caught by these three
commands in under a minute each, after CI had already found them the slow way. A
removal is exactly the kind of change that looks safe and isn't: nothing in the
Python or TypeScript lanes compiles Rust, so a break can travel several commits
before the first lane that does gets to it.
**Don't infer formatting from existing code.** Several lines in `local/store.rs` From `android/`:
exceed 100 characters and survive only because rustfmt cannot break a string
literal — copying that shape caused one of four consecutive fmt-only CI failures,
which is what this whole section exists to prevent.
## Checking the frontend lane before pushing ```
IMG=git.fabledsword.com/bvandeusen/ci-rust-android:1.97
DOCK="docker run --rm --user $(id -u):$(id -g) -e HOME=/tmp -v $PWD:/w -w /w"
Same technique, same authorisation, same reason — and it covers a gap the Rust gate $DOCK $IMG ktlint "app/src/main/**/*.kt"
cannot: `vue-tsc --noEmit` type-checks only the SCRIPT block, so a malformed TEMPLATE $DOCK $IMG detekt --build-upon-default-config --config config/detekt.yml \
passes the typecheck lane and fails `vite build` in a different workflow. `npm run --input app/src/main/java
build` runs both, which is exactly what the desktop lanes run. ```
`HOME=/tmp` because both tools want a writable home for their caches and `--user` has
taken the image's away.
**Neither can see a missing import.** They parse Kotlin without resolving symbols, so
a file that cannot compile passes both. Two more checks cover the blind spots:
```
python3 android/tools/check-symbols.py
python3 android/tools/check-strings.py
```
`check-symbols.py` flags any capitalised identifier that is neither imported, declared
in the same package, a type parameter, nor implicitly available — and members of this
package's own `object` declarations, so `Foo.bar()` fails when `Foo` has no `bar`. It
is not a type checker: `compileDebugKotlin` in CI is the only one.
`check-strings.py` covers resources, where the compiler is no help: `R` is generated,
so `R.string.whatever` type-checks whether or not the string exists. It catches a
missing name, `stringResource` used on a plural or the reverse, and a format string
that takes more arguments than the call passes — which renders `%2$s` as literal text
rather than failing.
Run all four before a push that touches Kotlin.
A checker of this shape is easy to get vacuously right: `re.sub(r'//.*', ...,
flags=re.S)` makes `//.*` swallow each file from its first comment to EOF and report
everything clean. **Test a checker against a known-bad tree before trusting a green
from it.** `check-symbols.py` is verified by deleting an import from a copy of the
source; `check-strings.py` by introducing one of each of its three fault kinds.
**Read the artifact, don't recall the API.** Compose comes from a BOM (`compose-bom`
in `libs.versions.toml`), so no file here states which `material3` a build gets, and
guessing costs eight minutes of CI a try. Resolve and read it:
```
# androidx is on Google's Maven, NOT Maven Central — repo1 returns 404
BOM=https://dl.google.com/dl/android/maven2/androidx/compose/compose-bom
curl -sS $BOM/2026.05.01/compose-bom-2026.05.01.pom | grep -A3 'material3</artifactId>'
M3=https://dl.google.com/dl/android/maven2/androidx/compose/material3/material3-android
curl -sS -o m3-src.jar $M3/1.4.0/material3-android-1.4.0-sources.jar
```
The sources jar answers what javap cannot: default arguments, parameter names, and
whether a declaration carries `@ExperimentalMaterial3Api`. An unnecessary `@OptIn` is
itself a Kotlin warning, so guessing "safely" breaks the zero-warning build as surely
as omitting a required one. The same works for any dependency — it is how
`work-runtime-ktx` shows up as an empty 6 KB stub as of 2.11, with `CoroutineWorker`
and `PeriodicWorkRequestBuilder` in `work-runtime` itself.
### Frontend
`vue-tsc --noEmit` type-checks only the SCRIPT block, so a malformed TEMPLATE passes
the typecheck lane and fails `vite build` in the desktop lanes. `npm run build` runs
both:
``` ```
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/w" -w /w/frontend \ docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/w" -w /w/frontend \
git.fabledsword.com/bvandeusen/ci-python:3.14 sh -c "npm ci --silent && npm run build" git.fabledsword.com/bvandeusen/ci-python:3.14 sh -c "npm ci --silent && npm run build"
``` ```
The typecheck lane uses the `ci-python` image too — it is the node the frontend jobs Delete `frontend/node_modules` and `frontend/dist` afterwards.
already run on, not a separate one. Delete `frontend/node_modules` and `frontend/dist`
afterwards; both are gitignored, but neither belongs in a working tree that never
builds locally otherwise.
## The desktop lockfile ## The Cargo lockfile
`Cargo.lock` is **committed** at the workspace root, per Cargo's own guidance for `Cargo.lock` is **committed** at the workspace root, per Cargo's guidance for binary
binary crates. Without it every CI run re-resolved the graph, which meant a crates, so a build is reproducible from its commit and can't break with no repo
released `.deb`/`.AppImage`/`.exe` couldn't be rebuilt from its tag, a build change, and Renovate has something to bump.
could break with no repo change, and Renovate had nothing to bump (issue 2102).
Enforced by `--locked` on each job's **first** cargo invocation — `cargo clippy `--locked` on each job's **first** cargo invocation enforces it — `cargo clippy
--locked` on Linux, a dedicated `cargo fetch --locked --target --locked` on Linux, a dedicated `cargo fetch --locked --target x86_64-pc-windows-msvc`
x86_64-pc-windows-msvc` step on Windows. If the manifest and the lockfile step on Windows. If the manifest and the lockfile disagree, the run fails there;
disagree, the run fails there instead of silently re-resolving; everything after everything after it in the same job compiles the recorded versions. The Windows step
it in the same job then compiles the recorded versions, so the flag isn't exists because that job's only other crate-graph command is the cross-compile, and
repeated on the bundle build. The Windows step exists separately because that drift is cheaper to learn in thirty seconds than thirty minutes in.
job's only crate-graph command is the cross-compile itself, and drift is cheaper
to learn in the first thirty seconds than thirty minutes in.
To regenerate it after a dependency change — same reasoning as `cargo fmt` To regenerate it after a dependency change:
above, and resolution is neither a test run nor a build:
``` ```
docker run --rm --user "$(id -u):$(id -g)" -e CARGO_HOME=/tmp/cargo \ docker run --rm --user "$(id -u):$(id -g)" -e CARGO_HOME=/tmp/cargo \
@@ -397,59 +292,48 @@ docker run --rm --user "$(id -u):$(id -g)" -e CARGO_HOME=/tmp/cargo \
git.fabledsword.com/bvandeusen/ci-tauri:1.97 cargo fetch git.fabledsword.com/bvandeusen/ci-tauri:1.97 cargo fetch
``` ```
**`cargo fetch`, not `cargo generate-lockfile`.** Both update the lockfile, but **`cargo fetch`, not `cargo generate-lockfile`.** generate-lockfile re-resolves the
generate-lockfile re-resolves the whole graph from scratch and will happily bump whole graph and will bump crates unrelated to your change, turning a two-line manifest
crates that have nothing to do with your change — turning a two-line manifest edit into a few-hundred-line lockfile diff. `cargo fetch` keeps existing pins and adds
edit into a few-hundred-line lockfile diff nobody can review. `cargo fetch` only new entries. Check it stayed additive (`git diff Cargo.lock | grep '^-'` should
performs the minimal resolution: existing pins are preserved, only the new show nothing but re-ordered dependency lists), and commit it with the `Cargo.toml`
entries are added. Verify it stayed additive before committing (`git diff edit — a manifest change pushed without it fails the gate.
Cargo.lock | grep '^-'` should show nothing but re-ordered dependency lists).
Resolving inside the CI image rather than against some other cargo is what keeps
the lockfile format and the picked versions identical to what CI would have
chosen. Commit the result in the same change as the `Cargo.toml` edit — a
manifest change pushed without it fails the gate.
## Channel releases: `dev-rolling` and `stable` ## Channel releases: `dev-rolling` and `stable`
The two update channels are releases on fixed tags, because Fabled-Git has no The two update channels are releases on fixed tags, because Fabled-Git has no
`/releases/latest/download/<asset>` route and the updater needs a permanent URL. `/releases/latest/download/<asset>` route and the updater needs a permanent URL. The
The **channel** is still called `dev` everywhere a person sees it; its **release **channel** is called `dev` everywhere a person sees it; its **release tag** is
tag** is `dev-rolling`. `packaging/channel-tag.sh` is the one mapping CI reads. `dev-rolling`, because a tag named `dev` would shadow the branch and make `git push
`desktop/src-tauri/src/update.rs` and `desktop/packaging/install.sh` carry their origin dev` fail with `error: src refspec dev matches more than one`.
own copy because neither can run it — change all three together. `packaging/channel-tag.sh` is the one mapping CI reads;
`desktop/src-tauri/src/update.rs` and `desktop/packaging/install.sh` carry their own
copy because neither can run it — change all three together. `tests/test_channel_tag.py`
and the `update.rs` tests fail if a channel tag is spelled like a branch.
The tag used to be `dev`, which shadowed the branch of the same name: once a clone A desktop app installed before 2026-09-10 reads a `dev` tag that no longer exists and
had fetched it, `git push origin dev` failed with gets no further updates; reinstall it with `install.sh`.
`error: src refspec dev matches more than one` (Scribe #2184). Never name a channel
tag after a branch; `tests/test_channel_tag.py` and the `update.rs` tests fail if
one is.
The old `dev` release and tag were deleted on 2026-10-08 (Scribe #3884), with the
bridge that kept writing to them. A desktop app installed before 2026-09-10 reads
that tag and gets no further updates; reinstall it with `install.sh`.
## Shell scripts have no CI lane ## Shell scripts have no CI lane
Nothing lints `desktop/packaging/*.sh`, and a broken installer or publish script Nothing lints `desktop/packaging/*.sh`, and a broken installer or publish script fails
fails at the moment a user runs it, not in a build. Check them before pushing — when a user runs it, not in a build. Check them before pushing — `install.sh` is POSIX
`install.sh` is POSIX sh, the rest are bash: sh, the rest are bash:
``` ```
dash -n desktop/packaging/install.sh # or: sh -n dash -n desktop/packaging/install.sh # or: sh -n
bash -n desktop/packaging/publish-release.sh bash -n desktop/packaging/publish-release.sh
``` ```
Where a script resolves URLs from the Fabled-Git API, exercise the resolution Where a script resolves URLs from the Fabled-Git API, exercise the resolution against
against the live instance (plain `curl` reads, no install) rather than trusting the live instance (plain reads, no install) rather than trusting the regex by eye.
the regex by eye. Both channel paths in `install.sh` were verified that way.
**Hand-assembled JSON: parse it before you push it.** `publish-release.sh` builds **Hand-assembled JSON: parse it before you push it.** `publish-release.sh` builds its
its request bodies as shell strings, and quoting context decides what survives request bodies as shell strings, and quoting context decides what survives into the
into the JSON — a `` \ `` inside an unquoted heredoc loses its backslash to the JSON — a `` \ `` inside an unquoted heredoc loses its backslash to the shell, the same
shell, the same `` \ `` inside a single-quoted variable does not, and reaches `` \ `` inside a single-quoted variable does not, and reaches Fabled-Git as an illegal
Fabled-Git as an illegal escape (HTTP 422, one wasted build). `sh -n` cannot see escape (HTTP 422). `sh -n` cannot see this. Extract the body block and parse it for
this. Extract the body block and parse it for every branch it can take: every branch it can take:
``` ```
sed -n '/^# The install command printed/,/^JSON$/p' desktop/packaging/publish-release.sh > /tmp/body.sh sed -n '/^# The install command printed/,/^JSON$/p' desktop/packaging/publish-release.sh > /tmp/body.sh
+9 -9
View File
@@ -3,21 +3,22 @@ name = "inkwell-core"
version = "0.1.0" version = "0.1.0"
description = "Inkwell client core — local-first SQLite store and opt-in sync engine" description = "Inkwell client core — local-first SQLite store and opt-in sync engine"
authors = ["bvandeusen"] authors = ["bvandeusen"]
license = "AGPL-3.0-only"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
log = { workspace = true } log = { workspace = true }
# Local-first store (M10.4): bundled = compile SQLite in, so there's no system # Local-first store: bundled = compile SQLite in, so there's no system
# libsqlite dependency to vary across the AppImage / native / Windows / Android builds. # libsqlite dependency to vary across the AppImage / native / Windows / Android builds.
# functions = the `fold` SQL function tag names are compared and indexed by (#5385): # functions = the `fold` SQL function tag names are compared and indexed by:
# SQLite's own lower() folds ASCII only, and the server folds every letter. # SQLite's own lower() folds ASCII only, and the server folds every letter.
rusqlite = { version = "0.32", features = ["bundled", "functions"] } rusqlite = { version = "0.32", features = ["bundled", "functions"] }
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
# RFC3339 timestamps for created_at/updated_at/remind_at (Date.parse-able on the JS side). # RFC3339 timestamps for created_at/updated_at/remind_at (Date.parse-able on the JS side).
chrono = { version = "0.4", default-features = false, features = ["clock"] } chrono = { version = "0.4", default-features = false, features = ["clock"] }
# HTTP for the opt-in server handshake (M10.6) and the sync engine (M10.7). # HTTP for the opt-in server handshake and the sync engine.
# #
# native-tls, NOT rustls, deliberately: on x86_64-pc-windows-msvc native-tls resolves # native-tls, NOT rustls, deliberately: on x86_64-pc-windows-msvc native-tls resolves
# to `schannel` — pure-Rust bindings to the OS TLS stack — so nothing C or assembly # to `schannel` — pure-Rust bindings to the OS TLS stack — so nothing C or assembly
@@ -43,12 +44,11 @@ flate2 = { version = "1", default-features = false, features = ["rust_backend"]
# Without this the build dies at `openssl-sys`: "Could not find directory of # Without this the build dies at `openssl-sys`: "Could not find directory of
# OpenSSL installation". # OpenSSL installation".
# #
# `vendored` compiles OpenSSL from source with the NDK toolchain. The alternative # `vendored` compiles OpenSSL from source with the NDK toolchain. Not rustls on
# was rustls on Android only, which builds faster — but rustls ships its own root # Android, though it builds faster: rustls ships its own root store, so the phone
# store, so the phone would trust a DIFFERENT set of certificates than the desktop # would trust a DIFFERENT set of certificates than the desktop does, and a
# does. A self-hosted server behind a private or enterprise CA would then work on # self-hosted server behind a private or enterprise CA would work on one surface
# one surface and fail on another, and "the surfaces behave the same" is worth more # and fail on another.
# than build minutes.
# #
# Declared as a direct dependency purely to turn the feature on: cargo's feature # Declared as a direct dependency purely to turn the feature on: cargo's feature
# unification applies it to the copy `native-tls` pulls in transitively. # unification applies it to the copy `native-tls` pulls in transitively.
+1 -4
View File
@@ -2,10 +2,7 @@
//! //!
//! Deliberately free of any UI framework. The desktop wraps it in Tauri commands; //! Deliberately free of any UI framework. The desktop wraps it in Tauri commands;
//! the Android client binds it through uniffi. Neither owns it, and a change to //! the Android client binds it through uniffi. Neither owns it, and a change to
//! either must not require touching this crate — that separation is the whole point //! either must not require touching this crate — that separation is the whole point.
//! (see Scribe note 2730). It was already true before the split: every file here
//! carried zero Tauri references, which is what made the extraction a move rather
//! than a rewrite.
//! //!
//! - `local` — the source of truth. Works with no server and no account. //! - `local` — the source of truth. Works with no server and no account.
//! - `sync` — entirely opt-in. Nothing in it runs until a server is linked. //! - `sync` — entirely opt-in. Nothing in it runs until a server is linked.
+20 -27
View File
@@ -6,7 +6,7 @@
//! letters, digits, `_` and `-`. On save these become labels attached with //! letters, digits, `_` and `-`. On save these become labels attached with
//! `via_tag = true`. The server and the web run the same cases //! `via_tag = true`. The server and the web run the same cases
//! (core/testdata/grammar.json). //! (core/testdata/grammar.json).
//! - `- [ ] item`: a checklist item. The body IS the checklist (M304) — there is no //! - `- [ ] item`: a checklist item. The body IS the checklist — there is no
//! table of items beside it, so a list can sit between two paragraphs instead of //! table of items beside it, so a list can sit between two paragraphs instead of
//! only after them. //! only after them.
//! //!
@@ -17,9 +17,6 @@
//! and the rewriters below are how one is edited. //! and the rewriters below are how one is edited.
//! //!
//! Dedupes case-insensitively, preserving first-seen order. //! Dedupes case-insensitively, preserving first-seen order.
//!
//! Also derived `[[wiki-links]]` until they were removed (note 2897) — this is a
//! capture-and-recall surface, and a linking system is organization.
/// Every `#tag` in ONE line, as `(start, end, name)` in char indices. /// Every `#tag` in ONE line, as `(start, end, name)` in char indices.
/// ///
@@ -31,10 +28,9 @@ fn line_tags(chars: &[char]) -> Vec<(usize, usize, String)> {
let mut i = 0; let mut i = 0;
while i < chars.len() { while i < chars.len() {
if chars[i] == '#' { if chars[i] == '#' {
// Start of line or after whitespace, and nothing else. Any non-tag // Start of line or after whitespace, and nothing else, so `(#todo)` and a
// character used to count, which made `(#todo)` a tag here and plain // pasted URL's `/#section` are not tags. All three implementations share
// text on the server, and made the `/#section` of a pasted URL a label. // this rule.
// Whitespace is the rule all three implementations now share (#5166).
let boundary = i == 0 || chars[i - 1].is_whitespace(); let boundary = i == 0 || chars[i - 1].is_whitespace();
// A tag must start with a letter (so "#1" or a bare "#" is not a tag). // A tag must start with a letter (so "#1" or a bare "#" is not a tag).
if boundary && i + 1 < chars.len() && chars[i + 1].is_alphabetic() { if boundary && i + 1 < chars.len() && chars[i + 1].is_alphabetic() {
@@ -54,8 +50,8 @@ fn line_tags(chars: &[char]) -> Vec<(usize, usize, String)> {
/// One `#tag` and exactly where it sits, for a renderer drawing the body itself. /// One `#tag` and exactly where it sits, for a renderer drawing the body itself.
/// ///
/// The card no longer prints a chip for a tag whose text is still in the note — it /// The card colours a tag where it was typed rather than repeating it as a chip.
/// colours the token where it was typed instead. To do that a renderer needs the /// To do that a renderer needs the
/// SPAN, not just the name, and asking it to find the name again would be a second /// SPAN, not just the name, and asking it to find the name again would be a second
/// grammar quietly disagreeing with this one about what `##a` or `#1` is. /// grammar quietly disagreeing with this one about what `##a` or `#1` is.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -142,8 +138,8 @@ fn collapse_blank_runs(text: &str) -> String {
/// else is left exactly as written. /// else is left exactly as written.
/// ///
/// The MIRROR of `split_body_tags` in the server's `notes/tags.py`, and it has to stay /// The MIRROR of `split_body_tags` in the server's `notes/tags.py`, and it has to stay
/// one: a note lifted differently here than there would change under the operator the /// one: a note lifted differently here than there would change the moment it synced.
/// moment it synced. Same discipline, and the same reason, as `DerivedTint`. /// Same discipline, and the same reason, as `DerivedTint`.
/// ///
/// The conservative reading of "standalone" is deliberate. A trailing tag is /// The conservative reading of "standalone" is deliberate. A trailing tag is
/// ambiguous and the text does not say which it is — `buy milk #grocery` is filing, /// ambiguous and the text does not say which it is — `buy milk #grocery` is filing,
@@ -152,8 +148,8 @@ fn collapse_blank_runs(text: &str) -> String {
/// ///
/// `standalone` tags become ORDINARY labels (`via_tag = 0`): nothing is left to derive /// `standalone` tags become ORDINARY labels (`via_tag = 0`): nothing is left to derive
/// them from, so the row becomes the record and the chip's × becomes the way to remove /// them from, so the row becomes the record and the chip's × becomes the way to remove
/// one. `inline` tags stay derived exactly as before. That is what `via_tag` means from /// one. `inline` tags stay derived. That is what `via_tag` means: backed by text still
/// here on — backed by text still in the body. /// in the body.
pub fn lift_standalone_tags(body: &str) -> (Vec<String>, Vec<String>, String) { pub fn lift_standalone_tags(body: &str) -> (Vec<String>, Vec<String>, String) {
let mut standalone: Vec<String> = Vec::new(); let mut standalone: Vec<String> = Vec::new();
let mut inline: Vec<String> = Vec::new(); let mut inline: Vec<String> = Vec::new();
@@ -220,7 +216,7 @@ fn is_tag_char(c: char) -> bool {
/// How two tag names compare: equal once every letter is lowercased, accented ones /// How two tag names compare: equal once every letter is lowercased, accented ones
/// included, so "Café" and "CAFÉ" are one tag. The server's `labeling.named` folds /// included, so "Café" and "CAFÉ" are one tag. The server's `labeling.named` folds
/// the same way (Postgres `lower`, Python `str.lower`), and the store's `fold` SQL /// the same way (Postgres `lower`, Python `str.lower`), and the store's `fold` SQL
/// function is this (#5385). /// function is this.
pub(crate) fn fold(name: &str) -> String { pub(crate) fn fold(name: &str) -> String {
name.to_lowercase() name.to_lowercase()
} }
@@ -248,8 +244,7 @@ fn push_unique(out: &mut Vec<String>, candidate: &str) {
// parse it would make a half-typed list stop being a list. // parse it would make a half-typed list stop being a list.
/// A checklist item, as found in the body. Its position in the returned vector is /// A checklist item, as found in the body. Its position in the returned vector is
/// its identity — the same thing `position` meant when these were rows, and all the /// its identity; the wire carries text and checked, never an id.
/// wire ever carried (`push.rs` sent text and checked, never an id).
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct DerivedItem { pub struct DerivedItem {
pub text: String, pub text: String,
@@ -361,9 +356,8 @@ pub const DISPLAY_TITLE_CAP: usize = 200;
/// twice, and they have to agree or a synced note is called different things on either /// twice, and they have to agree or a synced note is called different things on either
/// side of the wire. Both run the `display_titles` cases in testdata/grammar.json. /// side of the wire. Both run the `display_titles` cases in testdata/grammar.json.
/// ///
/// It no longer needs the items, because the items ARE lines of the body now (M304). /// The items ARE lines of the body, so it strips the task marker off: a list-only
/// What it needs instead is to strip the task marker off: a list-only note is still /// note is named by its first item, and calling that note "- [ ] milk" would be showing
/// named by its first item, and calling that note "- [ ] milk" would be showing
/// someone the storage rather than the note. An empty item is skipped rather than /// someone the storage rather than the note. An empty item is skipped rather than
/// naming the note "", which is what a half-typed list would otherwise do. /// naming the note "", which is what a half-typed list would otherwise do.
pub fn display_title(body: &str) -> String { pub fn display_title(body: &str) -> String {
@@ -431,9 +425,8 @@ pub fn set_item_checked(body: &str, index: usize, checked: bool) -> String {
/// ///
/// Spaced exactly as `import_export.py:_note_markdown` writes a list — a blank line /// Spaced exactly as `import_export.py:_note_markdown` writes a list — a blank line
/// between prose and the list, and nothing between consecutive items. That is not /// between prose and the list, and nothing between consecutive items. That is not
/// cosmetic: the server migration folds existing rows into bodies using the same /// cosmetic: the migrations that fold checklist rows into bodies use the same layout,
/// layout, so an export taken before the migration and one taken after have to /// so every surface lands on byte-identical bodies.
/// agree byte for byte.
/// ///
/// `checked` is a parameter rather than always false because the two migrations that /// `checked` is a parameter rather than always false because the two migrations that
/// fold existing rows into bodies have to carry the state those rows were in. A new /// fold existing rows into bodies have to carry the state those rows were in. A new
@@ -513,8 +506,8 @@ mod tests {
// ── lifting standalone tags ────────────────────────────────────────────── // ── lifting standalone tags ──────────────────────────────────────────────
// //
// The MIRROR of `split_body_tags` in the server's notes/tags.py, case for case. // The MIRROR of `split_body_tags` in the server's notes/tags.py, case for case.
// A note lifted differently here than there would change under the operator the // A note lifted differently here than there would change the moment it synced,
// moment it synced, so these are the cases that file agrees to. // so these are the cases that file agrees to.
#[test] #[test]
fn lifts_a_line_that_is_nothing_but_tags() { fn lifts_a_line_that_is_nothing_but_tags() {
@@ -662,8 +655,8 @@ mod tests {
#[test] #[test]
fn append_spaces_like_the_exporter() { fn append_spaces_like_the_exporter() {
// Prose then a blank line then the list — byte-for-byte what // Prose then a blank line then the list — byte-for-byte what
// import_export.py:_note_markdown writes, which is what the server // import_export.py:_note_markdown writes, which is the layout the server
// migration will fold existing rows into. // migration folds rows into.
assert_eq!(append_item("a note", "milk", false), "a note\n\n- [ ] milk"); assert_eq!(append_item("a note", "milk", false), "a note\n\n- [ ] milk");
// Nothing between consecutive items. // Nothing between consecutive items.
let one = "a note\n\n- [ ] milk"; let one = "a note\n\n- [ ] milk";
+1 -3
View File
@@ -73,9 +73,7 @@ pub fn open(path: &Path) -> rusqlite::Result<Db> {
/// Open a migrated, in-memory store. /// Open a migrated, in-memory store.
/// ///
/// Exists so a CONSUMER can test against a real schema without taking a rusqlite /// Exists so a CONSUMER can test against a real schema without taking a rusqlite
/// dependency of its own just to build a `Db` — which is exactly what the desktop /// dependency of its own just to build a `Db`.
/// crate was doing before the core was extracted. The Android bindings will want the
/// same thing.
pub fn open_in_memory() -> rusqlite::Result<Db> { pub fn open_in_memory() -> rusqlite::Result<Db> {
Ok(Db(Mutex::new(memory_conn()?))) Ok(Db(Mutex::new(memory_conn()?)))
} }
+2 -2
View File
@@ -45,7 +45,7 @@ pub struct Note {
pub previews: Vec<LinkPreview>, pub previews: Vec<LinkPreview>,
pub created_at: Option<String>, pub created_at: Option<String>,
pub updated_at: Option<String>, pub updated_at: Option<String>,
/// How this account holds the note (#5175): `owner`, or `edit`/`view` for one /// How this account holds the note: `owner`, or `edit`/`view` for one
/// someone shared with it. Named and valued as the server's, so the shared /// someone shared with it. Named and valued as the server's, so the shared
/// frontend gates the editor identically either way. /// frontend gates the editor identically either way.
pub permission: String, pub permission: String,
@@ -119,7 +119,7 @@ pub struct Label {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub count: Option<i64>, pub count: Option<i64>,
/// The newest `updated_at` among the untrashed notes carrying it, or None when /// The newest `updated_at` among the untrashed notes carrying it, or None when
/// nothing does. The drawer keeps the few most recent tags in reach (#5931). /// nothing does. The drawer keeps the few most recent tags in reach.
pub last_used: Option<String>, pub last_used: Option<String>,
} }
+2 -3
View File
@@ -1,4 +1,4 @@
//! Trash retention for a device with no server (M11.3). //! Trash retention for a device with no server.
//! //!
//! The server owns this policy whenever there IS one: a linked client learns about //! The server owns this policy whenever there IS one: a linked client learns about
//! every permanent deletion from the delta feed, as a tombstone, and does exactly //! every permanent deletion from the delta feed, as a tombstone, and does exactly
@@ -123,8 +123,7 @@ mod tests {
fn a_note_just_inside_the_window_survives() { fn a_note_just_inside_the_window_survives() {
// The comparison is STRICTLY older than the cutoff, so a note with a minute // The comparison is STRICTLY older than the cutoff, so a note with a minute
// of its 30 days still to run is kept. An exact tie isn't testable against a // of its 30 days still to run is kept. An exact tie isn't testable against a
// wall clock — the sweep reads `now` microseconds after the row is stamped, // wall clock — the sweep reads `now` microseconds after the row is stamped.
// which is precisely how the first version of this test failed.
let conn = db(); let conn = db();
let almost = Duration::days(30) - Duration::minutes(1); let almost = Duration::days(30) - Duration::minutes(1);
trashed_note_aged(&conn, "boundary", almost); trashed_note_aged(&conn, "boundary", almost);
+35 -44
View File
@@ -112,13 +112,11 @@ CREATE TABLE sync_state (
INSERT INTO sync_state (id) VALUES (1); INSERT INTO sync_state (id) VALUES (1);
"#; "#;
// v2 (M10.7c): local tombstones. // v2: local tombstones.
// //
// A permanent delete previously just dropped the row, which left NO record that it // A permanent delete is recorded here until the server acknowledges it. Dropping the
// ever existed. Offline, that means the delete can never be pushed — and the next // row alone leaves no record that it existed, so an offline delete could never be
// pull would faithfully resurrect the note from the server. A deletion that undoes // pushed and the next pull would resurrect the note.
// itself is about the worst outcome sync can produce, so deletes are now recorded
// here until they've been acknowledged by the server and cleared.
const SCHEMA_V2: &str = r#" const SCHEMA_V2: &str = r#"
CREATE TABLE pending_deletes ( CREATE TABLE pending_deletes (
entity TEXT NOT NULL, -- 'note' | 'label' entity TEXT NOT NULL, -- 'note' | 'label'
@@ -128,7 +126,7 @@ CREATE TABLE pending_deletes (
); );
"#; "#;
// v3 (M10.7e): when the last successful sync finished. // v3: when the last successful sync finished.
// //
// The cursor alone can't answer "is this up to date?" — it's a revision watermark, // The cursor alone can't answer "is this up to date?" — it's a revision watermark,
// not a time, and it doesn't move at all when a sync legitimately finds nothing new. // not a time, and it doesn't move at all when a sync legitimately finds nothing new.
@@ -137,24 +135,21 @@ const SCHEMA_V3: &str = r#"
ALTER TABLE sync_state ADD COLUMN last_sync_at TEXT; ALTER TABLE sync_state ADD COLUMN last_sync_at TEXT;
"#; "#;
// v4 (M11.3): WHEN a note was trashed. // v4: WHEN a note was trashed.
// //
// The table only ever recorded THAT a note was trashed, which is enough to draw a // Retention needs an age: without a timestamp there is no telling a note trashed this
// Trash view and nothing else. Retention needs an age: without a timestamp there is // morning from one trashed last spring, so an offline device could never expire its
// no way to tell a note trashed this morning from one trashed last spring, so an // own trash, and the UI couldn't warn anyone before it did.
// offline device could never expire its own trash — and the UI couldn't warn anyone
// before it did.
// It also records the LINKED server's retention window, captured from /api/config. // It also records the LINKED server's retention window, captured from /api/config.
// Once linked, the server's policy is the one that actually applies, so showing this // Once linked, the server's policy is the one that applies, so showing this device's
// device's offline default would put a countdown on screen that doesn't match what // offline default would put a countdown on screen that doesn't match what happens.
// happens — a wrong deadline is worse than none.
const SCHEMA_V4: &str = r#" const SCHEMA_V4: &str = r#"
ALTER TABLE notes ADD COLUMN trashed_at TEXT; ALTER TABLE notes ADD COLUMN trashed_at TEXT;
UPDATE notes SET trashed_at = updated_at WHERE trashed = 1; UPDATE notes SET trashed_at = updated_at WHERE trashed = 1;
ALTER TABLE sync_state ADD COLUMN server_retention_days INTEGER; ALTER TABLE sync_state ADD COLUMN server_retention_days INTEGER;
"#; "#;
// v5 (M10.9): small key/value app preferences. // v5: small key/value app preferences.
// //
// The first entry is the update channel, which is neither note data nor part of the // The first entry is the update channel, which is neither note data nor part of the
// server link — so it belongs in neither `notes` nor `sync_state`. Generic on // server link — so it belongs in neither `notes` nor `sync_state`. Generic on
@@ -166,17 +161,16 @@ CREATE TABLE prefs (
); );
"#; "#;
// v6 (M13 step 2): `kind` is gone. A checklist is something a note HAS, not something // v6: drops `kind`. A checklist is something a note HAS, not something a note IS —
// a note IS — the column was a mode flag with no enum and no constraint behind it, // the column was a mode flag with no enum and no constraint behind it. A note that
// and `note_items` was never tied to it. Dropping it loses nothing: a note that was // was 'list' keeps every one of its items.
// 'list' keeps every one of its items.
// //
// SQLite has supported DROP COLUMN since 3.35 (2021); rusqlite bundles well past it. // SQLite has supported DROP COLUMN since 3.35 (2021); rusqlite bundles well past it.
const SCHEMA_V6: &str = r#" const SCHEMA_V6: &str = r#"
ALTER TABLE notes DROP COLUMN kind; ALTER TABLE notes DROP COLUMN kind;
"#; "#;
// v7 (M13 step 3): the title field is gone. A note is a body plus optional items, and // v7: drops the title field. A note is a body plus optional items, and
// its NAME is the first non-empty line of that body, falling back to its first item — // its NAME is the first non-empty line of that body, falling back to its first item —
// derived at read time, never stored (see store::display_title). // derived at read time, never stored (see store::display_title).
// //
@@ -186,7 +180,7 @@ ALTER TABLE notes DROP COLUMN title;
ALTER TABLE note_revisions DROP COLUMN title; ALTER TABLE note_revisions DROP COLUMN title;
"#; "#;
// v8 (M304): `checklist_items` is gone. The body IS the checklist — a `- [ ] milk` // v8: folds `checklist_items` into bodies. The body IS the checklist — a `- [ ] milk`
// line is the item — so a list can sit between two paragraphs instead of only after // line is the item — so a list can sit between two paragraphs instead of only after
// them, which a side table could never express no matter how it was styled. // them, which a side table could never express no matter how it was styled.
// //
@@ -251,13 +245,12 @@ fn migrate_v8(conn: &Connection) -> rusqlite::Result<()> {
Ok(()) Ok(())
} }
// v9 (M315): `notes.color` is gone. A card is one neutral surface now and colour lives // v9: drops `notes.color`. A card is one neutral surface and colour lives only on a
// only on a tag, so the column was written by a picker nothing read and read by nothing // tag; `labels.color` is untouched.
// at all. `labels.color` is untouched — that is the colour that survived.
// //
// The saved-filter sweep is the second half and not optional. `params` is opaque JSON // The saved-filter sweep is the second half and not optional. `params` is opaque JSON
// and a stored view could carry `"color": "teal"`; with the facet gone that key would // and a stored view could carry `"color": "teal"`; with the facet gone that key would
// sit there forever, and a view that silently filters on a field the app no longer has // sit there forever, and a view that silently filters on a field the app doesn't have
// is worse than one that visibly lost a criterion. Guarded on `json_valid` because a // is worse than one that visibly lost a criterion. Guarded on `json_valid` because a
// corrupt blob must keep whatever it holds, not become NULL. // corrupt blob must keep whatever it holds, not become NULL.
// //
@@ -275,12 +268,11 @@ UPDATE saved_filters
AND params LIKE '%"color"%'; AND params LIKE '%"color"%';
"#; "#;
// v10 (#5168): an attachment can be created on THIS device. // v10: an attachment can be created on THIS device.
// //
// Until now every attachment row arrived on the delta feed, so every one was already on // A file attached here, offline, is not on the server yet, and `uploaded = 0` is the
// the server. A file attached here, offline, is not, and `uploaded = 0` is the queue // queue push drains once the note has landed. Rows from before this step all came
// push drains once the note has landed. The rows already here all came from the // from the server, which is why the default is 1.
// server, which is why the default is 1.
// //
// `upload_error` holds a refusal that retrying won't fix — over the size limit, an id // `upload_error` holds a refusal that retrying won't fix — over the size limit, an id
// already in use, bytes that don't match their hash. A row carrying one leaves the // already in use, bytes that don't match their hash. A row carrying one leaves the
@@ -292,15 +284,15 @@ ALTER TABLE attachments ADD COLUMN uploaded INTEGER NOT NULL DEFAULT 1;
ALTER TABLE attachments ADD COLUMN upload_error TEXT; ALTER TABLE attachments ADD COLUMN upload_error TEXT;
"#; "#;
// v11 (#5175): notes other people shared with this account. // v11: notes other people shared with this account.
// //
// The feed now carries them, so a note says how it is held: `permission` is `owner`, // The feed carries them, so a note says how it is held: `permission` is `owner`,
// `edit` (its text may change here) or `view`. Every note already on a device is the // `edit` (its text may change here) or `view`. Every note already on a device is the
// account's own, which is why the default is `owner`. `shared` is whether the owner // account's own, which is why the default is `owner`. `shared` is whether the owner
// has shared it with anyone, and `shared_by_*` names the owner of one shared with us. // has shared it with anyone, and `shared_by_*` names the owner of one shared with us.
// //
// `shares_synced` is whether this device has pulled with shares since it was linked. // `shares_synced` is whether this device has pulled with shares since it was linked.
// The notes shared before this build sit below the cursor it already holds, so the // Notes shared before a device reaches this step sit below the cursor it holds, so the
// first pull from a server offering `shares` starts again from zero (see // first pull from a server offering `shares` starts again from zero (see
// `engine::run_cycle`). A pull applies idempotently, so starting over costs a // `engine::run_cycle`). A pull applies idempotently, so starting over costs a
// download and nothing else. // download and nothing else.
@@ -312,29 +304,29 @@ ALTER TABLE notes ADD COLUMN shared_by_name TEXT;
ALTER TABLE sync_state ADD COLUMN shares_synced INTEGER NOT NULL DEFAULT 0; ALTER TABLE sync_state ADD COLUMN shares_synced INTEGER NOT NULL DEFAULT 0;
"#; "#;
// v12 (#5176): a shared note's pin, archive and position are this account's own. // v12: a shared note's pin, archive and position are this account's own.
// //
// `state_at` is when they last changed here, on a note someone else owns. It is kept // `state_at` is when they last changed here, on a note someone else owns. It is kept
// apart from `updated_at`, which stays the time of the note's TEXT: pinning a copy // apart from `updated_at`, which stays the time of the note's TEXT: pinning a copy
// whose text is behind the owner's must not make that text look like the newer // whose text is behind the owner's must not make that text look like the newer
// edit when it is pushed. Null on our own notes, where `updated_at` covers both. // edit when it is pushed. Null on our own notes, where `updated_at` covers both.
// //
// `sync_state.shares_synced` now holds a level rather than a flag (see // `sync_state.shares_synced` holds a level rather than a flag (see
// `state::SHARED_STATE`), and a device reaching this level pulls everything once // `state::SHARED_STATE`), and a device reaching this level pulls everything once
// more: the shared notes it holds carry their owner's pins until it does. // more: the shared notes it holds carry their owner's pins until it does.
const SCHEMA_V12: &str = r#" const SCHEMA_V12: &str = r#"
ALTER TABLE notes ADD COLUMN state_at TEXT; ALTER TABLE notes ADD COLUMN state_at TEXT;
"#; "#;
// v13 (#5180): saved views are gone. They never synced, so this device's set was its // v13: drops saved views. They never synced, so this device's set was its
// own and nothing else holds a copy. // own and nothing else holds a copy.
const SCHEMA_V13: &str = r#" const SCHEMA_V13: &str = r#"
DROP TABLE saved_filters; DROP TABLE saved_filters;
"#; "#;
// v14 (#5385): tag names compare by `fold`, which lowercases every letter. Until now // v14: tag names compare by `fold`, which lowercases every letter. SQLite's lower()
// the index and the queries used SQLite's lower(), which folds ASCII only, so "Café" // folds ASCII only, so with it "Café" and "CAFÉ" could be two tags on a device while
// and "CAFÉ" could be two tags on a device while the server held one. // the server held one.
// //
// Pairs that already exist are merged before the index can be rebuilt. THE OLDER ROW // Pairs that already exist are merged before the index can be rebuilt. THE OLDER ROW
// SURVIVES, as in `store::rename_label`, so every device holding the same pair picks // SURVIVES, as in `store::rename_label`, so every device holding the same pair picks
@@ -452,7 +444,7 @@ fn register_fold(conn: &Connection) -> rusqlite::Result<()> {
mod tests { mod tests {
use super::*; use super::*;
/// A database as it stood before M304 — items still in their own table. /// A database at v7, with items still in their own table.
fn v7_db() -> Connection { fn v7_db() -> Connection {
let conn = Connection::open_in_memory().expect("open"); let conn = Connection::open_in_memory().expect("open");
conn.execute_batch("PRAGMA foreign_keys = ON;").expect("fk"); conn.execute_batch("PRAGMA foreign_keys = ON;").expect("fk");
@@ -602,8 +594,7 @@ mod tests {
.collect::<rusqlite::Result<Vec<String>>>() .collect::<rusqlite::Result<Vec<String>>>()
.expect("collect"); .expect("collect");
assert!(!columns.iter().any(|c| c == "color")); assert!(!columns.iter().any(|c| c == "color"));
// The one that survived. Getting this wrong would take every tag's colour with // The one that stays. Getting this wrong would take every tag's colour with it.
// it, which is the whole thing M315 was keeping.
let mut stmt = conn.prepare("PRAGMA table_info(labels)").expect("pragma"); let mut stmt = conn.prepare("PRAGMA table_info(labels)").expect("pragma");
let label_columns: Vec<String> = stmt let label_columns: Vec<String> = stmt
.query_map([], |r| r.get::<_, String>(1)) .query_map([], |r| r.get::<_, String>(1))
+28 -37
View File
@@ -48,11 +48,9 @@ fn load_labels(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<NoteLab
/// The note's checklist, read out of its body. No query, because there is no table. /// The note's checklist, read out of its body. No query, because there is no table.
/// ///
/// A `- [ ] milk` line IS the item (M304). The id is the item's ORDINAL rather than a /// A `- [ ] milk` line IS the item. The id is the item's ORDINAL rather than a uuid:
/// uuid — which is all it ever amounted to anyway, since `push.rs` sent text and /// sync carries text and checked, never an id, and the ordinal is what the rewriters
/// checked and never an id, and both sides replaced the whole list on every sync. It /// in `derive` take, so a UI holding an id can act on it directly.
/// is also exactly what the rewriters in `derive` take, so a UI holding an id can act
/// on it directly.
fn items_of(body: &str) -> Vec<ChecklistItem> { fn items_of(body: &str) -> Vec<ChecklistItem> {
derive::extract_items(body) derive::extract_items(body)
.into_iter() .into_iter()
@@ -122,10 +120,9 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
permission, shared, shared_by_id, shared_by_name permission, shared, shared_by_id, shared_by_name
FROM notes WHERE id = ?1", FROM notes WHERE id = ?1",
[id], [id],
// By NAME, not by position. Dropping `color` shifted every column after it // By NAME, not by position: dropping a column shifts every one after it, and
// and the two timestamps were missed (fa89da1): created_at read updated_at // a positional read would then load the wrong timestamps with nothing
// and updated_at read trashed_at, with nothing failing. A name either // failing. A name either resolves or errors.
// resolves or errors.
|r| { |r| {
Ok(Note { Ok(Note {
id: r.get("id")?, id: r.get("id")?,
@@ -166,11 +163,11 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
Ok(note) Ok(note)
} }
// ---- notes shared with this account (#5175) --------------------------------- // ---- notes shared with this account ------------------------------------------
// //
// A note someone else owns arrives with `permission` `edit` (its text may change // A note someone else owns arrives with `permission` `edit` (its text may change
// here) or `view` (it may not). Its pin, archive and place on this board are this // here) or `view` (it may not). Its pin, archive and place on this board are this
// account's own either way (#5176). Everything else about it — trash, reminders, // account's own either way. Everything else about it — trash, reminders,
// labels, files — stays its owner's. The server enforces the same split; // labels, files — stays its owner's. The server enforces the same split;
// refusing here as well tells the person at once, instead of letting an edit be made, // refusing here as well tells the person at once, instead of letting an edit be made,
// queued, and then thrown away by the next sync. // queued, and then thrown away by the next sync.
@@ -268,21 +265,19 @@ fn find_or_create_label(conn: &Connection, name: &str) -> rusqlite::Result<Strin
/// Attach the note's tag labels, LIFT its standalone tags out of the body, and write /// Attach the note's tag labels, LIFT its standalone tags out of the body, and write
/// the shortened body back. /// the shortened body back.
/// ///
/// NAMED FOR THE MUTATION. It used to be `sync_tags` and only touched label rows; it /// NAMED FOR THE MUTATION: it rewrites `notes.body`, and every caller writes the body
/// now rewrites `notes.body`, and every caller writes the body just before calling — /// just before calling, so this overwrites what they wrote, on purpose.
/// so this overwrites what they wrote, on purpose.
/// ///
/// `display_title` needs no attention here, unlike on the server: the core derives it /// `display_title` needs no attention here, unlike on the server: the core derives it
/// on READ (`derive::display_title`, called from `load_note`) rather than storing /// on READ (`derive::display_title`, called from `load_note`) rather than storing
/// it, so there is no persisted copy to go stale. /// it, so there is no persisted copy to go stale.
/// ///
/// The two kinds of tag are handled differently, and that difference IS what `via_tag` /// The two kinds of tag are handled differently, and that difference IS what `via_tag`
/// means from here on — backed by text still in the body: /// means — backed by text still in the body:
/// ///
/// standalone lifted out, attached as an ORDINARY label. Nothing derives it any /// standalone lifted out, attached as an ORDINARY label. Nothing derives it, and
/// more, and the way to remove it becomes the chip's ×. /// the way to remove it is the chip's ×.
/// inline left in place, attached via_tag = 1, still detached when its text /// inline left in place, attached via_tag = 1, detached when its text goes.
/// goes. Unchanged from before.
/// ///
/// Mirrors `_lift_and_reconcile_tags` in the server's `notes/tags.py`. /// Mirrors `_lift_and_reconcile_tags` in the server's `notes/tags.py`.
fn lift_and_sync_tags(conn: &Connection, note_id: &str, body: &str) -> rusqlite::Result<()> { fn lift_and_sync_tags(conn: &Connection, note_id: &str, body: &str) -> rusqlite::Result<()> {
@@ -384,9 +379,8 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note
if let Some(f) = &q.facets { if let Some(f) = &q.facets {
if let Some(text) = f.q.as_deref().filter(|s| !s.is_empty()) { if let Some(text) = f.q.as_deref().filter(|s| !s.is_empty()) {
// One placeholder, one bind. This pushed the pattern twice after the // One placeholder, one bind: rusqlite refuses a count mismatch, which
// title column went (95aa10c), and rusqlite refuses a count mismatch, // would fail every desktop search.
// so every desktop search failed.
sql.push_str(" AND body LIKE ? ESCAPE '\\'"); sql.push_str(" AND body LIKE ? ESCAPE '\\'");
binds.push(format!("%{}%", escape_like(text))); binds.push(format!("%{}%", escape_like(text)));
} }
@@ -681,7 +675,7 @@ pub fn complete_reminder(conn: &Connection, id: &str) -> rusqlite::Result<Note>
} }
/// The longest snooze, in minutes: 30 days. The server's snooze route clamps to the /// The longest snooze, in minutes: 30 days. The server's snooze route clamps to the
/// same range (`notes.SNOOZE_MAX_MINUTES`), so a device and the web agree (#5386). /// same range (`notes.SNOOZE_MAX_MINUTES`), so a device and the web agree.
pub const SNOOZE_MAX_MINUTES: i64 = 60 * 24 * 30; pub const SNOOZE_MAX_MINUTES: i64 = 60 * 24 * 30;
pub fn snooze_reminder(conn: &Connection, id: &str, minutes: i64) -> rusqlite::Result<Note> { pub fn snooze_reminder(conn: &Connection, id: &str, minutes: i64) -> rusqlite::Result<Note> {
@@ -870,7 +864,7 @@ pub fn reorder(conn: &Connection, ordered_ids: &[String]) -> rusqlite::Result<()
let total = ordered_ids.len() as i64; let total = ordered_ids.len() as i64;
let at = now(); let at = now();
for (i, id) in ordered_ids.iter().enumerate() { for (i, id) in ordered_ids.iter().enumerate() {
// A note someone shared with us moves on this account's board only (#5176), // A note someone shared with us moves on this account's board only,
// stamped as its own state rather than as an edit. // stamped as its own state rather than as an edit.
conn.execute( conn.execute(
"UPDATE notes "UPDATE notes
@@ -1024,11 +1018,10 @@ pub fn create_label(conn: &Connection, name: &str) -> rusqlite::Result<Label> {
/// Rename a label. Renaming ONTO a name another label already holds MERGES the two. /// Rename a label. Renaming ONTO a name another label already holds MERGES the two.
/// ///
/// It cannot simply be an UPDATE: `idx_labels_name` is unique on `lower(name)`, so /// It cannot simply be an UPDATE: `idx_labels_name` is unique on `lower(name)`, so a
/// the bare statement failed with a raw SQLite "UNIQUE constraint failed" that /// bare statement fails with a raw SQLite "UNIQUE constraint failed". Merging is the
/// reached the user as database internals. Merging is the operator's call, and it /// reading that matches what a person means — typing an existing tag's name onto
/// is the reading that matches what a person means — typing an existing tag's name /// this one says "these are the same thing."
/// onto this one says "these are the same thing."
/// ///
/// THE OLDER ROW SURVIVES, and takes the new spelling. Older rather than "the one /// THE OLDER ROW SURVIVES, and takes the new spelling. Older rather than "the one
/// that already held the name" because age is the property neither participant's /// that already held the name" because age is the property neither participant's
@@ -1118,11 +1111,9 @@ pub fn merge_labels(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
//! The store had no tests of its own until two bugs shipped in it unnoticed: //! Every test here runs a real query against a real schema: reading columns by
//! `load_note` reading its timestamps from the wrong columns after one was //! the wrong position, or binding a pattern twice for one placeholder, is the
//! dropped (fa89da1), and the board's text facet binding its pattern twice for //! kind of thing only that catches.
//! one placeholder (95aa10c). Both are the kind of thing only a real query
//! against a real schema catches, which is what every test here runs.
use super::*; use super::*;
@@ -1158,7 +1149,7 @@ mod tests {
notes.iter().map(|n| n.id.as_str()).collect() notes.iter().map(|n| n.id.as_str()).collect()
} }
// ---- notes shared with this account (#5175) -------------------------------- // ---- notes shared with this account ----------------------------------------
/// A note someone else owns, as the feed leaves it: clean, and held at `permission`. /// A note someone else owns, as the feed leaves it: clean, and held at `permission`.
fn shared(conn: &Connection, id: &str, permission: &str) { fn shared(conn: &Connection, id: &str, permission: &str) {
@@ -1583,8 +1574,8 @@ mod tests {
assert!(edited.labels.is_empty()); assert!(edited.labels.is_empty());
} }
/// Every letter folds, accented ones included, as on the server (#5385). With /// Every letter folds, accented ones included, as on the server; SQLite's lower()
/// SQLite's lower() the second note minted a "CAFÉ" tag of its own. /// would mint a second "CAFÉ" tag.
#[test] #[test]
fn a_tag_spelled_with_different_accented_capitals_is_the_same_tag() { fn a_tag_spelled_with_different_accented_capitals_is_the_same_tag() {
let conn = db(); let conn = db();

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