187 Commits
Author SHA1 Message Date
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
Bryan Van Deusen 6e524ec616 guard: an empty channel killed the lane instead of passing it
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
Android / Build, or is the channel already serving this? (push) Successful in 2s
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 6s
CI & Build / Python tests (push) Successful in 9s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m33s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m14s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m46s
The first merge to `main` took the Android lane down (run 4857): the decide
job exited 1 in 0.16 seconds with no output at all, and the image build
skipped behind it because a failing lane must not publish.

`stable` had never published an APK, which the guard treats as a pass — there
is nothing to go backwards from, and `[ -z "$published" ]` says so in a branch
of its own. That branch was unreachable. `published="$(published_for ...)"`
under `set -e` dies on the substitution before it, and everything the pipeline
would have printed goes into the capture rather than the log.

What decided which lookups had the bug is the last command in the pipeline.
`sed` on empty input exits 0; `grep` exits 1. Three of the four end in `sed`.
Android's version_code ends in `grep -oE '[0-9]+$'`, so it was the only one —
and only on a channel with nothing on it, which is why a week of dev pushes
never saw it.

The tests now reach the half of the guard that talks to a feed, with `curl`
shadowed on PATH so they stay hermetic: an empty channel passes and builds, a
lower published version passes, a higher one fails the lane, and an equal
Android code is refused because Android will not install it.
2026-08-29 13:45:26 -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
bvandeusenandClaude Opus 5 c2fdc05e5c release: a tag builds nothing and carries a changelog instead
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m17s
Desktop (Tauri) / Update manifest (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m0s
Android / Kotlin + Rust (APK) (push) Successful in 8m4s
Step 7 of M314, the last one. Rule 22 — the old path comes out completely.

## A release stops building

`desktop.yml` no longer triggers on `v*`, and its two `Publish release` steps
are gone. `ci.yml` lost its tag trigger in step 6. So a tag now reaches exactly
one lane: the new `release.yml`, which builds nothing.

That is not a simplification for its own sake. The merge to `main` already
published everything a user can receive — `:latest` + `:<sha>`, both channel
feeds, the updater manifest. A tag rebuilding that source produces identical
artifacts under identical names and re-pushes `:<sha>` with different bytes,
which rule 145 forbids even when they match.

## So what a release is FOR

The changelog (note 3127 §5). Two halves to "what am I running", and the
version answers only the first: which build is this (the footer, /api/config,
the APK's versionName) and what is in it that was not in the one I ran last
month (nothing, until now).

`packaging/release-notes.sh` derives it from git rather than a hand-maintained
CHANGELOG, which drifts into recording what someone MEANT to ship. Capped at 60
entries with the omitted count stated — the first dated release spans 181
commits since `v0.1.0`, and a truncated list that does not say it is truncated
is a lie.

It publishes through `publish-release.sh` rather than making its own API calls,
for the create-or-PATCH-on-409 path: a fixed-tag release that only ever POSTs
keeps whatever body its first run wrote, which is #2182, and reimplementing that
correctly in a second place is how it comes back.

## Retired

`MANIFEST_TAG` and the whole branch behind it. It let the manifest live on a
`stable` pointer release while the bundles sat on a versioned one — a split step
3 removed when `stable` started holding its own bundles. Nothing had passed it
since; a parameter that can only ever receive its own default is a branch nobody
exercises and a comment that goes stale, and its stale text was still telling
readers the installable builds live on the versioned releases.

`desktop/src-tauri/Cargo.toml`'s version and `thoughtsync/__init__.py`'s both
now say out loud that they are not shipped values. The Cargo one carries the
history worth keeping: the old scheme took its base from that line, so `0.2.<run>`
on dev outranked a bare `0.2.0` on main, and the remedy was "remember to bump the
minor before tagging" — documented in a comment, enforced nowhere. #2183 is what
that looked like in the field. **That ritual is now formally dead**, and this is
the deliberate act of killing it rather than a side effect.

## Still there on purpose

`install.sh`'s transitional stable fallback. It cannot go until `main` has
published to `stable` at least once, and that is gated on an operator request.
Removing it now would break the DEFAULT install channel.

#3147

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:43:21 -04:00
bvandeusenandClaude Opus 5 fa43c2f4e9 ci: a docs-only merge to main produced no image, so no :<sha> for that commit
CI & Build / Build now, or wait for Android? (push) Successful in 3s
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
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Successful in 17s
Rule 145 promises every push to `main` publishes a `:<sha>`, so any production
commit is addressable without a release ceremony. `ci.yml`'s `paths:` filter
quietly broke that: a commit touching only docs never triggered the lane, so
that commit had no image and no sha tag.

Pre-existing — the filter has always been there — but it is rule 145's guarantee
and step 6 is where the tag set is being made to match the rule, so it is this
step's to close.

Confirmed live on a0c789b: a docs-only push produced two runs, both client lanes
skipping correctly, and NO image at all.

The server image now always builds. It is the cheap one — ~15 seconds against 6
and 9 minutes for the clients, which is exactly why they skip and it does not —
and always building is what keeps `python:3.12-slim` fresh on something that can
face the internet. That is also why §4's base-image tension does not bite this
project: the artifact it would apply to is the one that never skips.

#3146

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:32:27 -04:00
bvandeusenandClaude Opus 5 a0c789b3ba docs: the image tag list said something step 6 stopped being true
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
`:<git-sha>` is on `main` only now — a sha tag per dev push was a rollback
target nobody had ever pulled — and `:<version>` never existed as an image tag
after rule 145 was narrowed. Both were still documented.

`docs/android-distribution.md` also said `:dev`, `:latest` and `:<version>` all
ship a client, which is now two-thirds true and misses the more useful fact: the
channel IS the image you run, so a stable server serves a stable client. Worth
saying because until step 3 it was hard-wired to the dev release on every branch
and did the opposite.

This push is also the skip-if-exists verification. It touches neither client's
file set, so both `decide` jobs should report the channel already serving the
current version and skip a 6- and a 9-minute build — while the guard still runs
on that path (§6.3).

#3146

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:27:43 -04:00
bvandeusenandClaude Opus 5 22a9a279b1 ci: one definition of what ships decides both the version and whether to build
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 10s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m5s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m24s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m12s
Step 6 of M314. Two changes that only make sense together.

## The image tag set rule 145 mandates

  dev push   -> :dev
  main push  -> :latest + :<sha>
  a v* tag   -> nothing; the trigger is gone

`:<sha>` was going out on EVERY branch — a rollback target nobody has ever
pulled, accumulating forever, for a channel whose entire contract is that it
moves. It is on main only now, where rollback matters and where gated merges
(rule 2) make it dozens per year rather than one per push.

No version-shaped image tag in any lane. Verified the way rule 145 asks — by
looking for a CONSUMER, not for whether one is imaginable: `docker-compose.yml`
is parameterised for a pin and the docs describe the option, but no compose
file, deploy script or CI job reads one.

## Skip-if-exists, adapted, because §4 assumes a registry §5 removed

Note 3127 §4 says to ask the registry whether that exact version exists. There
is no `:<version>` tag to ask about any more. What there IS, for both clients,
is a channel that publishes the version it serves — and that answers the same
question: if the channel already serves what this source derives, the artifact
would be byte-identical.

So the `paths:` filters are gone from the desktop and Android lanes, replaced
by a `decide` job reading the real file set. That duplication is not
theoretical: `packaging/` was added to the sets and not to the filters, so the
commit that fixed a derivation bug never ran on the two lanes it fixed
(85ead4d). One definition, one reader.

The cost is that both workflows now start on every push rather than a matching
one — a ~15s container for a decision, against a lane that cannot silently fail
to run.

## The server always builds, deliberately

Its image is ~15 seconds against 6 and 9 minutes for the clients, so there is
little to save. And always building is strictly BETTER for something that can
face the internet: it picks up `python:3.12-slim` base updates on every push.

That also dissolves §4's base-image tension for this project rather than
deciding it — the artifact most exposed to base staleness is the one that never
skips. Resolving a base digest at derive time was the alternative and it is
forbidden: §7's corollary bars an external lookup, because two lanes would then
derive different values for one source.

## The guard runs on the skip path

It moved into `decide`, ahead of the decision. §6.3 is explicit that skipping
because "this version already exists" is indistinguishable from "we derived a
stale value that happens to match" unless something checks. It also now runs
once per lane instead of once per job.

## Two defects found while wiring this

`ci.yml`'s gate greps a path list that MUST match Android's file set, and
`packaging/` was missing from it. A packaging-only push would have had the
Android lane build and dispatch while the gate ALSO let the image through —
two images for one commit, and on main a second push of the same `:<sha>` with
different bytes. Rule 145's exact prohibition.

`guard-forward.sh` ends every fetch in `|| true`, so a runner image without
curl would have read as "nothing published yet" and passed without checking
anything. Missing curl is now fatal.

#3146

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:19:16 -04:00
bvandeusenandClaude Opus 5 0ab7d94294 versioning: refuse to publish a version below what the channel already serves
CI & Build / Python tests (push) Successful in 17s
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 6s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m21s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 9m19s
Step 5 of M314, note 3127 §6.3. Everything else in this milestone derives a
number and trusts it; this compares the derived value against what the channel
is actually serving and fails the lane if it went down.

Too-low is the unrecoverable direction: every installed client reports "up to
date" forever, and no later build fixes it until one climbs back above the bad
number. #2183 and #2993 are both that symptom.

## Two hazards, two mechanisms

A shallow clone is now tested DIRECTLY, in `version.sh`, via
`--is-shallow-repository`. The empty-result guard only caught the case where
nothing matched — and run 4796 showed the worse one, where a partial match
returned a real six-days-stale answer. Asking the question outright costs no
network and covers artifacts with nothing published to compare against.

`guard-forward.sh` handles the rest: a squash or rebase merge rewriting the
committer date, a rebuild of an older commit, and clock skew between runners.

## The comparison is per artifact, and the operator differs

  desktop  derived >= published   commit time, so equality is the ORDINARY
                                  no-change case and `<=` would fail every
                                  build that changed nothing
  android  derived >  published   build time, so equality means two builds in
                                  one minute — and Android refuses to install
                                  an APK whose versionCode does not RISE

The server is deliberately unguarded: nothing compares its version, `:latest`
moves regardless, and rule 145 removed the version tags that would be the
published list. A too-low value there is a wrong date in a footer, not a
stranded client. It still gets the shallow-clone check.

## Proved to fire, not assumed

Cloned the repo, checked out a commit eight back, ran the guard against the
LIVE dev feed:

  at the tip     derived 1.0.3502151, published 1.0.3502151  -> pass
  eight back     derived 1.0.3501535, published 1.0.3502151  -> FAILS
  android tip    derived 3502171,     published 3502152      -> pass
  stable         derived 1.0.3502151, published 0.2.0        -> pass

That last row is worth keeping: stable still advertises the bare `0.2.0` from
the old Cargo.toml scheme, so the transition orders upward on BOTH channels,
not just the one being exercised.

A channel with nothing published passes rather than failing — otherwise the
first publish to a new channel could never happen.

The guard runs BEFORE the build in all three lanes, so a bad derivation costs
seconds rather than a five-minute compile and a publish to undo.

`compare` is exposed as an explicit mode so the ordering is testable without a
network and inspectable without a push — 16 cases including `1.0.9 < 1.0.10`,
which a string compare gets exactly backwards.

#3145

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:33:00 -04:00
bvandeusenandClaude Opus 5 6e891357ff ci: the deriver is in the file sets but was not in the path filters
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m58s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m23s
Desktop (Tauri) / Update manifest (push) Successful in 6s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 22s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m59s
`85ead4d` changed `packaging/version.sh` — the script that decides what every
artifact claims to be — and the desktop and Android lanes did not run at all.
Only CI & Build fired, and only because it happens to watch `tests/**`.

So the fix in that commit is unverified on exactly the two lanes whose bug it
was fixing.

`version.sh` lists `packaging` in all three file sets; the workflows' `paths:`
filters did not. Two places holding one decision, with one of them updated —
the failure this subsystem keeps producing (#2181-2183, and again in step 3
where `install.sh` still expected stable's bundles on a versioned release).

The script's own header already warned about this: "a change here that is not
mirrored there means a lane that does not fire — check both." Written, then
not followed, in the same commit.

Step 6 removes the duplication for real by replacing these filters with
skip-if-exists. This is the stopgap until then, and it says so at each site.

#3144

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:11:26 -04:00
bvandeusenandClaude Opus 5 85ead4d66b versioning: anchor at the repo root — a pathspec is relative to the caller's cwd
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 15s
Three failures on c504433, two root causes, and the interesting one is that
`git log -- <paths>` resolves pathspecs against the CURRENT DIRECTORY.

Callers run from wherever suits them: the desktop build from
`desktop/src-tauri`, the Android build from `android`, the manifest job from
the root. So one push produced THREE versions:

  desktop build      1.0.3494522     <- six days stale
  pacman packager    1.0.3502131
  manifest job       1.0.3502131

The build's pathspec had matched `desktop/src-tauri/Cargo.toml` — a real file
— so git answered with the newest commit touching THAT. Non-empty, so the
shallow-clone guard could not fire; the manifest then found no bundle matching
its own answer and the lane went red two steps from the cause. The Android job
failed loudly in the same run only because ITS pathspec happened to match
nothing from `android/`. Same bug, luckier symptom.

The script `cd`s to `git rev-parse --show-toplevel` before doing anything now,
and the test asserts every artifact answers identically from four directories.

## And a third instance of the trap that bit yesterday

The unit test caught it: `version.sh display nope` printed "unknown artifact"
to stderr and then answered `2026.08.28.0900` with exit 0. `paths_for` is
reached through `$(paths_for "$1")`, so its `exit 2` ended the subshell,
returned an EMPTY pathspec — and an empty pathspec matches everything.

That is now three occurrences of one mistake in one file: the shallow-clone
guard on `key` (emitted `1.0.-26297280`, exit 0), the same guard on `display`
(which failed only because `date` then choked on the empty string), and this.
Each was found by a different mechanism and none by reading the code. The
artifact is validated in the parent shell now, and the file says so where the
next guard would be written.

Both tests assert on STDOUT as well as the exit code. The exit code alone
passed for `display nope` while stdout carried a lie.

#3144

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:03:04 -04:00
bvandeusenandClaude Opus 5 c5044339a1 versioning: each artifact derives from its own files, with the clock picked per value
CI & Build / Python lint (push) Successful in 4s
CI & Build / Python tests (push) Canceled after 13s
CI & Build / integration (push) Canceled after 13s
CI & Build / Build & push image (push) Canceled after 0s
Android / Kotlin + Rust (APK) (push) Failing after 14s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m19s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m24s
Desktop (Tauri) / Update manifest (push) Failing after 4s
Step 4 of M314. `desktop/packaging/build-version.sh` was one generator feeding
the desktop bundles AND the Android APK off `GITHUB_RUN_NUMBER`, so a
Kotlin-only commit re-versioned the desktop and a Rust-only commit
re-versioned the phone. Note 3127 §3 cites this repo as its example of that
failure. It is replaced by `packaging/version.sh` — one definition of HOW to
derive, three file sets, and the sets in one place.

Lives at the repo root rather than under desktop/, because it serves three
artifacts now and a shared thing filed under one consumer ends up owned by it.

## Two values, and the clock chosen per value (§2)

  desktop  key      1.0.<minutes since 2020-01-01>   commit time
  desktop  display  2026.08.28.0900                  commit time  (#3181 shows it)
  android  versionName                               commit time
  android  versionCode  <minutes since 2020>         BUILD time
  server   version  2026.08.28.0900                  commit time, no ordering key

Every human-readable version in the repo is now one shape. The two exceptions
are not version names at all — they are bare monotonic integers a comparator
reads and nobody quotes.

The desktop needs a separate key because Tauri parses `latest.json` with the
semver crate and `2026.08.28.0900` fails it twice (four segments, and `08` is a
leading zero). `1.0.` and not `0.0.`: the minor has to clear the installed
`0.2.466` line or every dev user is stranded on "up to date" permanently.

Android's code comes from BUILD time while the desktop's key comes from COMMIT
time, deliberately. Android hard-fails a downgrade with
INSTALL_FAILED_VERSION_DOWNGRADE and leaves a channel you cannot get out of, so
its key must be monotonic by construction; the desktop merely declines to offer
an update, which a guard can catch.

## The bug this found in itself

The shallow-clone guard `exit 1`-ed inside a function called as `$(...)` —
which ends the SUBSHELL, not the script. `display` still failed, but only
because `date` then choked on the empty string. `key` printed the error to
stderr, emitted `1.0.-26297280`, and exited ZERO.

That is precisely the failure the guard exists to prevent: a too-low version on
a green lane, and too-low is the direction you cannot recover from. It resolves
into a global in the parent shell now. The test is parametrized over both
requests, because one path was covered and the other was broken in exactly the
way the covered one was meant to rule out.

## Also

`fetch-depth: 0` on every job that derives — four of them, and only ci.yml's
gate had it. Depth-1 is silently wrong rather than loudly broken (§6.1).

The file sets include each artifact's BUILD RECIPE (its workflow, and
`packaging/`). A workflow file is not shipped, but change a Gradle flag and the
bytes change while the source does not — and once step 6 skips a build whose
version already exists, that serves the OLD artifact on a green run.

The base images are deliberately NOT resolved at derive time: that is an
external lookup, which §7's corollary forbids. `Dockerfile` is already in the
server's set, so pinning `FROM` by digest in step 6 puts the base inside the set
for free.

`build-version.sh` is deleted, its last consumer (the pacman packager) moved
over, and the one finding worth keeping out of its header — why not a `-dev.N`
prerelease — is preserved in the successor.

#3144

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 20:51:39 -04:00
bvandeusenandClaude Opus 5 c268ae4f23 ci: main publishes, so a tag stops being required — and :latest stops shipping a dev client
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 17s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m36s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m24s
Step 3 of M314. Note 3127 §0's diagnostic is "is `main` publishing
sufficient for a user to receive the build" — and here it was not. The
desktop and Android lanes BUILT on main and published nothing: `Publish
release` was gated on `refs/tags/v*`, the channel publishes on
`refs/heads/dev`, the manifest job on dev-or-tag. So the stable channel
moved only when somebody cut a tag, which made a `v*` tag load-bearing
rather than the optional bookmark the model wants.

Both channels are rolling fixed-tag releases now. `dev` from dev, `stable`
from main, same machinery — `publish-release.sh` already took RELEASE_TAG,
`write-manifest.sh` already pruned, and both already PATCHed a stale
description on 409 (#2182). This is wiring, not new mechanism.

## The defect this carried

`ci.yml`'s "Fetch the Android client to bake in" read
`releases/download/dev` UNCONDITIONALLY, on every branch. Every image baked
in the dev APK — `:latest` included — so a stable server served a
dev-channel client to anyone who downloaded it from there. That has nothing
to do with versioning; it is fixed here because this is the step that
finally gives `stable` an APK to point at.

It also means Android needs no channel machinery of its own. The APK is
served FROM the image, so the channel is already a property of which image
you run — note 3127 §7's "nothing to hand off" shape, arrived at here by
accident. One branch-conditional line, not a second channel in
`client_dist.py` as this milestone first assumed.

## The break this nearly shipped

`install.sh --channel stable` read the version out of `stable/latest.json`
and then fetched `releases/tags/v<version>` for the bundles — correct while
stable was a manifest-only pointer, and broken the moment stable holds its
own. Stable is the DEFAULT channel, so `curl … | sh` would have failed for
everyone between this commit and the first merge to main.

Both channels are one lookup now: fetch the fixed-tag release, install what
is on it. A transitional fallback covers the window where `stable` still
has no bundles, marked for deletion in step 7 — without it the default
channel is broken for however long it takes to merge, and that window is
gated on an operator request rather than on this lane.

## The two writers problem

`stable`'s manifest was written by tag builds. It is written by main now,
and the tag path stops writing it — two writers for one channel is a race
with no winner worth having. A `v*` tag still writes its own versioned
manifest; its build consequence goes entirely in step 7.

Also corrected: `update.rs`'s header still described stable as following
`v*` tags. Nothing in that file moved — it only ever read
`<channel>/latest.json` — but the comment was a lie, and it is the file
somebody reads to understand the feed.

#3143

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 18:22:31 -04:00
bvandeusenandClaude Opus 5 b7e0e5dbba ffi: two items: lines left at the indent of the field above them
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m56s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m24s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m33s
`cargo fmt --check`. Deleting `color:` from these two NoteDraft literals left
the line after it one level too deep — the sort of thing a formatter exists to
catch and an eye does not.

#3041

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:23:10 -04:00
bvandeusenandClaude Opus 5 e14d9d340a core: the v9 test pinned v8, and a blank line ktlint counted
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m33s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m48s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 7m35s
Two CI failures from the colour removal, both mine.

`a_fresh_database_reaches_v8` asserted the version the migration no longer
stops at. Renamed to say what it actually guards — the LATEST version — so the
next migration updates a number instead of a name that has quietly become
wrong.

While there, two tests the migration deserved and did not have. One asks
SQLite whether `notes.color` is gone rather than reading a row back, because a
SELECT that omits the column passes either way; it also asserts `labels.color`
is still there, since getting that wrong would take every tag's colour with it.
The other seeds three saved views and checks the sweep: one loses its colour
key and keeps its query, one without the key is untouched, and one holding
text that is not JSON at all comes out unchanged rather than NULL.

Writing that third case is what found a real bug in the migration. The guard
was `json_valid(params) AND json_extract(params, '$.color') IS NOT NULL`, which
is the obvious way to write it and is a trap: SQLite does not promise to
short-circuit AND, so `json_extract` can be evaluated against the very rows
`json_valid` was there to exclude — and on malformed input it does not return
NULL, it RAISES, which would have aborted the whole migration over one corrupt
blob. It is a LIKE now, which is total over any text.

The ktlint failure is a doubled blank line where `EditorAction.SetColor`'s
branch used to be.

#3041

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:15:33 -04:00
bvandeusenandClaude Opus 5 fa89da1fab notes: color leaves the model, the wire and all three surfaces
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m28s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 4m1s
Step 3 of M315, and the destructive half. Steps 1 and 2 stopped every read of
this field: a card is one neutral surface per theme, and the only coloured
thing on a board is a tag. What was left was a column written by a picker and
read by nothing.

Rule 22 — the old path comes out completely. No flag, no fallback, no
"override if set".

Server: the column, the `?color=` facet, the create/update/serialise paths,
the sync assignment, the front-matter line, and Keep's colour map. Alembic
0029 drops it and sweeps `"color"` out of stored saved-filter params — a view
that silently filtered on a field the app no longer has would return nothing
and never say why. That sweep is Python, not `params::jsonb - 'color'`,
because Postgres has no try-cast and one malformed blob would abort a
migration that is running over somebody's saved views.

`NOTE_COLORS` moves from `models/note.py` to `colors.py`. A palette defined on
the model that lost one is an invitation to put the column back; labels still
name a colour, so the vocabulary belongs where the normalizer already is.

Core: the field, the facet, the `NoteCreateInput`, and every read and write in
store/push/pull. Local schema v9 drops the column and does the same
saved-filter sweep, guarded on `json_valid` so a corrupt blob loses a key
rather than becoming NULL. The uniffi layer drops `NoteEdit::Color` and
`NoteDraft.color` with it.

Web: `ColorPicker.vue`, the per-card swatch popover and its stylesheet rule,
the FilterBar colour row, the facet in the query round-trip, and the colour
half of the editor's baseline-and-save. Android: the `ColorSheet`, the
`Picker.COLOR` case, the toolbar's swatch dot, `EditorAction.SetColor`.

## The protocol: v4, and the floor deliberately stays at 3

Checked against `compat.rs` and the push handler rather than trusting the
`#[serde(default)]` annotation, because the v2 precedent points the other way:
v2 dropped `kind` and `title` and DID raise both floors, on the rule that
dropping a field a client sends and expects back is breaking.

`color` fails the second half of that test. A v3 client reading a v4 note gets
`"default"` from its own serde default and draws the colour it derives
locally — the board it drew yesterday. A v3 client pushing `color` has the key
ignored, since `_assign_note_fields` reads its payload key by key and never
validates the shape. Neither direction errors and neither shows anything
wrong. `title` was the note's NAME; this is a field that no longer renders.

So `SYNC_PROTOCOL_VERSION` and `CLIENT_PROTOCOL_VERSION` go to 4, and both
floors stay at 3. `docs/sync.md` carries the reasoning and the per-version
history, and its push example is brought back in line — it still listed
`title`, `kind` and `items`, all gone before this.

Import stays tolerant: a pre-M315 export or a Keep takeout carrying `color:`
imports fine, the key simply read past. Old exports must still import.

#3041

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:07:03 -04:00
bvandeusenandClaude Opus 5 13a88179b8 tags: one ink, chip and inline, and the chip edge solved for 3:1
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 5s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m31s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m1s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 8m45s
Step 1 left one card surface per theme, so the tag ink is no longer
choosing a value that has to clear twenty backgrounds. Re-measured against
the one it actually lands on, the two tables collapse into one.

`-800` in light, `-300` in dark, for a `#tag` in the prose AND for a chip's
text. Dark needed no decision at all — the two tables already held the same
value for all ten hues, which is most of the argument on its own. Light
collapses onto the INLINE column deliberately: since M311 a tag whose text
is in the body is drawn where it was typed and not repeated as a chip, so
inline is the common case and this leaves what is seen most exactly as it
was. The chip is strictly better for the move:

                    inline, on the card    as a chip, on its own fill
  light `-800`      7.09 - 15.13           6.37 - 12.01  (was 4.52 - 8.23)
  dark  `-300`      9.45 - 14.23           8.23 - 11.88  (unchanged)

The chip edge goes 0.60 -> 0.65, and this is the first time that number
could be solved rather than judged. 0.60 was picked against a chip sitting
on a card of its own colour, a case that no longer exists; against a known
fill the smallest alpha clearing the 3:1 of WCAG 1.4.11 for all ten hues is
arithmetic. 0.60 gives 2.75-3.82 and misses for six of them, 0.65 gives
3.03-4.36 and misses for none. Dark runs 4.52-5.76.

That edge is doing more work than it looks: a chip's fill measures 1.02-1.26
against the card in light and 1.02-1.73 in dark, and dark red at 1.02 is
invisible. The ring is the pill; the fill only tints it.

`LABEL_CHIP_CLASSES` becomes `LABEL_CHIP_SHELL` — fill and edge, no ink —
and `labelChipClasses(label)` composes shell and ink in one place. The board
and the editor each had their own copy of that composition, with a comment
on one of them asking the other to stay in step. Now it is one call.

Fixed on the way past: the web drew `default`'s chip ring at `black/10`
(1.36 against its own fill) where Compose derived it from the ink (3.21) —
the same chip, visibly different pills. Both are the ink at 65% now.

`chipForeground` stays, narrowed to what it always actually was: the
REMINDER pill's ink, transcribed from NoteCard.vue's literal red-700 /
neutral-600. It is not a tag and must not move with one.

Also gone: `NOTE_NODE_FILL`, a per-hue table of solid hexes for graph nodes
with no consumer anywhere in the repo.

Step 2 of M315. #3149

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:13:43 -04:00
bvandeusenandClaude Opus 5 b91091caca cards: one neutral surface, and the generated fill deleted with it
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m54s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m15s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m59s
A card's fill stops being a function of the note. One neutral per theme on
all three surfaces — white in light, neutral-900 in dark, which is what the
palette's `default` always was and what both editors already used, so this
is a collapse onto a surface everything already had rather than a colour
anybody has to like.

Measured, against "not the same color as their background but close to it":
card vs board 1.04 light / 1.10 dark, edge vs card 1.98 / 1.73, body text
17.93 / 17.17, muted 10.37 / 14.23. The fill is deliberately the weakest
number on the card — the edge and the shadow separate it from the board, so
a fill that separated on its own would make it a panel.

Deleted, since the card was their only consumer: `derivedFill` / `hslHex`
and the level tables in colors.ts, `derivedFillArgb` / `hslToArgb` in
DerivedTint.kt, `NOTE_CARD_CLASSES_STRONG`, `chosenNoteColor`,
`noteCardClasses`, `noteTintVars`, the `.note-tint` rule in style.css,
`chosenBackground` / `tintable` / `noteTintFor` / `noteCardColor` /
`noteIsStrong` / `firstLabelColor` in NoteTint.kt, and
`resolvedNoteColor` / `noteColorIsChosen`.

`tintHash` and `derivedTint` STAY, against the plan: a label with no colour
of its own still derives one from its name, and that path was never the one
that failed. The mirrored pair and its fixture survive intact.

The editor follows the card, and its Done button takes the brand — the
board's compose FAB is the app's existing statement of "affirmative action
here", where Material's default secondaryContainer is a baseline colour this
theme never sets.

The colour picker is left in place, doing nothing, for exactly one step:
removing it here would leave `note.color` written by nothing and read by
nothing, which is a worse intermediate than a control that visibly does
nothing. #3041 takes the field and the picker together.

Step 1 of M315. #3148

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:55:51 -04:00
bvandeusenandClaude Opus 5 f50204a98b editor: detekt counts returns, so the promotion guards collapse into one
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 5s
CI & Build / TypeScript typecheck (push) Successful in 9s
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m0s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m0s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m3s
`promotingTasks` had four returns against ReturnCount's limit of two — three of
them the same `return this`. Collapsed into a null-or-task guard and a
`changed` flag, which says the contract more plainly anyway: the list comes
back untouched unless something was actually promoted.

Mirrored in blocks.ts even though nothing lints it there. The two files are
kept line-by-line alike on purpose, and letting them drift on shape is how the
next person stops trusting that reading one tells you the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:59:15 -04:00
bvandeusenandClaude Opus 5 1a49ae7ea9 editor: a - [ ] typed by hand becomes a real item when you leave the line
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m15s
Android / Kotlin + Rust (APK) (push) Failing after 5m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m31s
Desktop (Tauri) / Update manifest (push) Successful in 4s
`splitBlocks` runs once, when the editor opens. After that the blocks ARE the
state and nothing reads the body again — every edit travels the other way,
through `joinBlocks`. So a marker typed by hand stayed literal text on screen
until the note was closed and reopened, even though it was already a real item
in storage and the card was already drawing a checkbox for it. The editor was
the only place that disagreed with itself. (#3024)

On BLUR, and only the block being left. There is no good moment to convert
while someone is typing: re-splitting on a keystroke moves the caret out of the
word being written, and converting the instant `- [ ]` is complete does it
before the item has any text. Blur is the one moment the person has
demonstrably finished with the block.

`promotingTasks` / `promoteTasks` return the SAME list when there was nothing
to promote, and both call sites compare by identity. Without that, every blur
would re-key every field below it — including the blur that fires on first
composition, before a field has ever held focus.

Both surfaces in one commit, deliberately: blocks.ts is a line-by-line mirror
of EditorBlock.kt, and the reason that mirror is worth keeping is that the two
editors behave identically. Fixing one would spend its whole value.

Non-canonical markers (`- [X]`, an odd bullet) come back canonical — the only
case where this changes the body rather than just how it is drawn, and exactly
what reopening the note already did.

No unit test: `splitBlocks` reaches the core over uniffi for the grammar, so it
needs the native library and cannot run in the JVM lane. No existing Android
test touches the core for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:50:43 -04:00
bvandeusenandClaude Opus 5 e7af7a4b77 board: the FAB and the undo snackbar rode behind the keyboard
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m45s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m48s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 8m57s
Found by the Scaffold audit #2951 asked for. Three Scaffolds exist; the editor
and the sync screen both consume the IME inset, and the board consumed nothing.

`enableEdgeToEdge()` makes the manifest's `adjustResize` a no-op on API 30+, so
nothing resizes for the keyboard unless the app asks — and
`ScaffoldDefaults.contentWindowInsets` is systemBars, which the IME is not part
of. The Scaffold positions the FAB and the snackbar host from that value, so
with the search field focused both sat under the keyboard.

Not theoretical, and newly load-bearing: `3f0eef1` put an UNDO on the trash
snackbar, so the one control you could not reach was the one that takes back a
note you did not mean to throw away — reachable by searching, long-pressing a
hit and trashing it.

`union` rather than `add`: the navigation bar and the IME are the same edge,
not two stacked ones, and adding them would inset twice under a keyboard that
already covers the nav bar. Set once on the Scaffold rather than per-slot, so
the content column shrinks with it and the board's cards stay above the
keyboard instead of scrolling under it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:55:23 -04:00
bvandeusenandClaude Opus 5 396e91e609 board: ktlint on the long-press menu — a named modifier, three dead imports
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m49s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m0s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 7m55s
Two failures on `3f0eef1`, both ktlint, both mine.

`chain-method-continuation`: a multiline element in a Modifier chain wants the
next `.` glued to its closing paren — `).background(…)`. Every other multiline
chain element in this codebase happens to be LAST in its chain, so nothing had
exercised the rule before. `combinedClickable` is now a named `opening`
modifier applied with `.then(…)`, which keeps the chain single-line per element
and reads better than the shape ktlint was asking for.

`no-unused-imports`: lifting the delete-forever dialog into Panel.kt took the
last use of `Text`, `stringResource` and `R` out of NoteEditorScreen.kt with
it. I had checked AlertDialog and TextButton and stopped there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 07:49:57 -04:00
bvandeusenandClaude Opus 5 3f0eef145b board: a long press on a card does what the editor's overflow does
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m9s
Android / Kotlin + Rust (APK) (push) Failing after 4m43s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m39s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Trash existed on Android and was three interactions deep — open the note,
tap the overflow, Move to trash — with nothing at all on the board itself.
The operator's read of that was not "the actions are in the editor"; it was
"there are no long hold context menus in the app I have no way to delete
notes." (#2946)

The card now takes `combinedClickable` and raises a DropdownMenu holding the
same items as the editor's overflow, in the same words, from the same string
resources, dispatching the same `EditorAction`s through the same
`BoardViewModel.onEditorAction`. A note has one vocabulary of things you can
do to it, and reusing the exhaustive dispatcher means the board cannot grow a
parallel one that drifts.

Gated on `note.trashed` rather than on the board's destination — the same
reading the editor uses for read-only, and the only one that survives
Reminders and search, which both mix piles.

Trash gets an UNDO snackbar rather than a confirmation. A long press is a
gesture you can make by accident, so the mistake worth designing for is the
one nobody meant to make, and a dialog only helps someone paying attention in
the moment they were not. Delete forever keeps its dialog; that one does not
undo.

`MenuItem` and the delete-forever dialog move to Panel.kt now that two
surfaces raise them, so there is one place for the close-before-acting order
and one wording of the consequences.

Colour is deliberately not in this menu, though #2946 suggested it:
`note.color` and its picker come out in #3041, so a swatch row here would be
building the one control already known to be leaving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 07:40:29 -04:00
bvandeusenandClaude Opus 5 4f351c10ca core: rustfmt wraps the chain in the tag-span grammar test
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m10s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m29s
Android / Kotlin + Rust (APK) (push) Successful in 7m44s
`cargo fmt --all --check`, the only failing gate on d9e5753 — clippy, all 148
tests and every other lane were green. The line was 96 characters, under the
100 max_width, but a chain is held to `chain_width` (60% of it).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:23:46 -04:00
bvandeusenandClaude Opus 5 d9e5753dc2 board: a tag in the prose is coloured where it sits, not printed twice
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 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m53s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 3m25s
A tagged note was showing its tag twice — once where it was typed, once as a
chip — and the duplicate was the loud copy. Now the chip row carries only what
the body cannot say (a tag lifted off its own line, a label from the picker),
and a `#tag` left mid-sentence is tinted in place.

Which characters are a tag is asked of the CORE, the way the card already asks
it which lines are checklist items: `extract_tag_spans` keeps the spans
`extract_tags` throws away, and `body_tags` hands them to Kotlin. Offsets are
UTF-16 code units, because `AnnotatedString` and JS both index that way and a
char index lands mid-token the first time somebody writes an emoji. The web
keeps its own matcher in markdown.ts, mirroring `line_tags` case for case.

The inline ink is its own table, one Tailwind step deeper than the chip's. A
chip brings its own -100 fill and reads against that alone; inline text sits on
whatever the card is, including a gray-tagged card at neutral-200 — where the
chip's -700 measured 3.98 (green), 4.11 (orange) and 4.34 (teal), under the 4.5
body text needs. At -800/-300 every hue lands 5.63-12.01 light and 7.20-10.84
dark across every palette and generated fill.

Chips now carry the `#` on every surface. The via_tag branch that used to
decide it is gone from the card, and Android's row said no hash at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:20:17 -04:00
bvandeusenandClaude Opus 5 8c22425e91 M311 step 3 — the core lifts too, so a note never lifts twice
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m58s
Android / Kotlin + Rust (APK) (push) Successful in 7m56s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m13s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Step 2 rewrote the notes already on disk and, because the sync_revision
trigger fires, every client pulls them. So this is not about existing notes.
It is about the ones typed from now on.

Without it: you type `#todo` on its own line, the core stores it as written,
and a second later the push comes back and the text disappears under you.
Offline it never lifts at all until you reconnect. Two surfaces disagreeing
about what a note says is the thing this codebase mirrors rules to avoid.

`lift_standalone_tags` in derive.rs is the mirror of `split_body_tags`, case
for case, with the same two guards — a fenced line is code and is never
touched, and a note that is nothing but tags keeps its text.

ONE SCANNER, not two. `extract_tags` is rewritten over the same `line_tags`
the lift uses, so the two cannot disagree about what a tag is. Line-by-line
changes nothing, since a line start and a `\n` are both boundaries, and the
existing tag tests still pin it.

Char indices rather than byte offsets for the spans, because they are used to
cut the tags back out of the line and a byte offset can land mid-codepoint.

`sync_tags` becomes `lift_and_sync_tags` and is named for the mutation: it
now rewrites notes.body, and all three callers write the body immediately
before calling, so it overwrites what they wrote on purpose. The graduation
case is handled the same way as on the server — flip the row before the
delete pass, or the same row is dropped for no longer being in the body and
the tag is silently lost.

One thing the server needed and this does not: display_title. The core
derives it on READ rather than storing it, so there is no persisted copy to
go stale.

The rename was done with a lookbehind rather than a plain substitution, after
the same operation an hour ago turned the function it had just written into
`_lift_and_lift_and_reconcile_tags`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 20:01:32 -04:00
bvandeusenandClaude Opus 5 9810a75564 M311 step 2 — the migration that lifts the notes already written
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 17s
CI & Build / Build & push image (push) Successful in 19s
Step 1 made new saves lift; this does the ones already on disk, so a note
stops showing its tag twice without having to be opened.

Same rule, and a FROZEN copy of it — `split_body_tags` is deliberately not
imported, on 0027's principle that 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 recomputed only for a
note whose body actually moved: a note named after its `#todo` line needs a
new name.

The label rows graduate in the same transaction, and that is not cosmetic. A
`via_tag` row claims "backed by text still in the body", and reconcile
detaches any row it cannot find a `#tag` for — so leaving them true would
lose every lifted tag on the note's next save. Flipping them to false is also
what makes the chip's × appear, which is now the only way to remove a tag
whose text is gone.

`updated_at` is left alone so a client holding an unpushed edit still wins
under LWW. The `sync_revision` trigger does fire, which 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 step 3.

The downgrade is empty and says why. It cannot restore the deleted lines —
nothing distinguishes one this migration removed from one that was never
there — and flipping the rows back would be actively harmful, since the text
that flag claims backs them is gone and the next save would then detach the
label for real.

Tested on the ten cases that matter, three of which are prose that must come
back byte-identical. The test pins the frozen copy against fixed expectations
rather than against the app's rule — they are allowed to diverge later, which
is the whole point of freezing one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:54:26 -04:00
bvandeusenandClaude Opus 5 606e345580 Fix the rename that renamed itself
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 16s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build & push image (push) Successful in 28s
`sed s/_reconcile_tags/_lift_and_reconcile_tags/` ran over tags.py after the
new function was already written with the new name, so the definition became
`_lift_and_lift_and_reconcile_tags` while all 15 call sites were correct.
Twelve test modules failed to import.

The check that should have caught it is the reason it got through: the
verification grep piped output through `sed 's/:.*_lift/: _lift/'`, which
trims to the LAST `_lift` and therefore prints a doubled name identically to
a correct one. A filter that can only make wrong output look right is worse
than no filter.

Same sed also clobbered the docstring's historical reference — it read "it
used to be `_lift_and_reconcile_tags`", naming the function after itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:48:50 -04:00
bvandeusenandClaude Opus 5 ad48d30c68 M311 step 1 — lift a tag that is standing on its own
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 8s
CI & Build / integration (push) Failing after 9s
CI & Build / Build & push image (push) Skipped
A tag was shown twice: once as the `#todo` you typed and once as a chip. The
chip moved to the top of the card in 23fd2da; now the text goes — but only
when the tag was the whole line.

THE RULE: a line containing nothing but tags and whitespace is removed.
Anything else is untouched.

That is the conservative reading of "standalone" and it is the operator's:
"only lift standalone tags, leave mid-sentence ones alone". The looser
reading, also stripping a trailing tag off a prose line, is rejected because
the text does not say which kind it is — `buy milk #grocery` is filing,
`remember to call #mom` is the sentence's object, and lifting the second
leaves "remember to call". Mangling a sentence to save a duplicate chip is a
bad trade.

Two guards. A line inside a ``` fence is never touched: a `#tag` there is a
shell comment in somebody's snippet, and deleting it would eat a line of
their example. And a note that is NOTHING but tags keeps its text rather than
being blanked — a duplicated chip beats an empty card.

WHY THIS IS NOT JUST A TEXT EDIT. `via_tag` labels are DERIVED from the body:
reconcile detaches any row no longer backed by a `#tag`, and the picker only
manages `via_tag=False` rows. So a naive lift deletes every tag on the next
save, and leaves them unremovable until then.

Resolved by giving `via_tag` a sharper meaning — backed by text still in the
body — rather than deleting it:

  standalone  lifted, attached as an ORDINARY label. Nothing derives it any
              more because nothing is left to derive it from.
  inline      left in place, still derived, still detached when its text goes.

Which costs nothing elsewhere, because both editors already gate their remove
button on `!via_tag` (NoteEditor.vue:618, EditorChrome.kt:349). A lifted tag
gets its × for free — and needs it, since deleting the text is no longer a
way to remove one. No wire change, no column drop, no UI change.

A tag that GRADUATES from inline to standalone is the sharp edge: its row has
to be flipped before the detach pass, or the same row is dropped for no longer
being in the body. That is the bug, and there is a test on it.

The lift and the display_title re-derivation both live inside the function,
which is renamed to admit it mutates the body. All seven call sites derive
display_title BEFORE calling, so anywhere else and every note would be named
after a line that had just been deleted. Spreading a derived-value update
across seven write paths is the failure #2965 named: "easy to miss, and it is
the common one".

Existing notes lift lazily, on their next save. The migration that does the
rest is step 2, and the core's own copy of the rule is step 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:44:43 -04:00
bvandeusenandClaude Opus 5 23fd2da91e The tag goes at the top of the card, where it gets looked at
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m58s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m21s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m6s
Label chips sat under the body, the checklist, the attachments and the link
previews. On a tall note that puts the one thing saying what a note IS below
the fold of a glance — and a board is scanned, not read. "Which of these is
about the thing I am looking for" should be the first thing the eye lands on.

Above the body rather than beside it: the body's first line is the note's
NAME (M13 steps 3 and 4), and a chip floated next to it would compete with
the thing that identifies the note. A row of its own costs one line, and only
on notes that carry tags.

Both surfaces, same order. Does not depend on tag lifting, which is a much
larger change — see the task.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:17:32 -04:00
bvandeusenandClaude Opus 5 3d490bb6f3 HSL lightness is not luminance — the dark floor was too low
The unit test I added with the generated fills failed on its first run, on
exactly the claim it was written to check, so it earned its keep immediately.

The floor was 0.090 — `neutral-900`'s own HSL lightness — reasoning that a
ramp starting at the card surface and climbing could not end up below it.
That confuses HSL lightness with luminance. At one fixed lightness the eye
sees very different brightnesses by hue, because green carries 71% of the
luminance formula and blue only 7%: at L=0.090 a yellow measures 0.0118 and a
blue 0.0061. Every blue-ish untagged note was 1.41x DARKER than the card it
was supposed to match, which on the board reads as a hole rather than as
variety — the opposite of what the whole change is for.

Solved rather than nudged: 0.113 is the lowest floor at which EVERY hue
clears the card surface. The range now measures 1.11-1.71 against the board
against the old 1.06-1.54, so the floor is back where the shipped ramp had it
and the ceiling is higher. Body text 7.8 against the 4.5 it needs, meta 4.6
against 3.0. 338 distinct dark fills.

Two things about the test are worth keeping.

It asserts on LUMINANCE rather than on the lightness that was put in — a test
of the input would have agreed with the bug and passed.

And it now sweeps 40,000 ids rather than 500. The worst case is a HUE, not an
id, and 500 ids reach only 459 of the 2160 hue/level combinations — it caught
this one by luck. 40,000 covers all 2160.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:17:32 -04:00
bvandeusenandClaude Opus 5 86f1e4a08f detekt: sector indices as a table, not a when
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m44s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m52s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Failing after 5m23s
MagicNumber's ignore list is -1/0/1/2, so the `3 ->` and `4 ->` branch
labels were findings. A lookup table has no literals to flag, and it is the
form colors.ts already uses — the two now read as the same function rather
than as two people's idea of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:08:20 -04:00
bvandeusenandClaude Opus 5 c255b170d4 ktlint: a stray blank line from the append
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m48s
Android / Kotlin + Rust (APK) (push) Failing after 4m18s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m49s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:01:40 -04:00
bvandeusenandClaude Opus 5 1d3cc7bcd4 Nine tints that looked like three — generate the fill instead
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 20s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m54s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Failing after 4m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m13s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Measured, the nine dark subdued fills were separated from each other by at
most a 1.03 contrast ratio. That is not "subtle", it is identical, and it is
why a board of them reads as one card repeated: "I only see 3 colors ... it
looks like a monolithic wall."

Two causes, and the second one is mine.

  NINE IS TOO FEW. The palette exists to say WHICH TAG. An untagged note's
  fill says nothing at all — it only has to keep the board from repeating.
  Those are different jobs and tying them together capped the second at nine
  values for a board that will hold hundreds.

  ONE AXIS IS TOO FEW. The subdued ramp varied hue while pinning every fill
  to the same lightness — deliberately, so each would read as a card against
  the board. But the eye separates by lightness first, so nine hues at one
  lightness are one card nine times. Hue alone was never going to carry it at
  that darkness.

So an untagged note's fill is now generated from its id rather than looked up:
hue anywhere on the circle, one of six lightness levels, saturation fixed.
324 distinct fills in dark and 193 in light, against nine. Separation between
fills goes from a 1.03 ceiling to 1.42.

Varying lightness is only SAFE because the card has its own grey edge now.
While the fill was the card's only boundary it could not afford to drift
toward the board; the edge bought that freedom, one commit before it was
needed.

Saturation is the one dial the hash never touches — variety comes from hue and
lightness, loudness would come from saturation. Dark starts a hair under
`neutral-900` and climbs, so no note is ever darker than a plain card. Light
runs from white down past the board. Body text measures 8.7 at worst against
the 4.5 it needs; the meta row 5.1 against 3.0.

DOUBLE, NOT FLOAT, on the Kotlin side. JavaScript has one number type and it
is binary64; a Kotlin Float is binary32, so the two would round differently
near a channel boundary and a note would be one byte off between the phone and
the browser. Nobody would ever file that — they would see two colours that are
"sort of the same" and never work out why.

The web half cannot be executed here at all (no node on this machine), so the
Kotlin fixture test is the only place the two implementations are ever
compared. It now pins eight generated values as well as the hash, plus the
properties that actually matter: that lightness varies, that nothing sinks
below the card surface, and that body text stays clear of AA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:55:50 -04:00
bvandeusenandClaude Opus 5 ae2053d2ed Give the cards an edge again — one grey, not ten hues
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 9s
CI & Build / Python lint (push) Successful in 10s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 20s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m59s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m39s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m20s
The border was never the problem; a border that carried COLOUR was. It said
exactly what the fill already said, at 1.56-2.09 against that fill where the
fill managed 1.03-1.05 against the board — the loudest element on every card
was redundant with the quietest. A line that varies by colour is content and
competes with the fill. A line that never varies is structure and does not.

So the edge comes back, and it comes back as a constant in NoteCard rather
than a column in the palette. Uniformity is the feature, and putting it where
the palette cannot reach it is how that stays true.

  light  #b8b8b8    1.57-1.98 against all twenty card fills
  dark   #404040    1.58-1.73

Matched, not eyeballed: both land at ~1.6-1.7 against the card they edge, so
the edge reads with the same authority in either theme. Dark is `neutral-700`
— what the `default` card's border always was, one entry's value promoted to
the rule for all of them. Light sits between `neutral-300` and `neutral-400`
because neither lands in range: 300 fades to 1.18 on a gray-tagged card, 400
jumps to 2.52 and reads as a wireframe.

Rejected on measurement: a translucent black/white edge, which is the tidier
way to write it and self-adjusts per card. A border composites over the
card's own fill, so `border-white/20` comes out #56396d on a purple card and
#a3c9c1 on a teal one. Hue-coded edges are the thing being removed.

The shadow steps back to what it was for — depth, not the boundary. Web
returns to `shadow-sm`; Android's 2dp drops to 1dp, matching it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 15:00:22 -04:00
bvandeusenandClaude Opus 5 47f108c9c8 The border was the thing making every note look the same
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 9s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m23s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m4s
A note card carried a 1px tint border. Measured against its own fill, that
line was a 1.56-2.09 contrast in dark mode while the fill managed only
1.03-1.05 against the board — so the loudest thing on every card was an
identical line in an identical place, and a field of them read as a grid of
outlined rectangles however different the colours inside were.

Removed from the note card on both surfaces. `border` survives for panels,
banners, the update card and the pickers: those are single elements, not a
field of them.

What replaces it differs by theme, because elevation does.

  Light leans on a shadow. An untagged card is `bg-red-50` on a `neutral-50`
  board — a 1.04 contrast that can only read as a card by sitting above one.
  The web goes `shadow-sm` -> `shadow`; Android had no shadow at all and gets
  2dp.

  Dark cannot use one, black on near-black. So the subdued fills moved onto
  the card surface instead: `{hue}-950` composited at 0.18 over #171717 and
  baked, rather than the same hue at 0.25 over the near-black board. An
  untagged card now sits where the plain white card always sat (1.11-1.14
  against the board, against `bg-neutral-900`'s 1.10) while carrying LESS hue
  than before — chroma 7-17 where the old ramp had 10-23.

Subtler and more visible at once, which is only a contradiction if subtlety
has to come from lightness. Here it comes from chroma, and lightness is left
to say "this is a card". Which also reframes the two weights: in dark they
now sit within a hair of each other (red: 1.11 vs 1.12) and differ threefold
in colour (chroma 10 vs 41).

The chosen ramp is untouched — the operator signed those colours off, and a
ramp somebody likes is not something to redo while fixing something else.
Light was already built this way: `-50` and `-100` are both white plus a
different amount of hue.

Body text still measures 14.3-16.4 against the 4.5 it needs, meta 6.9-7.1
against 3.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:21:42 -04:00
bvandeusenandClaude Opus 5 fe18aaa956 The contrast pass, and the invisible chip it found
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 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m53s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m30s
Step 4 of milestone 309. All 40 combinations measured rather than eyeballed —
10 hues x 2 themes x 2 weights, dark ones composited over the board the way
Compose and CSS both do, against the text actually drawn on a card
(neutral-700/300 body, neutral-500/400 meta).

Body text ranges 8.23:1 to 13.01:1 against a 4.5:1 requirement; meta text 4.33
to 7.11 against 3.0. Every combination passes AA with room to spare, so the two
ramps step 3 introduced need no adjustment. That is the boring half.

THE PASS FOUND A REAL REGRESSION. A tagged note takes its first tag's colour and
is drawn at that hue's `-100` — which is exactly what the chip uses as its fill.
Measured contrast between the chip and the card it had itself coloured: 1.00 in
light mode. Perfectly invisible. Dark was 1.04-1.07, invisible in practice. On
every tagged note the tag name had stopped reading as a chip and become loose
text, and nothing about step 3 looked wrong while writing it.

Fixed with an EDGE rather than a different fill. A fill can collide with any card
colour and chasing that would need the chip to know what it is sitting on; a
border in the chip's own foreground reads against any background and needs no
plumbing.

Alpha is 0.60, measured: 2.32:1 at worst, where the 0.30 I first wrote gave 1.49
and was no edge at all. It does not reach WCAG 1.4.11's 3:1, which needs 0.80 and
draws a hard outline instead of a hairline. 1.4.11 governs boundaries carrying
REQUIRED information, and a chip's information is its text — passing AA at 8:1 or
better on every card here. The number and the reasoning are both in the source so
the judgment can be overruled rather than rediscovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 13:37:51 -04:00
bvandeusenandClaude Opus 5 20e9d535de android: two more ktlint rules, both in the code I just added
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m57s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m54s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m57s
`noteIsStrong` had a single-line body expression wrapped onto the next line;
ktlint's function-signature rule wants it on the signature line when it fits.
`firstLabelColor` wrapped a call chain after `note.labels.firstOrNull()`, and
chain-method-continuation wants a newline before EVERY link once one is wrapped.
It reads better as two statements than as a chain, so it is two statements.

Third ktlint round trip on this milestone. I pre-flighted the rules I already
knew and these were not among them — and when I then wrote greps for the two new
rules, they flagged sixteen files that have been passing for months, because my
heuristics do not match what the rules actually check. There is no local ktlint
(rule 10), so CI is the first and only reader; more elaborate greps are not the
fix, and pretending they are would just add false confidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 13:25:28 -04:00
bvandeusenandClaude Opus 5 988e1d3f00 A note's colour is its first tag's colour, at a heavier weight
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 20s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m41s
Android / Kotlin + Rust (APK) (push) Failing after 3m53s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m7s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Four #todo notes on the operator's board in four different colours, because the
tint was derived per-note-id and ignored tags entirely. Now a tagged note wears
its first tag's colour, so notes that share a tag share a look.

TWO WEIGHTS, NOT ONE RAMP. The operator, seeing step 1: "the tints look the same
as the chosen colors". They did — there was only one ramp. `strong` is not a
second decision, it IS whether the colour was chosen: a tag (or, until step 5,
the picker) means somebody said what this note is, while a derived tint only
means the board should not be a wall of white.

The two weights move in OPPOSITE directions per theme, because that is where
each has headroom. The operator asked whether the tint could go lighter instead
of the tagged end going darker; in dark mode that is the better half of the
answer, so the derived end drops to a quarter opacity — closer to the board,
which gives the light body text MORE contrast rather than less. Light mode has
nowhere to go below `-50` without being white again, so there the gap opens by
deepening the chosen end to `-100`.

No hex was transcribed for any of it. `-100` is already in NoteTint.kt as every
hue's `lightChipBackground`, and the dark weights are the existing `-950` fill
re-alphaed, so the only two numbers that have to agree by hand are the alphas.
Copying ten more Tailwind values from memory is exactly how this mirror would
have drifted.

`default` is marked not tintable — it is the ABSENCE of a colour, there is no
emphatic version of it, and re-alphaing its opaque neutral fill would have made
every draft card translucent.

Borders untouched: the fill is the signal, moving both muddies the edge.
Resolution order is explicit pick, then first tag, then the id hash. First tag
because it is the one you control by typing; manual labels count the same as
#tags because nobody can tell which kind they made by looking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 13:15:47 -04:00
bvandeusenandClaude Opus 5 6fbee27f9c A tag with no colour of its own derives one from its name
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Update manifest (push) Successful in 6s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m10s
Android / Kotlin + Rust (APK) (push) Successful in 7m43s
Every #tag ever typed is `default`. `notes/tags.py` mints one as
`Label(owner_id=…, name=name)` with no colour, so it takes the column default —
which means tag-driven note colour, built on top, would have left the board
exactly as grey as it was. Four #todo notes in the operator's screenshot, four
different colours, because the tint is per-note-id and ignores tags entirely.

DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing the plan in 2965. That plan
wanted a hashed colour written wherever a label is born, and named the risk in
its own body: `find_or_create_label` is "easy to miss, and it is the common one",
because most tags are born from typing `#grocery`, not from a management screen.
Deriving has no mint points to miss, needs no backfill for the tags that already
exist, and reuses the hash and the fixture the notes already have.

The cost is that renaming a tag recolours it. That is defensible — the name IS
the tag — and an explicitly picked colour is still stored and still wins, so tag
colours stay editable exactly as asked.

Lowercased before hashing: tags dedupe case-insensitively, so #Todo and #todo are
one tag and must not be two colours.

All five places a label's colour is drawn now resolve the same way — the card
chip, the editor chip, the drawer's tag list, and the management modal's dot and
swatch ring. The modal's ring follows the resolved colour rather than the stored
one, so opening the picker highlights what you can already see instead of
nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:57:59 -04:00
bvandeusenandClaude Opus 5 cddaf35280 android: ktlint forces a multiline signature at two parameters
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m55s
Android / Kotlin + Rust (APK) (push) Successful in 8m3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m13s
Desktop (Tauri) / Update manifest (push) Successful in 3s
`resolvedNoteColor` and `noteTintFor` are the first non-composable functions
here to take more than one parameter, and ktlint_official's function-signature
rule requires each parameter on its own line once there are two or more. Four
findings on one and four on the other, all the same rule.

Nothing had type-checked: ktlint is step 6 and the unit tests are step 8, so the
fixture pinning the derived-tint mirror never ran.

I checked line width, trailing whitespace and KDoc adjacency before pushing —
the three that have bitten before — and not this one. The list of rules learned
by failing CI is not the list of rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:38:23 -04:00
bvandeusenandClaude Opus 5 6f173b166b Every note carries a tint, derived from its id
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m33s
Android / Kotlin + Rust (APK) (push) Failing after 6m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 7m2s
Desktop (Tauri) / Update manifest (push) Successful in 3s
The board was a wall of white rectangles: `default` is the colour nobody picks,
so it was the colour of every note except the two the operator had coloured by
hand. Reported twice — 2026-08-23 as "a wall of broken up text", and again today
as "all the existing notes are the same dull color".

The ask was "random subdued colors", but random is the one thing it must not be.
A tint rolled at render time would differ between the phone and the browser and
change on every reload. FNV-1a over the note's id is deterministic, identical on
every surface, needs no column and no migration, and a note keeps its colour for
life — which is what "random" meant here.

Two implementations, deliberately mirrored, same discipline as the checklist
grammar. The Kotlin half lives in a Compose-free file so a host-JVM test can pin
the fixture; the TypeScript half carries the same four ids and hashes as a
comment because the frontend has no test runner at all — its whole CI lane is
`vue-tsc --noEmit`. That asymmetry is worth naming rather than papering over.

A draft has no id yet (DRAFT_ID is ""), so it stays white until it is saved.
Hashing the empty string would give every draft one shared tint and then change
it on save anyway — two surprises where one will do.

An explicitly-picked colour still wins. The picker is on its way out (milestone
309 step 5) but it has not gone yet, and a hand-coloured note changing under the
operator would read as data loss.

First of five steps toward colour coming from tags. This one stands alone: no
storage change, nothing removed, and the board stops being white today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:26:01 -04:00
bvandeusen f92a3d0a99 android: detekt's return limit, on two functions I wrote after it caught me once
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m12s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m26s
Desktop (Tauri) / Update manifest (push) Successful in 9s
Android / Kotlin + Rust (APK) (push) Successful in 8m33s
`dev` went red on 1e2b42a and nobody was watching — the CI wait was killed with the
session, so the push was never confirmed. Checked on the way back in.

Both findings are ReturnCount: four exits against a limit of two. The same rule
caught continueChecklist earlier the same day, which is the annoying part — I had
the lesson and wrote two more guard-clause ladders anyway.

checkInBackground becomes a `when`, which it wanted to be regardless: it is four
mutually exclusive situations and one action, and the ladder made that read like a
sequence of unrelated escapes.

onWifi folds its three null checks into one nullable chain. Same behaviour, and the
`caps != null &&` reads as what it is — an uncertain answer being treated as no.
2026-08-26 10:01:24 -04:00
bvandeusen 1e2b42af25 android: say nothing until the update is downloaded, and only fetch on wifi
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m29s
Android / Kotlin + Rust (APK) (push) Failing after 5m39s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m30s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Both corrections to what I built, and the second changes the first.

NAG ONLY WHEN READY. The banner is now gated on the bytes being on disk. I had it
appearing as soon as a build was FOUND, with Install downloading on demand — which
turns one tap into an unplanned download, and is exactly the surprise the wifi gate
was meant to avoid. Off wifi the app now stays quiet and picks it up later.

ONLY ON WIFI, and both halves of that. `isActiveNetworkMetered` alone would download
over an unmetered cellular plan, which is not what "on wifi" means. TRANSPORT_WIFI
alone would download over a tethered hotspot, which is mobile data wearing a
different hat and the precise bill this avoids. It now requires both.

Found while making the first change: gating the nag on `ready` broke the nag. The
background path returns early once a build is fetched, so `nagDismissed` would never
be cleared again and a single "Later" would have silenced the update permanently —
the exact "lost" this whole path exists to prevent. Coming forward with a fetched
build now clears the dismissal instead of returning.

Also: a build found off wifi retries its FETCH on the next foreground rather than
waiting out the six-hour check interval. Found on the train, downloaded at home.

The banner loses its two-state text with the change, and BoardUpdate loses `ready` —
it is implied now. It stays visible while installing, deliberately: that is the one
moment it has something to report, and hiding it would look like the tap did nothing.
2026-08-26 09:53:40 -04:00
bvandeusen a48b034a94 android: my insertion stole downloadTarget's doc comment
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m57s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m58s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m41s
Anchoring the new function on `fun downloadTarget` put it between that function
and its own KDoc — so downloadTarget lost its doc and onUnmeteredNetwork gained a
second one describing something else entirely. ktlint caught both halves.

Anchor on a declaration and you land inside its documentation. Swept the rest of
the tree for the same shape; nothing else.
2026-08-26 08:43:42 -04:00
bvandeusen ee47a61270 android: find updates without being asked, fetch them, then nag
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m14s
Android / Kotlin + Rust (APK) (push) Failing after 4m29s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 5s
`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 having to remember to go
looking is the same as not being told. The operator has been doing that by hand
every time.

Three parts.

FIND. The app checks when it comes forward, which is the moment the person is
present. Rate-limited to six hours in the view model, so flicking between two apps
is not a re-check, and skipped entirely on an unlinked device — updates come from a
linked server and there is nothing to ask. Same ForegroundTransitions shape as
AutomaticSync, for the same reason.

FETCH. Finding one downloads it, so the nag is a one-tap install rather than the
start of a wait. NOT over mobile data: fifty-odd megabytes is a bill nobody agreed
to, so this is gated on an unmetered connection (new ACCESS_NETWORK_STATE
permission — normal, no prompt). On a metered link the update is still found and
still nags; Install downloads it then, which is a choice rather than a surprise.

NAG. A banner on the board, under the error banners — an update is worth saying and
never worth saying before a note failed to save. "Later" clears it for this sitting
only: the next time the app comes forward the check finds the same build and says so
again. That is the difference between a reminder and a notice you can lose.

downloadAndInstall now skips the download when the background fetch already did it,
so the sync screen's button and the banner's are the same action with the same
name — whether the bytes are already there is this class's problem, not the
person's.
2026-08-26 08:34:28 -04:00
bvandeusen 68f851110f android: checklist rows were still 48dp of touch target
Second pass on the same report. Taking the field's own padding off got rows from
57dp to 48dp and the operator said it was still too big — correctly, because 48dp
was never the field's, it is Material's minimum touch target and every interactive
component gets it.

On a checklist that minimum IS the row height. It is the right floor for a control
somebody has to find on a screen; it is the wrong one for a box that sits in a
predictable column with an identical box directly above and below it, where a near
miss ticks the neighbouring item — visible, and undone by tapping again.

36dp, provided to the row rather than hardcoded into the controls, so the checkbox
and the delete × move together and nothing else in the app is affected.
2026-08-26 08:31:21 -04:00
bvandeusen 96a6f6e691 web: the editor draws the checklist too
CI & Build / integration (push) Successful in 19s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 38s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m37s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m34s
Desktop (Tauri) / Update manifest (push) Successful in 3s
2992's other half. The browser was the last surface still showing `- [ ] ` as
markup: cards rendered and ticked checkboxes, the editor did not.

Same shape as Android, deliberately. notes/blocks.ts mirrors EditorBlock.kt —
splitBlocks, joinBlocks, afterEnter, withoutIndex, plusTask — because the two
editors should behave alike and the cheapest way to keep them that way is for the
code to read alike. `body` becomes a computed over the blocks, so every save,
baseline check and draft still reads the one markdown string they always did.

markdown.ts now exports parseTaskLine and renderTaskLine, and parseMarkdown uses
the former. The read view and the editor's block split had been matching the same
grammar through two separate copies of one regex; now they agree by construction.

Two places the web can do better than Compose, and does:

  * Backspace at the start of an empty item removes it. A browser sends a real
    keydown for Backspace; an Android soft keyboard sends an IME delete that never
    surfaces as one, which is why that surface only has Enter-on-empty.
  * Prose fields size to their text — rows="1" plus a scrollHeight fit, which beats
    guessing a row count that is wrong the moment a line wraps.

KNOWN, and the same on both surfaces: typing `- [ ] ` by hand into a prose block
leaves it prose until the note is reopened. Blocks are split when the editor loads,
not re-derived per keystroke — re-splitting mid-type would move the caret. The
toolbar button is the intended path. Converting on blur would fix it and is worth
doing to BOTH editors at once rather than letting them drift.
2026-08-26 07:53:13 -04:00
bvandeusen 44b3bcb2b2 Correct a claim about the operator's data, and the first-row delete
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 12s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m2s
CI & Build / Build now, or wait for Android? (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) / Tauri desktop (Linux) (push) Successful in 5m39s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m12s
Two things, one of which I got wrong in a place that outlives the session.

**The claim.** Migration 0027's docstring said "The Google Keep import is genuine
content on this instance, not fixtures." That is not true and I had no basis for it.
Note 2916's headline is the opposite — "there is no work that anyone has done that
isn't test data" — and its clause about imports is CONDITIONAL: text arriving from
another app would be real, and any import path has to treat it that way. I read a
rule about how import code must behave as a fact about what is in the database, then
repeated it in a migration that will be read long after anyone remembers this week.

The operator has never run the importer. They did not know it existed.

Nothing about the migration changes. Content-preserving was cheap and is right for
anything that rewrites somebody's text — and it is what the rule will demand the day
an import does happen. Only the reason recorded in the file was wrong, and a wrong
reason in a migration is how a later decision gets made on a false premise.

**The delete.** Removing the FIRST checklist row asked to focus `index - 1`, which is
-1, so nothing took focus and the keyboard stayed up over a list with no cursor in
it. It now focuses whichever row takes the deleted one's place, which also does the
right thing when the deleted row was the only one — `withoutIndex` leaves a fresh
empty block behind, and that block is what gets the caret.

Found by reading the path the operator said they were about to test, rather than by
waiting for them to find it.
2026-08-26 07:44:31 -04:00
bvandeusen a45a44ef11 android: split the block model from the block UI
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m41s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 9m16s
detekt's TooManyFunctions, at exactly the threshold. Worth taking as the signal it
is rather than suppressing: the file held the block MODEL — split a body, join it
back, add an item, drop one — and the COMPOSABLES that draw it, which are two jobs
that happen to share a data class.

EditorBlock.kt keeps the model and is pure: no Compose imports beyond the types it
stores, and testable on its own if it ever earns tests. BlockBody.kt keeps the four
composables.

afterEnter, withoutIndex and nextId become internal, since the UI half calls them
across the file boundary now. That is the one cost of the split and it is small —
same module, same package, and each says why in its doc.
2026-08-26 07:13:27 -04:00
bvandeusen 56264a9220 android: the checklist rows were carrying a form field's padding
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m50s
Android / Kotlin + Rust (APK) (push) Failing after 3m52s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m9s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Reported from the device with a screenshot: six items took up most of a phone
screen. The rows measured ~57dp apart, which is Material's TextField content
padding almost exactly — 16dp above the text, 16dp below, around a 24dp line.

That padding is right for a form field, where it is the difference between a
comfortable target and a fiddly one. On a checklist it IS the row height, so every
item was paying for a hit area the checkbox beside it already provides.

The editor's blocks drop to BasicTextField. Nothing about the "no box" treatment is
lost — PlainTextField exists to strip a container and an indicator, and
BasicTextField never had either, so there is nothing here to drift back into
existence. What it does not supply and BlockField now does: the text colour, which
defaults to Color.Unspecified and draws BLACK (the same default that made the
toolbar invisible in dark mode), the cursor brush for the same reason, and the
placeholder, which becomes a plain Text behind the field.

PlainTextField keeps serving the search box, the label picker and the sync-pairing
form — fields where Material's padding is what you want. Its TextFieldValue
overload went with the change: the editor was its only caller, and every remaining
one passes a String.

Rows are now bound by the 48dp checkbox rather than by the field. If that is still
looser than it should be, the next lever is the touch targets themselves, which
trades against how easy the box is to hit — worth looking at on a device before
spending it.
2026-08-26 07:03:15 -04:00
bvandeusen a88f7c2dd0 core: drop the two helpers the block editor made unnecessary
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m56s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m15s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m30s
`toggle_at` existed to map a tap on `[ ]` inside a plain text field to an item, and
`continuation` to make Return start the next one. The block editor needs neither: a
checkbox is a real Checkbox, so it is tapped rather than located, and Return is the
field's own IME action rather than a shape recognised in a string.

Removed rather than kept for later (rule 22). Both were exported over the FFI with
no Kotlin caller, which is API surface promising something nothing does — and their
tests were weight on code nothing runs.

The section comment above them described the tap-in-a-text-field problem, which is
no longer the problem this pair solves. Rewritten to say what is actually there:
one function to read a body apart, one to put a line back together, and between them
Kotlin renders checkboxes without owning the grammar.
2026-08-24 10:42:26 -04:00
bvandeusen 32ec29fc4a android: the comment pointed at the file's old name
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m48s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m48s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m44s
2026-08-24 10:32:55 -04:00
bvandeusen ae17b8a8e7 android: name the file after the type in it
Android / Kotlin + Rust (APK) (push) Canceled after 7s
Desktop (Tauri) / Tauri desktop (Linux) (push) Canceled after 7s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Canceled after 7s
Desktop (Tauri) / Update manifest (push) Canceled after 0s
detekt's MatchingDeclarationName: a file whose only top-level type is EditorBlock
has to be EditorBlock.kt. The plural read better as 'the blocks and the machinery
around them', but the rule is about the type, and the convention here already works
that way — NoteCard.kt holds NoteCard plus its helpers.
2026-08-24 10:32:45 -04:00
bvandeusen eeca4d48c2 android: a trailing blank line where the dead helpers used to be
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m49s
Android / Kotlin + Rust (APK) (push) Failing after 4m20s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m49s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Removing MIN_BODY_LINES took its declaration but left the newline in front of it,
so the file ended with a blank line. My pre-push sweep only looked for consecutive
blanks INSIDE a file and could not see one at the end — checked across the whole
Kotlin tree this time, not just the files I touched.
2026-08-24 10:23:59 -04:00
bvandeusen b2435d97b6 android: the editor draws the checklist instead of the markup for one
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m30s
Android / Kotlin + Rust (APK) (push) Failing after 4m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m53s
Desktop (Tauri) / Update manifest (push) Successful in 4s
2992. A checklist item is a real Checkbox with its text beside it, so a box can be
ticked while looking at the note — which is what M304 left undone. It changed where
a checklist is STORED and never changed what the editor draws.

The body is split into blocks and joined back on every edit, so the note underneath
is the same markdown string it was this morning. Nothing below the editor can tell
this exists: no migration, no protocol change, no new shape on the wire.

A run of prose lines is ONE block, not one per line. Typing a paragraph has to feel
like typing a paragraph, and a separate field under every sentence would break the
caret mid-sentence. Only a checklist item earns a block, because only a checklist
item needs a widget.

Two things that look like detail and are not:

  * A block carries its own TextFieldValue, and an ID that survives insertion.
    Compose keys fields by position unless told otherwise, so adding an item would
    otherwise move every caret below it up a row. Content cannot be that key —
    two empty items are identical and neither is the other.
  * Focus is hoisted to the screen rather than kept inside BlockBody, because the
    toolbar's checklist button also asks for one. Two owners of one cursor is one
    too many.

Return on an item makes the next item and puts the caret in it; on an EMPTY item
the block becomes prose, which is how a list ends and how you get a paragraph after
one — the same rule the plain text field used, now with somewhere to land. It
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 that key.

The core gains `render_item` and `DerivedItem.line`; `item_lines` and
`checklist_lines` are gone, subsumed. Every renderer that walks a body line by line
needs the text, the state and the position TOGETHER — asking for them separately is
how two calls come to disagree about a body that changed between them. The card now
reads its items from the body for the same reason, instead of from note.items,
which is the same list by a longer route and one save behind.

WANTS A DEVICE PASS, and the focus behaviours are what to look at: return making a
row and landing in it, return twice at the end of a list getting you a paragraph,
and rotation restoring the right field. CI can only prove this compiles.
2026-08-24 10:14:53 -04:00
bvandeusen 9a3c4ec377 android: ticking a box on a card threw the editor open on top of it
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m45s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m18s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m31s
Reported from the device: tapping a checkbox on the board checks it AND opens the
note. The "and" is the tell — both things happened, so this was never a tap landing
on the wrong target.

`mutate` ends with `editing = updated ?: state.editing`. That is right for an editor
action, where the reloaded note refreshes a screen already on display. But
`editing != null` IS "the editor is up" — it is what MainActivity's `when` selects
on — so calling `mutate` from the BOARD, where editing is null, wrote the mutated
note into it and opened the editor as a side effect of saving.

Fixed at `mutate` rather than at the caller, because the caller was not wrong: any
board-initiated mutation would have done this, and toggleItem is simply the first
one to exist. It now refreshes an open editor and cannot open a closed one.

The comment claimed the narrower behaviour all along — "so an open editor shows its
own change" — which is what the code should have been doing and wasn't.
2026-08-24 09:59:00 -04:00
bvandeusen 315c5f19e6 android: detekt caught a callback that never reached the cards
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m18s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m46s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m39s
Not a style finding. `onToggleItem` was added to BoardScreen's signature and read
inside NoteBoard — which is a separate top-level composable, not a nested one, so
the two were never connected. detekt reported it as an unused parameter; the
compiler would have called it an unresolved reference. Neither had run: Kotlin is
compiled at the "Unit tests" step, which is gated behind detekt, so nothing in this
lane had type-checked the Android changes yet.

Threaded properly now, which is what makes ticking a box from the board actually
work rather than merely appear to.

Two more from reading it again with that in mind:

  * `when { item != null -> … onToggleItem(index, …) }` would not have compiled.
    Kotlin does not infer that a non-null item implies a non-null index, so the
    index stayed `Int?` against an `Int` parameter. Both are in the condition now.
  * continueChecklist had six returns against detekt's limit of two. Collapsed to
    one `when`, with the two intermediate values guarded on `typedNewline` —
    `caret - 1` is only a real index once it is known to be the newline just typed.
2026-08-24 08:35:52 -04:00
bvandeusen 1a66d9c3a8 tests: pin the export against writing every checklist twice
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 21s
CI & Build / Python tests (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 15s
M304 step 7. The code change landed with the server half — _note_markdown's
`if items:` branch went, and the export payload stopped carrying an items array —
but neither had a test, and the failure mode is quiet: every list appears twice in
an export, then twice again when that export is imported back.

Three cases, and the third is the one worth having. An export taken BEFORE this
milestone has a body with no task lines and a separate items array, so importing
one still has to fold the checklist in. That is the same fold the Keep importer
does, and the reason _insert_note still accepts items at all — asymmetric on
purpose: the export stopped writing them, the import did not stop reading them.
2026-08-24 08:26:50 -04:00
bvandeusen 77b1a87712 android: ktlint on the import order and a leftover blank line
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m56s
Android / Kotlin + Rust (APK) (push) Failing after 4m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m55s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Inserting the checklistLines import after Note split Note from NoteLabel, and
removing the checklistOpen state left two blank lines behind it. Both are the
formatter only — the bindings built and the Rust lanes were already green.
2026-08-24 08:26:01 -04:00
bvandeusen 68b2a5dc8d android: a checklist is lines of the note here too
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m22s
Android / Kotlin + Rust (APK) (push) Failing after 4m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m3s
Desktop (Tauri) / Update manifest (push) Successful in 4s
M304 step 6, and the surface with the least room to hide: Android has no markdown
renderer at all, so the card was about to show every list twice — once as literal
`- [ ] milk` in the body preview, and again as the glyph rows underneath. Same bug
the web had, one commit later.

The card now renders the body LINE BY LINE and draws a checkbox where one belongs,
which is what puts a list between two paragraphs instead of always after them. The
glyphs became tappable while they were being rewritten: ticking something off from
the board without opening the note is the common gesture, and the web just gained
it. The tap target is the glyph, not the row — tapping the TEXT still opens the
note, the way tapping anywhere else on a card does.

Kotlin gets no parser. Three implementations of the grammar is the price already
paid; a fourth in Compose would be a fourth place for a checklist to change shape
when it syncs. So the core exposes three pure functions instead —
`checklist_lines`, `checklist_continuation`, `checklist_toggle_at` — and Kotlin
does the caret arithmetic around them.

Those are FREE functions, not methods, and that is the interesting constraint. The
editor's body field is LOCAL state on an idle-debounced autosave, so anything that
edits a checklist there has to rewrite the text the field is holding, not a row the
store would hand back a moment later. Going through the store would overwrite
whatever was being typed. The BOARD has no such problem — nothing there is holding
a half-typed body — so the card's toggle goes through the store as usual.

`toggle_at` addresses an item by LINE and COLUMN rather than a text offset, because
the two sides do not count the same way: Compose measures in UTF-16 units and Rust
in bytes, so the same number means different places in a note with an emoji in it. A
line number is identical in every encoding, and so is a column inside the marker,
which is ASCII at the start of its line.

In the editor: the toolbar button inserts `- [ ] ` at the caret — the only toolbar
action needing no saved note, so it works on an empty compose box the moment it
opens — and Enter continues the list, or ends it on an empty item. Continuation is
recognised by SHAPE inside onValueChange (exactly one more character, and it is a
newline) rather than by a key event, so a paste or an autocorrect falls through
untouched.

EditorChecklist.kt and the four item actions are gone (rule 22). Adding, renaming,
ticking or deleting an item is editing text now, and the editor already does that —
through SaveText, with the same autosave and the same revision window as any other
edit.

KNOWN GAP, not an oversight: tapping a checkbox inside the EDITOR does nothing yet.
Material3's TextField does not expose onTextLayout, so mapping a tap to a character
offset means either moving the body to BasicTextField or intercepting pointer events
ahead of the field — both real changes to the surface this operator uses most, and
neither verifiable without a device. `checklist_toggle_at` lands here, tested, so
that task is pure UI. Ticking from the board works today.
2026-08-24 08:16:57 -04:00
bvandeusen 3cab054684 web: task lines render as checkboxes where they sit in the note
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 25s
CI & Build / Build & push image (push) Successful in 41s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m55s
Desktop (Tauri) / Update manifest (push) Successful in 4s
M304 step 5. The server already returns a derived `items` array, so the web kept
working across the last two commits — but it was rendering every list TWICE: once
as literal `- [ ] milk` bullets inside the body, and again as the separate
NoteChecklist block underneath. This is the commit that makes the body the only
place a checklist appears.

markdown.ts gains a `task` block, matched BEFORE the plain bullet — which would
otherwise swallow the marker and leave the brackets showing, the same ordering
reason `code` is matched before emphasis in INLINE_RE. Each item carries its
ordinal across the WHOLE document, because that is what an item's id means
everywhere else now; counting per block would have made the second list's
checkboxes toggle the first list's items.

The card's preview clamp is why that ordinal is safe there: it only ever drops
lines from the end, so a visible item's index is the same whether or not the body
was truncated.

MarkdownText emits a toggle rather than reaching for the store. Ticking a box
rewrites a line of someone's note, and a renderer used in several places should not
be the thing deciding that is allowed — the card passes `toggleable` and wires it,
a read-only render does not and the boxes are inert. Not wrapped in a <label>
either: on a card the text is the note's own words and clicking it opens the note,
so only the box toggles.

In the editor, the toolbar button stops revealing a section and inserts `- [ ] ` at
the caret. That makes it the one toolbar action needing no persisted note to hang
anything off — ensureDraft is gone from it, and it works on an empty compose box
the moment it opens. Enter on a task line continues the list, and on an EMPTY one
clears the marker; without that second half a list would be impossible to get out
of. Indent and bullet are carried over rather than normalised, because continuing
someone's `*` list with a `-` is an edit they did not ask for.

NoteChecklist.vue is deleted (rule 22). The store's item methods stay: they are the
repository seam the REST routes and Tauri commands both implement, not the old path.

CI cannot check any of this beyond types — there are no frontend tests, only
vue-tsc. It wants a real browser pass.
2026-08-24 08:10:20 -04:00
bvandeusen fe1f72ae1b tests: the display-title tests still passed the argument that went away
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 18s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build & push image (push) Successful in 30s
Three of them called derive_display_title(body, first_item). I updated the call
sites in src/ and not these — the integration lane and the linter both passed,
because a stale keyword argument is only a TypeError at the moment it runs.

Rewritten rather than deleted. The property the fallback existed to protect is
still real — a note that is only a checklist has to have a name — it is just
reached differently now: an item IS a body line, so the first one is simply the
first line with its marker stripped. The new cases pin the two edges that rule
introduces: an empty item must not name a note "", and a list of nothing but empty
items still has no name.
2026-08-24 08:06:11 -04:00
bvandeusen 761c3b5e82 server: the body is the checklist here too, and note_items is dropped
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 8s
CI & Build / integration (push) Successful in 20s
CI & Build / Build & push image (push) Skipped
M304 steps 3 and the server half of 4. The client half landed in 668f7fa; these
belong in one deploy, and the protocol floor below is what enforces that.

notes/checklist.py is the Python half of a grammar that now exists three times —
here, core/src/local/derive.rs, and (next) frontend/src/notes/markdown.ts. That
triplication is the deliberate cost: the alternative is a round trip to the server
before a phone can draw a checkbox. Each copy names the other two, and each is
tested against the same table of cases, including the near-misses that must stay
prose: `-[ ] x`, `- []`, `- [ ]x`, a `[ ]` mid-sentence.

Routes: add/update/delete items stop touching rows and rewrite note.body, all
through one _rewrite_body that runs the same sequence the PATCH route runs for a
body change — because it IS a body change. Revisions, #tag reconciliation, the
name, and link unfurls therefore happen in one place rather than three routes each
remembering to.

The reorder route is gone (rule 22). Reordering a checklist is moving a line, and
no client ever called it — the only reference in the tree was a test asserting the
route existed.

The API still returns `items`, DERIVED from the body on the way out. That is not a
second source of truth and it cannot disagree with the body it came from; it keeps
the web client working across the rest of this milestone and saves any consumer
that only wants to draw checkboxes from carrying a parser.

Export drops its separate items block, in both formats. The body already ends with
those exact lines, so writing them again would double every checklist in an export
and then double it again on re-import. Import still ACCEPTS items, because a Keep
takeout has a list and not a blob; it folds them in before the Note is built, so
display_title and _reconcile_tags both see the finished text.

Protocol 3 on both sides now. A v2 client is refused rather than half-served —
which matters more than I first said: _apply_note_items returned early on an absent
`items` key, so an un-bumped v3 client against a v2 server would not have LOST the
rows, it would have kept them and then had the migration fold them a second time.
Duplicated lists rather than missing ones. The floor prevents both.

Migration 0027 folds every existing row into its note's body and drops the table.
It inlines its own copy of the fold on purpose — a migration has to keep producing
what it produced the day it ran — and a test pins that copy against the app's until
they are allowed to diverge. updated_at is deliberately untouched: a client holding
an unpushed edit keeps the newer timestamp, so last-write-wins keeps its work
instead of the migration silently winning.

The downgrade is honest rather than faithful. It recreates an empty note_items and
leaves the bodies alone, because once items are lines nothing distinguishes one this
migration wrote from one somebody typed, and a downgrade that guessed would eat
hand-written lists. Recreating the table is still necessary: 0015's downgrade drops
a trigger ON note_items, and IF EXISTS covers the trigger, not the table.
2026-08-24 08:03:37 -04:00
bvandeusen 32dafca148 core: what rustfmt actually wanted
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m59s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m14s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m11s
Three from the checker's own diff. Two are the rule I had guessed at: when a
call overflows and its last argument is a closure, rustfmt keeps the earlier
arguments on the line and expands the closure into a block, rather than putting
every argument on its own line.

The third is a stray double blank line before the test module.
2026-08-24 00:49:05 -04:00
bvandeusen 668f7faf03 core: the body is the checklist, and checklist_items is gone
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m30s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m47s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m45s
M304 steps 2 and the client half of 4, together — they cannot be separated. A
commit where the store writes items into the body while push.rs still reads them
from a table is one that silently pushes the wrong list, and dev publishes to the
dev channel on every green build.

Store:
  * load_items becomes items_of(body) — a parse, not a query. An item's id is its
    ORDINAL, which is all it ever amounted to: push.rs sent text and checked and
    never an id, and both sides replaced the whole list on every sync.
  * add_item / update_item / delete_item route through update_note, so they get
    revision snapshotting, #tag re-derivation and the dirty/updated_at bookkeeping
    without any of it being written a second time.
  * create_note folds its items: input into the body, and syncs tags from the
    FOLDED body — an item can carry a #tag too.
  * display_title no longer takes items, because items ARE body lines now. It
    strips the task marker instead: a list-only note is still named by its first
    item, and calling that note "- [ ] milk" would show someone the storage.

Wire: items leave it. A second copy of data already in the body field of the same
message is how the two come to disagree. CLIENT_PROTOCOL_VERSION and
MIN_SERVER_PROTOCOL_VERSION go to 3, which is what makes this safe to land before
the server: a v3 client refuses a v2 server outright rather than pushing a body
whose list the old _apply_note_items would then delete.

Schema v8 folds every existing row into its note's body before dropping the
table. Written in Rust, not SQL: the fold has to produce exactly what
derive::append_item produces, and group_concat only gained a guaranteed ORDER BY
in SQLite 3.44 — a checklist that quietly reordered itself during a migration
would be a poor way to learn that. updated_at and dirty are deliberately left
alone, because the server's migration folds the same rows the same way and both
sides land on identical bodies; marking every note dirty would push a body the
server already has, from every device at once.

NOT deployable yet. The server still speaks v2 and still has note_items, so a
client built from this will refuse to sync until the server half lands.
2026-08-24 00:41:23 -04:00
bvandeusen d0e3e48943 core: rustfmt splits on fn_call_width, not max_width
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m14s
Android / Kotlin + Rust (APK) (push) Successful in 7m19s
Three assertions I had collapsed to one line because they fit inside
max_width=100. rustfmt's fn_call_width is 60 and applies to the ARGUMENT list,
so a call can sit well under the line limit and still be split vertically.
Clippy and the tests were already green; this is the formatter only.
2026-08-24 00:28:08 -04:00
bvandeusen 1045db318b core: derive checklist items from the body, the way tags already are
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m35s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m48s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m17s
First step of M304. Additive on its own — nothing calls this yet — so it can be
read and tested before anything depends on it.

A checklist is currently a TABLE, and a table can only ever render after the
body, because a row has no idea where in the note it belongs. That is why
"inline with the note" is not a styling problem: prose, three checkboxes, then
more prose is not expressible at all today.

derive.rs already owns "structure derived from body text" for #tags and says so
in its module doc. Task lines join it rather than opening a second home for the
same idea. The difference between the two is worth stating and now is: tags
MATERIALISE into label rows because the board queries by label; items
materialise into nothing, because nothing queries them. Their only readers are
the card, the editor, and display_title.

The grammar is fixed here because three languages will implement it —
derive.rs, notes/checklist.py, notes/markdown.ts — and any difference between
two of them is a checklist that changes shape when it syncs. `*` is accepted
since markdown.ts already takes it for a plain bullet, and a rule that allowed
`* item` but not `* [ ] item` would be one nobody could guess. `- [ ]` with
nothing after it parses as an empty item: that is what pressing Enter on a list
leaves behind, and refusing it would make a half-typed list stop being a list.
`- [X]` parses and normalises to lowercase on the first rewrite, so round trips
are stable.

append_item spaces its output exactly as import_export.py:_note_markdown does.
That is not cosmetic — the server migration will fold existing rows into bodies
with the same layout, so an export taken before it and one taken after have to
agree byte for byte.

A stale index is inert rather than fatal: the index comes from a UI that may be
a moment behind the store, and a late tap should do nothing rather than panic.
2026-08-24 00:20:32 -04:00
bvandeusen 65af37d159 android: a checkmark to leave, and asking for a checklist stops writing a blank one
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m23s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m10s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m16s
Two reports from the same device pass.

**The exit is a checkmark.** It shipped as the word "Done" one commit ago, on the
argument that a tick in a NOTES app reads as a checklist item to anyone who has
used one. Overruled by the operator, and the filled treatment is what settles the
objection anyway: 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 argument survives where it actually mattered — read
aloud.

**Starting a checklist wrote an empty item**, purely so the section would have
something to render. That left a blank row with the always-present add-row beneath
it — two empty fields, and the caret in the lower one. Whether a checklist is
SHOWING is view state, not a row in the store: the toolbar reveals the section and
focuses the add row, and nothing reaches SQLite until an item has words in it.

EditorAction.AddChecklist is gone rather than repurposed (rule 22), which makes the
first real item the action that can create a body-less note — a note named from its
first item, which the core already does.
2026-08-23 23:50:30 -04:00
bvandeusen 8257e1035c android: put the way out of a note back within reach
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m5s
Android / Kotlin + Rust (APK) (push) Canceled after 4m27s
Desktop (Tauri) / Tauri desktop (Linux) (push) Canceled after 4m27s
Desktop (Tauri) / Update manifest (push) Canceled after 0s
Moving the toolbar to the top took the back arrow with it, and left the only
exit from a full-screen editor in the top-left corner — the furthest point on
the display from a right-handed thumb, reached over the whole note to get to.
Reported on the first device pass, and correctly.

So the footer carries a Done as well as the timestamp. With the keyboard up it
sits directly above the thumb, which is where a hand already is for every other
part of writing a note.

The top-left arrow stays. Two affordances for one action is usually clutter,
but this is the case that earns it: the arrow is what habit, the system back
gesture and TalkBack all expect of a full-screen surface, and removing it would
strand the reflex to strike a duplicate costing one icon slot.

A word rather than a checkmark, on the same argument the overflow menu makes: a
tick in a notes app is a checklist item to anyone who has used one, and "Done"
cannot be misread, including aloud.

EditorSavedLine is now EditorFooter, since it is no longer only a line.
2026-08-23 23:46:03 -04:00
bvandeusen bca9e16bd0 android: ktlint wants that body expression on one line
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m26s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m17s
2026-08-23 22:30:56 -04:00
bvandeusen 9ea2a2f9b6 android: the toolbar moves to the top, and the note says when it saved
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m45s
Android / Kotlin + Rust (APK) (push) Failing after 3m50s
Desktop (Tauri) / Tauri desktop (Linux) (push) Canceled after 4m41s
Desktop (Tauri) / Update manifest (push) Canceled after 0s
The capture sheet's drag handle cost a strip of screen and did nothing a back
gesture does not already do. The toolbar takes that strip instead, which is
where it belonged once one surface served both writing and 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.

The bottom is now the answer to "did that land". There is no save button —
writes are continuous, so a button offering to do what already happened would
be a lie with a tap attached — but that left nothing on screen saying the work
was safe. Not saved yet → Saving… → Edited just now is the whole lifecycle in
the corner, and someone who watches it once never has to be told that closing
a note keeps it. DateUtils formats the relative part, so plurals and
"yesterday" are not this app's problem to solve twice.

Shape: the screen keeps the sheet's rounded top and its gap below the status
bar, so opening a note still reads as something rising over the board. Full
height rather than a real ModalBottomSheet — a sheet spends a writing session
negotiating with the IME for the bottom half of the display, and the
swipe-down it buys is a gesture back already does.

Both content colours on the card are spelled out. Surface and Scaffold each
default theirs to contentColorFor(their container), which returns Unspecified
for anything that is not a colour-scheme role; a note tint never is. That is
the same default that made the last toolbar invisible in dark mode, latent in
two more places.

Also: the running LinearProgressIndicator is gone, since the corner line now
says the same thing without moving the text; and the SaveText comment in
BoardViewModel still claimed saves happened on close.
2026-08-23 22:26:12 -04:00
bvandeusenandClaude Opus 5 ce6a1093a3 android: writing a note and editing one are the same surface
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m40s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m44s
The + button raised a capture sheet with a single text field. The editor is
a screen with a toolbar. So a note being WRITTEN could not be given a
colour, a reminder or a checklist — those live on the toolbar, and the sheet
had none. To make a checklist you wrote a note, saved it, reopened it, and
found a control you had never seen.

ComposeSheet is deleted. + opens the editor on an unsaved draft.

A draft is a real Note carrying DRAFT_ID (the empty string) rather than a
null. Note has eighteen fields and the editor reads eight of them; threading
nullability through all of that to express "not saved yet" would spread the
concept across a screen that should not have to know about it. A real id is
a uuid, so the sentinel cannot collide.

It becomes a row on its first save, and the first save is now an autosave:
the editor writes a second after typing stops. That is affordable because
2707054 made a body write stop costing a revision — before it, saving this
often would have meant a revision per second.

Autosave is also what makes materialisation work at all. Creating the note
on a toolbar tap instead races: the typed text lives in the field's own
state and only reaches the view model on flush, so the tap would create an
EMPTY note and lose what was written. With a one-second debounce the note
already exists by the time any button is reachable.

Three consequences worth naming:

- editingSession, bumped only when the editor opens on a DIFFERENT note.
  The text field keys on it instead of note.id, because a draft's id changes
  the moment it is first saved and re-keying on that would reset the field
  to whatever the store just returned — discarding everything typed during
  the write.
- The field is rememberSaveable now. A new note has nothing to fall back on,
  and the old sheet used rememberSaveable for exactly this reason; the
  editor inherits the requirement along with the job.
- draftDismissed, so a create still in flight cannot reopen an editor the
  user has already closed.

Starting a checklist may create an empty note — a note named from its first
item is one this app already has. Colour and reminder are attributes OF a
note and need words first.

editor_body_hint becomes "Take a note…". It read "Note", which is a label on
a blank screen where the sheet's was an invitation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 22:14:02 -04:00
bvandeusenandClaude Opus 5 2707054563 A write should not cost a revision
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m21s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 7m42s
Every body change snapshotted into history — core/src/local/store.rs and
notes/__init__.py both — so a write was expensive, and the clients
compensated by writing as rarely as they could. BoardViewModel says it
outright: "Saved on close rather than per keystroke, so a session of typing
costs one write and one revision snapshot."

That is durability paying for version history. An app kill mid-session lost
everything typed, so that the revision list would stay tidy. The safety
property is worth more than the feature it was subsidising, and no
comparable product makes this trade: Keep and Apple Notes write
continuously with no history, Docs and Notion write continuously and
coalesce history behind the scenes, Obsidian debounces and snapshots on an
interval. Save-on-close is the outlier, and this coupling is why we had it.

A body change now earns a snapshot only if it is the first of an editing
session — the body actually differs, and the note carries no revision from
the last ten minutes.

Session granularity falls out of the window rather than being declared. A
snapshot stores the body as it was BEFORE the edit, so the first write of a
sitting captures the note as you found it and every write after it inside
the window adds nothing. One revision per sitting, with no commit flag for
a client to send and no wire surface to carry it.

That is why it is a time rule and not a protocol one. sync.py applies pushed
bodies through the same check, so a client autosaving every second cannot
make the server snapshot every second either — which a client-declared
commit point could not have guaranteed without a protocol bump.

Restoring a revision still snapshots unconditionally: a considered act, not
a keystroke, and it stays undoable.

Unblocks idle-debounced autosave, an honest updated_at, and the "Edited just
now" line the editor is getting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 21:47:01 -04:00
bvandeusenandClaude Opus 5 24685556b7 android: open an existing note ready to keep writing
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m14s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m51s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m19s
Opening a note put no cursor anywhere, so carrying on cost a tap into the
body and usually a second one to drag the caret past the existing text.
The compose sheet has always focused its field on open; the editor never
did, and continuing a note is the more common act of the two.

Focus the body on open, caret at the end. Not for a trashed note — that
renders read-only and a keyboard over a record you cannot edit is noise.
Keyed on note.id so the reused editor re-requests when pointed at a
different note.

The caret position is why the body state moves from String to
TextFieldValue: a String field always starts its selection at offset zero,
so focusing one lands the cursor before the first character — the wrong
end of a note you meant to continue. PlainTextField gains a TextFieldValue
overload for it, and the two overloads share one colours definition rather
than growing a second copy of the "no box" treatment this file exists to
keep in one place.

I recorded this backwards in Scribe 2947 — as the keyboard opening
unwanted, when the report was the opposite. The source having no
FocusRequester was the tell, and I read it as a mystery instead of as
evidence I had the direction wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 21:00:57 -04:00
bvandeusenandClaude Opus 5 50e2d308ea android: the editor toolbar was black icons on a near-black bar
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m11s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m38s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 7m54s
Reported as "I'm unable to see a toolbar in the editor on android, is
there one?" — and it was rendering the whole time.

EditorBottomBar passed containerColor but no contentColor, so Material3
defaulted it to contentColorFor(containerColor). That maps a colour-SCHEME
ROLE to its `on-` pair and returns Color.Unspecified for anything else. A
note tint is never a role: the default note is 0xFF171717 while the dark
scheme's surface is 0xFF0A0A0A. So contentColor resolved to Unspecified,
Surface published it as LocalContentColor, Icon took it as its tint, and an
unspecified tint applies no colour filter — leaving the icons-core vectors
their intrinsic black, on a near-black bar.

Every note colour, both themes, only visible in dark. The top bar escaped
it because topAppBarColors(containerColor = …) overrides the container and
leaves the icon colours at their scheme defaults.

Also inset the bar for the keyboard. enableEdgeToEdge makes the manifest's
adjustResize a no-op and Scaffold does not inset its bottomBar slot, so the
bar would sit under the IME the moment anyone typed — a second way to not
see it. imePadding moves to the bar; the content Column drops its own, since
Scaffold now measures the bar at its lifted height and the inset reaches the
content through innerPadding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 17:40:28 -04:00
bvandeusenandClaude Opus 5 77c5422951 ci: a failing lane must not publish an image
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Successful in 16s
build gated on lint + typecheck only, so run 4293 failed its test lane and
pushed :dev and :09b5f87 regardless — the deployed server was running a
build whose tests were red.

The comment justified this by saying DB-backed testing happened manually
against the dev image rather than on every push. That was true when it was
written and stopped being true at 6f21db8, which added the integration
lane. The reason went away; the exception didn't.

Gate on test and integration too. A :<sha> image is the rollback unit for
its commit (family rule 46) — one publishable from a failing run is not
something you can roll back to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 16:52:59 -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
bvandeusenandClaude Opus 5 c851b901df The proxy-hops test still read the value from Config
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 15s
09b5f87 moved trusted_proxy_hops out of the environment and into the
settings registry, but tests/test_proxy.py kept asserting against
Config.trusted_proxy_hops() — which no longer exists. The unit lane has
been red since that commit.

Assert through live() instead. That is what proxy.py actually calls, and
it is seeded from the defaults at import time, so the test covers the
case that matters: a boot that has not reached the database yet still
counts one hop rather than zero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 16:31:21 -04:00
bvandeusen abe01da5f7 compose: say which of the three deployment shapes you're in
The operator asked why `THOUGHTSYNC_BIND` isn't just defaulted to the safe value.
Fair question, and the answer exposed that my own advice was incomplete: I told
them to set it to 127.0.0.1 without asking where their proxy runs, and for a
proxy inside Docker that is the wrong fix.

There are three shapes, not two:

1. **LAN, no proxy** — the default. Binds every interface so a phone and a desktop
   can reach the server. This is why the default is NOT the locked-down value: a
   server reachable only from the machine it runs on isn't hardened, it's broken,
   and that is the primary documented use of this app.
2. **Proxy in Docker** — delete the `ports:` block entirely. The proxy reaches the
   app over the compose network; publishing a host port is a second,
   unauthenticated way in that bypasses whatever the proxy does about TLS. Safer
   than 127.0.0.1, because there is no host port to reach even from the host.
3. **Proxy on the host** — `THOUGHTSYNC_BIND=127.0.0.1`.

The compose file now spells out all three where the decision is made, and
`docs/public-hosting.md` item 4 asks where your proxy runs before telling you what
to do, plus how to check: `curl http://<lan-ip>:5000/api/health` from another
machine should NOT answer once you're proxied.

No default changed. Changing it would silently break every LAN install on the next
`docker compose pull` — the phone would just stop syncing, with nothing saying why.
2026-08-23 15:30:01 -04:00
bvandeusen 09b5f874b6 Security values move into the Settings UI
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 9s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Failing after 12s
CI & Build / Build & push image (push) Successful in 32s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m14s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Operator: *"proxy hops defaults to 1 and should be in the settings UI not in the
envs, we need the security values to be in the UI."* Overrules the call I made
yesterday, and rule 25 is on your side — I argued deployment-topology, but the
operator has to be able to SEE what protects them, and reading a container's
environment is not seeing.

Six new settings in a **Security** group: trusted proxy hops (default 1), the
per-account and per-address sign-in limits with their shared window, and the
sign-up limit with its own. `THOUGHTSYNC_TRUSTED_PROXY_HOPS` is gone; the rate
limits are no longer hardcoded constants.

**The hard part was keeping the throttle cheap.** It consults these BEFORE opening
a database connection — deliberately, because a refused attempt is meant to cost
nothing, and the hop count is needed to know who is even asking. A query per
attempt would undo both. So there is a small cache seeded from the registry
defaults (the app works with no database at all, which is what the DB-free unit
lane relies on), loaded at boot, and refreshed on every settings save — the same
live-update contract `session_ttl_days` already had.

`SlidingWindow` now takes its limit and window as SUPPLIERS rather than values, so
a saved number applies to the next attempt instead of the next deploy.

**Bounds are rejected, not clamped.** A hop count of 99 would trust anything a
caller sent; a sign-in limit of 0 would lock every account out permanently. Both
now fail validation with a message naming the range, and the number input carries
min/max so the browser objects first. Silently storing a different number than the
one typed is how somebody ends up believing a protection is set to something it is
not.

`MAX_BUCKETS` stays a constant on purpose: it protects the limiter from itself
rather than the app from a caller, and there is no operator judgment to apply.

Two integration tests, because the whole point is the round trip: a dangerous
value refused, a legitimate one reaching the cache the throttle reads and
persisting; and every Security row reaching the admin payload with bounds and a
description that explains itself.
2026-08-23 15:24:15 -04:00
bvandeusen a85c53ba2c Trust proxy headers by hop count, and log every credential event
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 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 12s
CI & Build / Build & push image (push) Successful in 32s
Operator, before exposing the instance: *"I'd expect that we should have a proxy
hops setting for how many proxy hops we should trust a shared real-ip at… and is
there any session logging."* Neither existed, and the first one was a real hole.

**The address was forgeable.** `client_address()` read the LEFTMOST
`X-Forwarded-For` entry — nominally "the original client", and precisely the one a
caller controls, because anything they send arrives before what proxies append. So
`curl -H "X-Forwarded-For: 1.2.3.4"`, rotated per request, minted a fresh
rate-limit bucket every time.

Concretely: stuffing ONE account stayed limited (the account key is unforgeable
and that is why it exists), but spraying MANY accounts from one source was not —
each account got its own budget, and the per-address cap meant to bound the total
was defeated by a header. On a LAN that is nothing. It is not nothing on a public
host.

Now it counts in from the RIGHT by `THOUGHTSYNC_TRUSTED_PROXY_HOPS`, default 1.
Each hop appends what it saw, so the rightmost entries are the ones our own
infrastructure wrote and a forged prefix lands to the left of them where it can
never be selected — proven for the honest, forged, padded, CDN and
shorter-than-configured cases. 0 ignores the header entirely; 2 is Cloudflare in
front of a proxy. Too high is the dangerous direction, so a header shorter than
configured falls back to the socket address rather than reaching further left.

`X-Forwarded-Proto` had the same bug and now shares the same rule. Both live in a
new `proxy.py` rather than being written twice — two places holding one decision
is how issue 2183 happened, and this is the same decision.

Env rather than the Settings UI, against rule 25's usual pull: it is deployment
topology rather than preference, and the limiter consults it BEFORE opening a
database connection, which is the entire point of checking a throttle before doing
expensive work. Easy to move if that reads wrong.

**And there was no logging at all** — `auth.py` had no logger, and the only record
of anything was `device_tokens.last_used_at`. Sign-ins, failures, throttle trips,
new accounts and device-token issuance now all log, with the attempted email and
the trusted address. Deliberately including the email: it is the operator's own
server, and "somebody failed a login" without saying against which account is not
actionable.

`basicConfig` at INFO in `create_app`, because hypercorn configures its own loggers
and leaves the root at WARNING — without it every line above would have gone
nowhere, which is a worse failure than not writing them.

This is the app log, not an audit table. Not queryable, not retained past log
rotation. The table is task 2939; this is what makes the next few days observable.
2026-08-23 15:12:14 -04:00
bvandeusen 2141a0ac45 Registration closes itself once the instance has an owner
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 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 17s
CI & Build / Build & push image (push) Successful in 28s
Operator: *"registration should be open only for the first user and they get
granted admin privileges. then registration is closed."*

The old shape had a window in it. The first account was always allowed and became
admin; every account after that was gated by `allow_registration` — which
defaulted to ON. So the door stayed open between "my account exists" and "I
remembered to turn it off in Settings", and on a public host that gap is the
entire exposure: it starts the moment DNS resolves and lasts until someone
remembers.

Now the door shuts as a CONSEQUENCE of the admin account existing, in the same
transaction that creates it. Not "defaults closed" — that would still need the
first person to get in somehow. There is no window to remember, because there is
no window.

Re-opening it is a deliberate act in Settings → Access: turn it on, have the
person register, turn it off. Crude, and it is the only mechanism there is —
**there is no invite system**, not even a stub. That is real work (a token table,
admin create/revoke, a redemption flow, expiry) and is filed as later work rather
than smuggled into a release.

An integration test covers it, because it is the interaction between two writes
in one transaction: first register → 201 and `is_admin: true`; the setting is
then false; a second register → 403; re-open deliberately and a third → 201, not
admin.

**This does not retroactively close an instance that already has users.** The
close fires on first-account creation, so a server whose admin predates this
keeps whatever the setting was — which was on. `docs/public-hosting.md` now says
so explicitly, and step 1 of the checklist is "check" rather than "do" for
exactly that reason.
2026-08-23 14:18:58 -04:00
bvandeusen 1aca294b95 Bump to 0.2.0 — a release at 0.1.0 would have been a downgrade
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 14s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m59s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m9s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m0s
Found while preparing the release, and it would have quietly defeated the point
of cutting one.

The version does NOT come from the tag. `desktop/packaging/build-version.sh`
reads `desktop/src-tauri/Cargo.toml`, and on `dev` it appends the CI run number
(`0.1.269`) while on a tag or `main` it ships the file's value verbatim — which
was still `0.1.0`.

So tagging today would have published a "release" numbered BELOW every dev build
already out there, and below the 0.1.227 on the operator's phone. The desktop
updater compares semver: an installed build would have read the stable manifest,
seen a version older than its own, and correctly concluded it was already
current. The APK would have installed (versionCode is the run number and keeps
rising) while displaying a version that reads as going backwards.

build-version.sh predicted this in its own comment: "Bumping the minor in
Cargo.toml still wins over any dev build on the old line, which is the ordering
you want: 0.2.0 > 0.1.2932."

Bumped in four places, which is every one that can be read by something:
- `desktop/src-tauri/Cargo.toml` — the actual source; everything else derives
- `tauri.conf.json` — overridden at build time by `--config`, but a checked-in
  value that lies is exactly how issue 2183 happened
- `pyproject.toml` + `__init__.py` — the server's APP_VERSION fallback when no
  BUILD_VERSION is injected

`core` and `android/ffi` stay at 0.1.0 deliberately: internal library crates whose
version reaches no surface, and versioning workspace libs independently of the
app is normal.

Cargo.lock regenerated with `cargo fetch` per ci-requirements — one line, the
version itself. Verified: a tag build now yields 0.2.0 and a dev build 0.2.270,
so stable is an upgrade for every existing install and dev stays ahead of stable.
2026-08-23 14:02:02 -04:00
bvandeusen 7033995975 search is a facet on the board, not a place you go
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Successful in 37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m13s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Operator (note 2930): tags exist so you can *"filter during a search"*. The
server has always been able to do that — `GET /api/notes` composes `?q=` with
`?label=` and the rest into one AND-ed query. The frontend never reached it.

The header search box navigated to `/search`, and that view called a DIFFERENT
endpoint — `GET /api/notes/search?q=`, full text only, no facets at all. So the
one screen you landed on when you searched was the one screen where you could not
narrow by tag. Tag filtering lived on the board's FilterBar, which is where you
weren't searching. Two search boxes, two endpoints, and only the hidden one did
what tags are for.

Now the header box writes `?q=` into the board's URL beside whatever labels are
already there, and stays on the lens you're in — searching while looking at Trash
searches Trash. The box READS from the URL rather than holding its own copy, so
it stays in step with the Filters panel's Clear and with a saved view opened from
the sidebar.

Deleted: `SearchView.vue`, its route, `GET /api/notes/search`, `repo.notes.search`
and both adapter implementations, and the `notes_search` Tauri command whose only
caller was the adapter entry. FilterBar loses its own "Search text…" input — it
was the same facet, hidden behind a collapsed panel, duplicating a box that is
always on screen. Filters now does what its name says: narrowing. The header does
searching.

`core::store::search` STAYS. Android calls it through the FFI (`search_notes`) and
has its own search surface — which has the same no-tag-filter gap the web just
lost, and deserves the same fix on its own terms rather than as a rider here.
2026-08-23 10:58:23 -04:00
bvandeusen de72d27bd4 URLs unfurl on their own, and a lone link becomes the note
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / integration (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m16s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Operator: *"I'd like for URLs to unfurl. To be the whole note when the note is a
single URL, and to be a compact slot on the bottom of the note when the URL is
inline. We also need to support multiple URLs in a single note."*

Less new machinery than it sounds: `unfurl.py` already fetched and parsed OG
tags, SSRF-hardened, and `note_link_previews` was already `UNIQUE(note_id, url)`
— so several URLs per note has worked at the storage layer all along. What was
missing was that it needed a button, had one size, and drew that size in the
wrong place.

**Automatic, and never in the way.** New `unfurl_queue.py` detects a body's URLs
and fetches them on a background task AFTER the note is committed. Capture speed
is the product: an unfurl is a five-second timeout against a host nobody
controls, and a note has to persist the instant someone stops typing. Scheduled
from create, from a body edit, and from a synced push — so a linked desktop or
Android client gets previews too, on its next pull. An unlinked one has no server
to ask and simply has none, which is the honest consequence of being offline.

Safe to call on every save: it re-reads what's cached and does nothing when
nothing is new. Capped at five URLs per note, silent on every failure (a link
that won't fetch isn't an error the person needs — the note is fine, the link is
still there), and it re-checks before storing, so a slow fetch can't resurrect a
preview for a URL that was deleted while it was in flight.

**Two presentations.** A note whose body is nothing but a URL renders as its
preview and nothing else — printing the raw URL under a card that already says
where it goes is saying the same thing twice, badly. Until the fetch lands, or if
it never does, the URL stands in, so the card is never blank. Anything else gets
a compact strip.

**And the strip moved.** Previews were rendered ABOVE the body, which put a
stranger's headline where the note's own first line should be — worse now that
the first line IS the note's name. They sit at the foot of the card now, under
the note's own words.

The editor's "Preview example.com" button is gone with the manual path; removing
an unwanted preview stays, and stays editor-only.

Nine tests: three on detection (order, dedupe, sentence-punctuation trimming,
non-http rejection) in the unit lane, and three in the integration lane for what
only a real database shows — the upsert landing on the right row, a second pass
fetching nothing, and a preview NOT being stored for a URL that left the body.
2026-08-23 01:05:37 -04:00
bvandeusen c99cbb3e14 cards: clamp the web note preview, as Android always has
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 29s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m16s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m24s
Desktop (Tauri) / Update manifest (push) Successful in 4s
M13 step 4 asked for no bold first line, and step 3 already delivered that —
removing `note.title` took the card's <h3> and the Android editor's bold field
with it. What step 4 also asked for, and hadn't been done, was the other half:
"be willing to spend something small on legibility that isn't weight on the
first line."

The web card rendered the entire body. Android has always clamped to eight lines
(`MAX_PREVIEW_LINES`), so one long note produced a card taller than the screen on
the web and pushed the rest of the board off it — a real asymmetry between two
surfaces that are supposed to be peers.

It matters more without a title. The first line used to be what your eye caught;
with one weight throughout, an unbounded card is just a wall, and the note beside
it is the one you were actually looking for.

Clamped in the STRING, not with CSS `line-clamp` — that needs a `-webkit-box` and
behaves unreliably around the block elements MarkdownText emits (lists, quotes,
fenced code). Doing it before the parse is deterministic, matches Android's
semantics exactly, and skips parsing a body the card was never going to show.
2026-08-23 01:00:39 -04:00
bvandeusen 6f21db85a1 ci: an integration lane, so the migrations are finally run by something
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 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / integration (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 25s
26 Alembic revisions and none had ever been executed by CI. `alembic upgrade
head` ran for the first time when the operator's container started, and 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 but a server boot.

Copied from FabledScribe's `integration` job, which had already solved the parts
that are easy to get wrong — and which are family rules precisely because they
were: a separator-free job key with no `name:` (act_runner derives the service
container name from the truncated display name, and the discovery step filters
`docker ps` by it), bridge-IP resolution because service hostnames aren't
routable on this runner, and a Python readiness wait because `run:` is busybox
sh with no `/dev/tcp`.

`postgres:16-alpine` to match the production compose. The schema is built by
real migrations, never metadata.create_all — that step IS the migration test.

Six tests, each pinning something that has only ever been checked by hand:

- an ORM insert against the migrated schema, which is the model/migration
  agreement nothing has verified until now;
- `notes.title`, `notes.kind` and `note_revisions.title` are actually gone, and
  `note_links` with them — a silently no-op migration shows up here;
- the rebuilt `search_vector` indexes both the name and the body, which matters
  because 0026 had to DROP and recreate a generated column rather than alter it;
- a note keeps its body AND its items, the shape step 2 made normal;
- `_apply_note_items` leaves items alone when a change doesn't mention them —
  the data-loss path step 2 removed, pinned so its return would be caught;
- a note with no body is still named by its first item, the hole that made
  removing the title unsafe until checklists stopped being their own kind.

Runs for visibility; does not gate the build, matching `test` and Scribe.

No local equivalent: running it means standing up Postgres on the workstation,
which rule 12 reserves for an explicit request. Documented in ci-requirements
alongside the Rust, Kotlin and frontend gates.
2026-08-23 00:24:57 -04:00
bvandeusen 924ddb20db notes: saveEdit still asked for a title
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 35s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m0s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m10s
Desktop (Tauri) / Update manifest (push) Successful in 4s
The one thing step 3 missed, and the typecheck lane caught it: `saveEdit`'s
parameter type still declared `title`, so the editor's call — correctly no
longer passing one — didn't match.

I gated Rust locally and not the frontend. Both are now in ci-requirements,
including WHY the frontend one has to be `npm run build` rather than
`vue-tsc --noEmit`: the typecheck only reads the script block, so a malformed
template sails past it and fails `vite build` in a different workflow, which is
exactly how the stray `</div>` got two commits away from where it was written.
2026-08-22 21:38:13 -04:00
bvandeusen 95aa10c2c3 Remove the title field — a note is named by its first line
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 7s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 31s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m45s
Operator (note 2897): "notes shouldn't have a title field." The concept of a NAME
stays — search results, export filenames and the command palette all need one —
but nothing is typed into it any more. `display_title` is now the first non-empty
line of the body, falling back to the first checklist item.

That fallback is what step 2 bought, and the reason this could not go first: a
checklist had no body to be named from, so the title was its only name. Now every
note has a body, and a note that is only a checklist is named by its first item.

Gone everywhere: the column and note_revisions.title (0026), the field on the
core's Note/NoteCreateInput/NoteRevision and its SQLite columns (user_version 7),
`normalize_title`, the wire field, the FFI record and `NoteEdit::Title` /
`ClearTitle`, the web editor's "Title (optional)" input and the card's <h3>, and
the Android title field in both the compose sheet and the editor.

**The search vector had to be rebuilt, not just left alone.** `notes.search_vector`
is a STORED GENERATED column whose expression names `title` — Postgres refuses to
drop a column another generated column depends on. It is dropped and recreated over
`display_title` at weight A, which keeps the original intent: a note's NAME ranks
above the rest of its body.

**An imported title becomes the note's first body line.** Keep notes carry one, and
so does any ThoughtSync export taken before this. Dropping it would silently lose
text someone wrote; folding it in puts it exactly where a name now lives, so the
note arrives named as it was. Skipped when the body already opens with that line,
so re-importing an export this code produced doesn't stack duplicates.

Two smaller things fell out. The Android editor loses its bold first field — one
weight throughout, because the first line is the note's name but not a different
KIND of text, which is most of step 4 arriving early. And `ClearTitle`'s
justification comment moved to `ClearRemindAt`, which is now the surviving example
of why NoteEdit is a list rather than a struct of options.

Protocol note corrected to say what actually shipped: v2 is "no kind, no title",
one bump for the pair.

Verified with the local Rust gate this time, not by CI: fmt, clippy and 116 tests
all green before pushing. It caught four things — orphaned serde attributes where
fields were removed, a `wire::Preview.title` I deleted by mistake (a link preview
still has one), nine retention fixtures inserting a dropped column, and four
rustfmt diffs.
2026-08-22 19:33:57 -04:00
bvandeusen 6d778f26a7 Fix the ktlint and compat-test failures, and start using the Rust gate
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m44s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m46s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m47s
Two more from the step-2 removals:

**Two unused Kotlin imports** — `FilterChip` (the Note/List switch) and
`Icons.Filled.Create` (the "switch to a note" icon), both orphaned when their
callers went. ktlint treats them as errors.

**`server_info_tolerates_unknown_and_absent_fields`** pinned
`sync_protocol_version: 1` as a literal, so bumping the protocol to v2 made it
fail for a reason that has nothing to do with what it tests. It is about unknown
FIELDS; the versions now come from `CLIENT_PROTOCOL_VERSION`, like every other
test in that file already did.

The bigger fix is the habit. `ci-requirements.md` has documented since
2026-08-18 that the operator authorised running fmt/clippy/test against the CI
image locally, and I had not been doing it. All three now pass here — 116 tests,
clippy clean, fmt clean — and every Rust failure in this milestone so far would
have been caught by them in under a minute instead of by CI, several commits
downstream. Noted in ci-requirements so the next session doesn't relearn it: a
removal is exactly the change that looks too safe to check.
2026-08-22 14:51:38 -04:00
bvandeusen 33e9278975 Fix three breaks the removals left behind
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 / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m53s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m4s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 4m27s
**`snapshot_revision` was deleted with `create_titled`** (bc22f8e). I sliced the
function out by scanning to the next `pub fn`, and the private `fn` sitting
between them went too. Nothing in Python or TypeScript compiles Rust, so it sat
undetected until the first lane that does. Restored verbatim.

**An orphaned serde attribute** in push.rs: removing `pub kind: Option<String>`
left its `#[serde(skip_serializing_if)]` behind, which then stacked onto the
next field's. That failed the derive, which is why three follow-on errors all
said `Change: Serialize is not satisfied` — one cause, four messages.

**An unbalanced `</div>`** in NoteEditor.vue, orphaned when the "Links / Linked
from" footer was cut. `vue-tsc --noEmit` type-checks the SCRIPT block and never
parses the template, so the typecheck lane passed it and `vite build` caught it
two workflows later. Worth remembering: a green typecheck says nothing about
template structure.

I also pushed step 2 without waiting for ad21eac to go terminal, which is what
let the Rust break travel a commit further than it should have.

Each fix comes with the check that would have caught it: a scan for stacked
serde attributes and called-but-undefined fns across every .rs, and a tag
balance pass over every .vue. Both are clean.
2026-08-22 13:25:26 -04:00
bvandeusen c46a4a7709 A checklist is something a note has, not something a note is
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) / Tauri desktop (Linux) (push) Failing after 9s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 8s
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 13s
Android / Kotlin + Rust (APK) (push) Failing after 1m43s
`kind` was never a type. A plain TEXT column with no enum and no CHECK behind
it, compared against a hardcoded ("text", "list") tuple in six places;
`note_items` was always an ordinary child table keyed by note_id; serialization
already emitted `items` whatever the kind; and the Android editor already
toggled between the two losslessly, saying so in a comment. The storage has
modelled "a body plus optional checkable items" the whole time. This deletes the
gates that forbade it.

Every surface: the create/PATCH gates, the ?kind= filter and its saved-filter
facet, the three import/export branches, the column (alembic 0025); the core's
`kind` field, its SQLite column (user_version 6), the sync wire, push and pull;
the FFI records and `NoteEdit::Kind`; and on Android `NoteKind.kt`, `DraftKind`,
the compose sheet's Note/List switch, and the branches in the card, the editor
and the chrome.

The editor's note⇄list toggle becomes "Add a checklist" — on both the web and
Android. It is not a conversion any more: nothing moves, nothing is swapped, the
body stays exactly where it is and the note gains somewhere to put items. The
card renders both, in order.

Two things that fell out of the merge rather than being aimed at:

- The Keep importer was DISCARDING `textContent` whenever a note also had
  `listContent`, because the target could only hold one. Both survive now, and
  the test says so.
- Markdown export wrote the body OR the checklist. It writes both.

Protocol goes to v2, floor included: dropping a field a v1 client sends and
expects back is breaking. `title` leaves in step 3 and lands in the same
generation, so it needs no further bump. This is the change that will make the
0.1.227 build on the operator's phone refuse to sync — the in-app updater is
independent of the handshake and remains the recovery path.

The V1 SQLite schema deliberately KEEPS the kind column. V1 is the historical
schema and every later block alters it, so removing it there would make a fresh
database run V1 without the column and then v6's DROP COLUMN against a column
that never existed — "no such column: kind" on every new install.
2026-08-22 12:53:53 -04:00
bvandeusen 229076c82d sync: stop deleting a note's checklist items because it isn't a "list"
`_apply_note_items` didn't ignore items on a non-list note — it deleted them.
That was survivable only because nothing in the product could produce a note
holding both a body and items.

M13 makes exactly that the normal shape: a checklist is something a note HAS,
not something a note IS. Against that shape this guard is a data-loss path — the
first sync after adding a checklist to a note would wipe it.

Landing it before the UI that can create the state, so there is never a window
where the two disagree. `kind` itself, and the rest of the merge, follow.
2026-08-22 12:45:14 -04:00
bvandeusen ad21eac5bc editor: drop the adapter import that went with backlinks
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 27s
CI & Build / Build & push image (push) Failing after 22s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 30s
Desktop (Tauri) / Update manifest (push) Skipped
`repo` reached the editor for exactly two calls — `repo.notes.backlinks` and
`repo.notes.linkSearch` — and both left with the linking system. vue-tsc runs
with noUnusedLocals, so one stale import failed the whole shared-frontend build
and took both desktop lanes down with it (TS6133).

My local sweep checked for dangling *references*; it never checked the inverse,
that every import still has one. It does now, across all fifteen files that
removal touched — `repo` was the only one.
2026-08-22 12:44:33 -04:00
bvandeusen bc22f8e249 Remove [[wiki-links]], backlinks and the graph
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 31s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 37s
Desktop (Tauri) / Update manifest (push) Skipped
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) Failing after 6s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 8s
Android / Kotlin + Rust (APK) (push) Failing after 1m56s
Operator, 2026-08-22 (note 2897): ThoughtSync is an intermediary surface. You
write here because it's easy — a notebook in your pocket — and later you recall
the thing and go finish it somewhere else. Recall is the product; organization
is secondary. A linking system is organization, and it isn't what this is for.

So: `[[wiki-links]]`, backlinks, the `[[` autocomplete, the note_links table,
`/api/notes/link-search`, `/api/notes/<id>/backlinks`, the whole graph blueprint
and GraphView. Rust core loses `extract_links`, `backlinks`, `link_search` and
`create_titled`; the desktop loses the three Tauri commands that exposed them.

This subsumes 982d24c rather than reverting it. That commit bound links to a
note id so a rename would stop rewriting other notes' bodies — real infra, but
infra for a feature that is now gone, and nothing it added survives. Alembic
0023 stays in the chain anyway: it shipped in an image and may already be
applied, and deleting an applied revision strands a database's version pointer.
0024 drops the table and takes the column with it. The history stays honest
about the fact that it existed for a day.

Two things deliberately kept, because they were serving recall and only
incidentally serving links:

- `/api/notes/titles` and the titles store. The command palette lists them so
  you can jump to a note by name. `resolve()` — the name→note lookup that only
  linking needed — is gone.
- `display_title`. Every note still has a name for search results and export
  filenames. What that name is FOR changed; that it exists did not.

`notes/links.py` is now `notes/tags.py`, holding the #tag→label reconciliation
it always also owned. A file called links.py with no links in it would have been
exactly the drift this removal is meant to end.

Also swept out on the way: `_escape_like`, whose only caller was link-search,
and the `graph` icon. Nothing lost that a person typed — note_links was always
derived, and the `[[text]]` is still sitting in every body it was written in.
2026-08-22 12:00:57 -04:00
bvandeusen 982d24c83b links: bind a [[link]] to a note, not to a string
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 34s
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 5s
A wiki-link was stored only as normalized TEXT, so a note's NAME was the edge.
Renaming it broke every inbound link — and the fix that shipped for that
(task 1848, option b) was `_rename_inbound_links`: rewrite the `[[Old Name]]`
text inside the body of every note that linked to the renamed one.

That works while an explicit title exists to hold still. It stops being
defensible the moment a note's name is just its first body line, which is where
M13 is going: fixing a typo in your opening sentence would silently edit other
notes' words, with nothing to opt out to. So this lands first, before the title
comes out, and that window never ships.

`note_links` gains `target_id`, bound when the link is written. `target_norm`
stays and is what an UNRESOLVED link carries — linking to a note that doesn't
exist yet is a supported way to create one, so a link has to be able to name a
target that isn't there. Resolution reads the id, falling back to the name only
where nothing was bound, which is what lets a forward link connect the moment
its target appears. `_claim_unresolved_links` then binds it, so the fallback is
a transitional state rather than a permanent one.

`_rename_inbound_links` and `rewrite_link_title` are gone. What replaced them
touches link rows only: a note's text is never modified by something happening
to a different note.

The client can no longer resolve links for itself, and that is the point. It
used to look `[[text]]` up in a client-side name index, which only held together
BECAUSE renaming rewrote the text everywhere. Now the written text can name
something the target is no longer called, and only the server holds the binding
— so each note serializes its resolved links (`norm`, `id`, and the target's
name as it stands NOW). A renamed note reads correctly everywhere it is linked
from, without a single body having been edited. Unresolved links are simply
absent and fall through to the create-on-click affordance that already existed;
so does the offline desktop store, which derives links at query time and has no
binding to send.

The name-fallback join is owner-scoped everywhere it appears. Bound ids were
resolved owner-scoped when written, but matching on display_title alone would
have let two users who each have a note called "Groceries" see the other's id
and name through an unresolved link (rule 47).

The new behaviour is all SQL and this suite runs without a database, so the
dead helpers' tests are removed rather than replaced. This repo has no
integration lane to hold that ground — noted, not papered over.
2026-08-22 11:02:39 -04:00
bvandeusen bacedea8a3 tests: seed the throttle counters on the clock the routes actually read
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 19s
The three route tests stamped their pre-loaded hits at t=0..9 through the
injected clock, then called a route that reads `time.monotonic()`. Against a
trailing window those hits are fifteen minutes stale on arrival, so they were
pruned before they could refuse anything, the request carried on to the database
that this suite doesn't have, and the assertion read `500 == 429`.

The window's own tests keep the injected clock — they pass the same one to both
sides, which is what makes them deterministic and instant. Only the tests that
hand off to a route need the real one.
2026-08-21 22:03:19 -04:00
bvandeusen b6152ec18b server: harden the surfaces a public deployment leaves exposed
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 9s
CI & Build / Python tests (push) Failing after 11s
CI & Build / Build & push image (push) Successful in 33s
On a LAN the login form is reachable by people you already trust. Exposed, it is
reachable by everyone, and nothing in front of it was counting.

Three credential routes — /login, /register and /device-login — now throttle.
Every attempt is counted against BOTH the account and the calling address, and
either can refuse it. The account key is the one that matters and the one that
cannot be forged: it stops stuffing against a known email no matter how many
addresses the attempts arrive from. The address key bounds one source spraying
many accounts, and is best-effort by nature — behind a proxy it comes from
X-Forwarded-For, which a caller can set to anything if the app is exposed
directly. That is exactly why it isn't the only key.

The check runs BEFORE the password is verified, which is the other half of what
this protects. bcrypt is deliberately slow; an unauthenticated caller who can
trigger it without limit has a CPU exhaustion primitive as well as a guessing
one. Sliding rather than fixed windows, because a fixed one lets twice the limit
through across a boundary. Bucket count is capped so a rotating forged header
can't turn the limiter into the exhaustion it prevents.

A sign-in against an email with no account now spends a real bcrypt against a
throwaway hash first. Without it "no such account" returned in microseconds
while a wrong password took ~100ms, which is a reliable oracle for which emails
are registered here.

Every response carries a CSP with script-src 'self', object-src 'none' and
frame-ancestors 'none', plus nosniff, a referrer policy and a permissions
policy. The app has no inline and no third-party scripts, so this concedes
nothing; the exceptions are honest — inline STYLE (Vue writes it itself for
v-show and the FLIP), and remote images (a link preview renders the og:image of
an arbitrary host, over either scheme, since a LAN install is served over http).
HSTS only where the request already arrived over TLS, and scoped to the one
host: no includeSubDomains, no preload, neither of which is this app's to
commit.

X-Forwarded-Proto detection moved into one `_is_https()` — the session cookie's
Secure flag and HSTS are the same question, and answering it twice is how the
two drift apart.

docs/public-hosting.md is the rest of it: the four things only the operator can
do (close registration, terminate TLS and forward the scheme, stop publishing
the app port, back up the attachment volume as well as the database), and an
honest list of what the app does NOT have — no email verification, no password
reset, no second factor, no per-user quota, no audit log. Those aren't blockers
for an instance whose accounts are people you know. They're the reason not to
leave signups open to strangers.
2026-08-21 22:01:37 -04:00
bvandeusen 16f86bef93 web: make the board usable on a phone, not just reachable
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 34s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 50s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m6s
Desktop (Tauri) / Update manifest (push) Successful in 6s
The controls a card carries were always-visible overlays on a touch device —
correct as far as it went (task 2697: a finger cannot hover, and the pill is
the only way to pin or archive), but they were still absolutely positioned, so
they sat ON the note's own title. A card reading "thought sync tauri app"
rendered as "ught sync tauri app" with the grip parked over the first three
characters, and the four-icon pill covering the right half of the first line.

Placement is now CSS's decision. One element each, two placements: where a
pointer can hover they lift out of flow into the floating top-corner pills they
have always been; where nothing can hover they stay in flow as a footer row,
which cannot overlap anything by construction. Keyed on hover rather than
width, for the same reason `.hover-reveal` already is — a narrow window on a
laptop still hovers, a wide tablet still doesn't. The colour popover moved
inside the action set so it follows it, and opens into the card from either
end.

The header was sharing one phone-width row between a menu button, the logo, the
lens name, a search field and four icons; everything in it was truncated, the
lens down to "N…" and the search box to an empty pill. It wraps now, so search
takes its own line below sm, and account / settings / sign-out move into the
drawer where there is room to name them rather than guess at a glyph. One input,
moved by CSS — duplicating it would have meant two `searchInput` refs and a `/`
shortcut that focuses the wrong one.

Also closes the other half of task 2706, which was waiting on a device to look
at: `viewport-fit=cover` together with the `env(safe-area-inset-*)` padding
that makes it safe (sides on body, top on the sticky header, bottom on the
board and the drawer), and `100dvh` behind an @supports so the app box follows
the visual viewport when the keyboard opens instead of the layout viewport.
Both halves in one change, as that task insisted.

And the composer no longer tells a phone to "Press Enter".
2026-08-21 21:55:46 -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
bvandeusen 81695fa0c8 android: update the app from the server it syncs with (2727, M12 step 7)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m8s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m28s
Android / Kotlin + Rust (APK) (push) Successful in 7m36s
Closes M12. The phone can now notice that its server has a newer build and
install it, instead of the operator copying an APK to a device by hand.

**A PackageInstaller session, not an install intent.** The obvious route —
ACTION_VIEW on the APK — is exactly what on-device install heuristics are tuned
against, and it is what produced the "bypassing Android security" warning on
Minstrel (Scribe note 2437). It also never tells the OS that this app is the
legitimate updater of its own package, and it returns nothing: a failed install
is indistinguishable from someone dismissing the dialog.

The session says who is doing what, and on Android 12+ declares no user action
required — which, with UPDATE_PACKAGES_WITHOUT_USER_ACTION, removes the
confirmation entirely on the UPDATE path. Only there: Android will not let an app
quietly put a NEW package on a device, which is right. It also only applies when
the new build carries the same signing key as the installed one, which is why
signing had to land first.

Two things from that research deliberately NOT done: `setRequestUpdateOwnership`
was chased and turned out to be a red herring, and REQUEST_INSTALL_PACKAGES is
not the differentiator either — Mihon declares it too. The mechanism was the
whole difference.

**The outcome comes back.** `commit` takes an IntentSender and the result lands
at `UpdateReceiver`, so a failure can be shown rather than guessed at, and
STATUS_PENDING_USER_ACTION is handled — that is the ordinary path below API 31
and still possible above it, since the OS is entitled to ask anyway. Someone
declining is reported as no error at all: calling a deliberate choice a failure
is how an app sounds broken when it is not.

**The network work stays in Rust.** Two FFI additions — `clientUpdate` and
`downloadClientUpdate` — because the device token lives in the core, and pulling
it into Kotlin to make an HTTP call would spread the one secret this app holds
across two languages for nothing. The core also owns the comparison, so the rule
"version CODE decides, never the name" lives in the layer that has to get it
right for every surface.

The download is streamed to disk, not buffered: 55 MiB in memory on a phone is
how an update gets killed halfway through. It lands in `update.apk.part` and is
renamed only once size and sha256 both match, so an interrupted download can
never be mistaken for a finished one. The digest is not a trust anchor — the
signature is, and Android checks it — but it catches a truncated transfer before
the installer is bothered with it. The advertised path is joined to the base URL
this device is LINKED to rather than followed as given, so a server cannot point
the download at a host nobody agreed to.

**Updates are linked-only, and it says so.** An unlinked install has no update
path, so it gets one sentence explaining where updates come from rather than a
Check button that silently finds nothing — the same lesson as the desktop's
unlink copy (issue 2110). And the "install unknown apps" grant is asked for
BEFORE downloading, so nobody spends 55 MiB to be told no.

Every Android API here was read out of `android-36/android.jar` with javap
first, and the two new FFI methods out of freshly generated bindings, rather
than recalled: `suspend fun clientUpdate(installedVersionCode: Long):
ClientUpdate?` and `downloadClientUpdate(destPath: String)`.

Also fixes `check-symbols.py`, which reported four false positives on
`UpdateOutcome.Result` — its object-member index collected functions and
properties but not nested TYPES, and a data class inside an object is an
ordinary member.
2026-08-21 08:44:08 -04:00
bvandeusen 0cf77336d4 ci: build the server image after the Android lane, not alongside it
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 8s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 10s
Android / Kotlin + Rust (APK) (push) Successful in 7m19s
Baking the newest client into every image left two holes, both raised by the
operator.

**An Android-only push never rebuilt the image.** `ci.yml` does not trigger on
`android/**`, so a new APK could be published and no image would ever pick it up
until some unrelated server change came along.

**A push touching both raced.** Both workflows start at once; the image build
would fetch the PREVIOUS client and there would be no second build to correct it
— `:<sha>` is the immutable rollback unit (rule 46), so rebuilding it with
different content would make it neither immutable nor a rollback unit.

Ordering now runs the other way: the Android lane finishes, then calls the image
build. `ci.yml` gains a `gate` job that stands down on any push touching the
Android app, and `android.yml` dispatches `ci.yml` when it is done. One image per
commit, containing the client from that commit.

Cases:

- **server only** — ci builds immediately; the newest published client is already
  the right one.
- **Android only** — ci does not trigger at all; the Android lane dispatches it
  afterwards.
- **both** — ci's push run stands down, the Android lane dispatches it. Exactly
  one image.
- **tag** — always builds. The Android lane does not run on tags, so waiting for
  a call that never comes would mean a release tag with no image.

The dispatch is `always()`, so a FAILED Android build still lets the server image
through with the previous client. The alternative is a broken Android lane
silently blocking server delivery, which is a worse failure than a slightly old
APK.

Two details that would each have made this quietly wrong:

The gate diffs the whole PUSHED RANGE (`event.before..HEAD`, full fetch), not
`HEAD^..HEAD`. A three-commit push whose Android change sat in the first would
otherwise have looked Android-free and raced anyway — silently, which is the
worst version of this bug.

The dispatch is `curl -fsS`, not `|| true`. If that call ever stops working the
symptom is server images silently never being built for Android pushes, which
nobody would notice until wondering why the app stopped updating.

The gate's path list has to match android.yml's trigger, and two places holding
one decision is the recurring failure in this repo (issues 2181-2183). It is a
`git diff` rather than a config precisely so the decision is visible in the log,
and both sides carry a comment pointing at the other.
2026-08-20 21:46:05 -04:00
bvandeusen 010e9a2f85 server: bake the newest Android client into every image (operator call)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 47s
Reverses the placement decision made an hour ago. That one put the APK only on
the data volume, reasoning that ~55 MiB should not be charged to installs that
never touch Android. The operator's call is that ending the manual copy is worth
the megabytes, and it is their deployment.

CI now fetches the newest published client into the build context immediately
before the image build, so `:dev`, `:latest` and `:<version>` all ship one and a
`docker compose pull` delivers a new server and a new client together.

**Always the rolling `dev` release — the newest build there is.** A versioned
image therefore carries the newest client rather than one pinned to that
version. Deliberate: the two negotiate a sync protocol version before linking, so
a mismatch is caught by the handshake, and pinning would buy nothing the
handshake does not already provide.

**Fetched by the JOB, never by the Dockerfile.** The release is private, and a
token used inside a build ends up in the context or a layer.

**It cannot fail the image build.** No release yet, a network blip, a first-ever
build — all of them log a warning and produce an image with no client, which is a
state the server already supports. Half a pair is cleaned up rather than shipped:
a sidecar without its APK is worse than neither, because the server would be
describing something it cannot serve.

**The volume still wins.** `DATA_DIR/client/` is checked first and the baked copy
second, so an operator who deliberately drops a build in gets that build — and a
BROKEN drop-in falls through to the image's copy rather than taking the feature
offline, which is what makes the copy-order advice survivable instead of
load-bearing. Three tests cover the precedence, including that last case.

The baked copy lives inside the package, not under DATA_DIR: that path is a
volume mount, and anything the image wrote there would disappear behind it the
moment one is attached.

`client/.keep` is tracked so `COPY client/` cannot fail on a tree where the CI
step never ran; the artifacts themselves are gitignored, since a 55 MiB binary
does not belong in git history and is re-fetched on every build anyway.
2026-08-20 21:19:47 -04:00
bvandeusen 43ebb6eceb packaging: the rolling-release prune was eating the Android client
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
Run 4092 published `thoughtsync.apk` to the `dev` release. Run 4098 removed it,
four minutes later, and both runs were green.

`write-manifest.sh` prunes the rolling channel to stop ~100 MB AppImages
accumulating forever, keeping `latest.json` and anything whose name contains the
current `$APP_VERSION`. The Android assets deliberately have no version in their
names — a fixed name is the only addressable URL on a tag that never moves,
which is the entire reason the `dev` release exists — so they matched neither
rule and were swept.

They would have been swept even if they HAD carried a version: Android is a
different workflow with its own run number, so its version never equals the
desktop's `$APP_VERSION` in this script.

The keep-list is now about fixed names rather than about `latest.json`
specifically, which is what the rule always meant. A fixed-name asset is
self-limiting — each publish replaces the same name — so the accumulation this
prune exists to prevent cannot happen to one.

Worth noting how this presented: two green runs and a missing file. Nothing
failed, and the only way to see it was to ask the release what it actually held
rather than trusting that a step named "Publish" had published.
2026-08-20 20:31:03 -04:00
bvandeusen e6da720e6b packaging: drop assets that aren't there, instead of trusting nullglob
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m34s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m42s
Desktop (Tauri) / Update manifest (push) Successful in 5s
`d77a798` added the Android client to publish-release.sh's asset list and broke
the desktop lane's publish, which had been working (run 4094, curl exit 26 —
"couldn't read local file"). The Android lane published fine, which is what made
the shape of the mistake clear.

`shopt -s nullglob` drops PATTERNS that match nothing. The two entries I added —
`android/dist/thoughtsync.apk` and its sidecar — contain no wildcard, so they are
not patterns at all: globbing leaves them in the array verbatim and curl is handed
a path to a file that does not exist. In the Android job those files are there, so
it worked; in the desktop job they never are, so it did not.

Every entry is now filtered on existence, which is what the array has always
meant. That covers the literal paths and the globs alike, rather than relying on
each future entry containing a `*` to be safe — the trap that just cost a run.

Verified both ways before pushing: a literal missing path survives nullglob and is
removed by the filter, and an all-empty result still exits cleanly under `set -u`.
2026-08-20 20:20:58 -04:00
bvandeusen d77a79859c server: hand out the Android client this server syncs with (2726)
CI & Build / Python tests (push) Successful in 11s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Build & push image (push) Successful in 50s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 3m8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 5m32s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 8m8s
A self-hoster should not need an account on someone else's forge to get the app
for their own notes. The Fabled-Git instance is private — which is why
`install.sh` already cannot fetch for anyone but the operator — so a release page
is no use as a distribution point. The server holding the notes is something the
person already trusts and already reaches.

It also keeps the pair in step by construction. Client and server negotiate a
sync protocol version before linking, so a server that also serves the client
cannot hand out a phone it is unable to talk to.

**Two files, and both must be present**: `thoughtsync.apk` and a
`thoughtsync-android.json` sidecar carrying `{version_name, version_code, size,
sha256}`. The sidecar exists because an APK keeps its version in a binary AXML
manifest, which Python cannot read and which is not worth putting `aapt` on a
Quart server to reach. CI writes it beside the APK, where the values are already
known — including the digest, computed over the same bytes it uploads, so a
phone can tell a truncated download from a complete one before handing it to the
installer. Not a trust anchor; the signature is that.

**Under DATA_DIR, not baked into the image.** Baking charges ~55 MiB to every
self-hoster including everyone who never touches Android. `/var/thoughtsync` is
already the mounted volume that holds attachments, so a build dropped there
survives container recreation.

**Absence is an ordinary state, not an error.** No APK means the key is absent
from `/api/config` — absent rather than null, so a client testing for it cannot
confuse "this server has no client" with "this server predates the field" — the
web UI hides the card instead of offering a button that 404s, and the metadata
route answers 404. A server whose owner does not use Android is not misconfigured.

**A mismatched pair also counts as no client.** If the sidecar's recorded size
does not match the file on disk, the two did not arrive together; serving one
build while advertising another is worse than serving none, because the phone
would compare versions against a promise the bytes do not keep. That makes the
copy order in docs/android-distribution.md load-bearing, and it is written down
there: APK first, sidecar last.

**The version is public, the bytes are not.** An updater has to be able to ask
"is there something newer?" cheaply and before it has done anything; 55 MiB is
not for anyone who can reach the port. `login_required` already accepts either a
session cookie or a device bearer token, so the browser and a linked phone both
work with no second auth path.

The Android lane now publishes both files to the same rolling `dev` release the
desktop bundles use, reusing `publish-release.sh` — its nullglob asset list was
already built for several jobs in separate workspaces publishing to one release,
which is exactly this. Signed builds only: publishing an unsigned APK would offer
people something they cannot install over what they already have.

Nine tests, DB-free like the rest of the suite — this lane runs no Postgres, so
the advertisement is asserted through `advertisement()` rather than through
`/api/config`, whose other half needs a database. Both routes ARE exercised,
because neither opens a session.
2026-08-20 20:11:32 -04:00
bvandeusen 6589be2b0f android: unit tests are a debug-only task, and the artifact name says variant
Android / Kotlin + Rust (APK) (push) Successful in 6m52s
Run 4082: `Task 'testReleaseUnitTest' not found`. AGP creates unit-test tasks
only for `testBuildType`, which is debug — so pairing the test task with the
packaged variant was wrong from the start.

It was only paired to stop two Gradle invocations asking for different Cargo
profiles and paying the four-minute cross-compile twice. With the profile pinned
to debug (#2810) that reason is gone, so the step goes back to `testDebugUnitTest`
unconditionally. Costs one extra Kotlin compile and buys the type-check on the
variant an emulator build would actually use.

Also: the artifact was named from the Cargo profile, which is now always "debug"
— so a signed release APK would have been uploaded as
`thoughtsync-android-debug-<sha>`. Same word, two different things. It is named
from the APK's variant now, and the two outputs are kept separate so they cannot
be confused again.
2026-08-20 19:23:53 -04:00
bvandeusen cae9888eb9 android: build the release APK with a debug-profile .so, for now
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m0s
Android / Kotlin + Rust (APK) (push) Failing after 4m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m29s
Desktop (Tauri) / Update manifest (push) Successful in 5s
`d0a9c73` switched the lane to a release Cargo profile alongside the release
variant. The variant was the point; the profile was mine, and it broke the build
(run 4077): `generateUniffiBindings` fails with "No UniFFI metadata found" on
the release `.so`.

The workspace release profile sets `strip = true`, and uniffi's `--library` mode
finds its interface metadata through symbols. That is the obvious suspect and it
is recorded as a suspect, not a finding — `lto = true` dropping the metadata
statics would print the identical message and the two have not been told apart.

Backed out to the debug profile rather than guessing at a fix, because the two
halves of that commit are not equally important. Signing and a rising
versionCode are what make an install replace the last one instead of wiping the
notes; the Rust profile only makes the result faster. The APK this produces is
no worse than every previous build, all of which shipped a debug-profile `.so`.

Recorded as Scribe #2810 with the four candidate fixes and, more usefully, the
instruction to establish the cause on a host build before spending another
four-minute cold cross-compile on a guess.
2026-08-20 19:16:03 -04:00
bvandeusen d0a9c73bf9 android: sign the release build, and give it a version that rises
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m54s
Android / Kotlin + Rust (APK) (push) Failing after 4m10s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m15s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Two separate reasons updates were impossible, both fixed here.

**Every CI build was signed with a different key** (issue #2803, measured with
`apksigner --print-certs` across two runs). No signing config meant AGP's debug
keystore, which AGP GENERATES when absent — and every job starts from a fresh
container. So no build could ever be installed over another: the only way
through was uninstall-then-install, which deletes the app's database and every
local note with it.

**versionCode was hardcoded to 1.** `build.gradle.kts` has read a
`THOUGHTSYNC_VERSION_CODE` property since the skeleton landed; nothing ever
passed it. Even with signing fixed, every APK would have claimed to be the same
version and nothing could tell a newer one existed. It now comes from
`GITHUB_RUN_NUMBER` — the same monotonic counter the desktop's version scheme
already uses, needing no state between runs and immune to the shallow checkout
that makes a commit count useless here. The version NAME comes from the
desktop's `build-version.sh`, so both surfaces report one product version rather
than two that can disagree.

**The alias is hardcoded, not a secret.** It is fixed for the life of the app and
already written into the certificate every install carries; hiding it would buy
nothing and stop this file describing its own signing. Two secrets, not three —
and PKCS12 cannot hold a key password distinct from the store password anyway,
so `keyPassword` is the same value by necessity rather than by shortcut.

**The lane now builds RELEASE when it can sign, debug when it cannot.** That is
not cosmetic. A debug APK is `debuggable`, which on a phone holding personal
notes and a device sync token means anyone with adb can read both.

Which meant confronting something the release path would have shipped quietly:
`cargoNdkDebug` was hardcoded to the debug Cargo profile and every variant took
its `.so` from it, so `assembleRelease` would have packaged an UNOPTIMISED store
and sync engine. Now one `cargoNdk` task takes its profile from a property, and
the whole run uses one profile. A debug/release task pair would have been the
tidier shape and would have made a run that both type-checks and packages pay
the four-minute cross-compile twice — this runner has no working Gradle or Cargo
cache, so that cost is real on every push.

The run prints the signing certificate after assembling, so the fingerprint can
be compared against the one recorded at generation. Signing with the wrong key
produces a perfectly valid APK that simply refuses to install — a failure that
otherwise surfaces on the device, long after the run is green.

`.gitignore` learns `*.jks`, `*.keystore`, `*.p12`, `*.b64` first, so generating
a keystore anywhere near this tree cannot go wrong.

Also corrects the record: the comment this replaces cited "Scribe task 2136" as
though it were a standing rule. It is not one — none of the 46 always-on rules
mentions signing keys. 2136 is a desktop-updater task whose REASONING got
repeated until it sounded like policy. The reasoning holds, and holds harder on
Android where a key cannot be rotated without the original, so the practice is
unchanged; the citation is now honest about what it is.
2026-08-20 19:03:57 -04:00
bvandeusen f38864088b core: a completed recurring reminder advances instead of ending
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m19s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m37s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (debug APK) (push) Successful in 8m9s
`complete_reminder` cleared `remind_at` and said so in its own comment —
"(Recurrence advancement is a later refinement.)". So Done on a daily reminder
was quietly the last time it ever fired. Reminder notifications made that much
easier to hit, because Done is now a button in the notification shade.

**The server already had this.** `src/thoughtsync/notes/recurrence.py` has done
it correctly all along, which means the web behaved one way and the desktop and
Android the other, on the same note, in the same account. This is a port of that
file rather than a fresh implementation, kept behaviourally identical rather than
merely similar: the same reminder can be completed from a browser or a client,
and a disagreement would move it depending on which one you happened to use.

The seven new tests in `core/src/local/recur.rs` mirror the Python suite case for
case, including the one that matters most in practice — 31 January plus a month
is 28 February, and the step after that is 28 March rather than back to the 31st.
That clamp is sticky, and it is now asserted on both sides so a future "fix" to
either has to change both.

Advancement is measured from the reminder's own time, never from now, which is
what keeps a 09:00 daily reminder at 09:00 when it is dealt with at 09:47. A
phone left in a drawer for a fortnight rolls forward to tomorrow rather than
arriving at fourteen pending occurrences of the same thing.

Also matched from the server, and a latent bug of its own: the non-recurring
branch now clears `recurrence` as well as `remind_at`. Before, completing a note
that carried a rule left the rule behind with no reminder attached — invisible in
every UI, since they only render recurrence when there is a reminder to recur
from, and waiting to surprise whoever next set a time on that note.

Documented rather than hidden, and shared with the server: the arithmetic is in
UTC and a note carries no timezone, so a daily reminder crossing a DST boundary
keeps its UTC time and shifts by an hour locally. Fixing that means a zone per
note, which is a wire-format change.

Verified in the CI image before pushing: fmt, clippy --all-targets -D warnings,
and the full suite — core 89 to 96, ffi 11 to 12. The new FFI test walks the path
the notification's Done button actually takes.
2026-08-19 21:22:22 -04:00
bvandeusen 8f13dc2e2c android: restore the dismiss I deleted, and teach the checker to see it
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m27s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m10s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (debug APK) (push) Successful in 7m4s
`785ebdb` failed at compileDebugKotlin with two `Unresolved reference 'dismiss'`.
Splitting the reminder notification code into its own object, I removed
`dismiss` from `Reminders` and never pasted it into `ReminderNotification`. The
call sites were correctly qualified; the function simply was not there.

All four local gates passed it, and `check-symbols.py` passed it for a reason it
documented about itself: it only resolved the LEADING segment of a dotted
expression, because that is the part a regex can resolve. `ReminderNotification`
existed, so `ReminderNotification.dismiss(...)` looked fine.

That was a real gap rather than an inherent one, so the checker now indexes the
members of every `object` declared in the package and verifies `Foo.bar` against
them. Brace-counted, not regex-matched — an object body is full of nested braces
from lambdas and apply blocks, and no regex closes correctly over them.

Verified by deleting `dismiss` from a copy of the tree again: it reports the
same two call sites the Kotlin compiler did. What it still cannot see is
narrowed and written down rather than left implied — members of anything
declared outside this package, members reached through a variable rather than a
type name, and every question about types.
2026-08-19 20:13:43 -04:00
bvandeusen 785ebdba59 android: reminders that actually reach you (M12 step 6)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m0s
Android / Kotlin + Rust (debug APK) (push) Failing after 5m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m37s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Reminders have been settable since the editor landed and have never once gone
off. The board showed them overdue in red, which tells you what you already know
by the time you are looking at the board.

**AlarmManager, not WorkManager.** The background sync is right to be on
WorkManager — nobody minds whether it runs at 3:05 or 3:19. A reminder minds
very much. WorkManager's periodic floor is fifteen minutes and it batches into
maintenance windows, so "remind me at 09:00" would routinely arrive at 09:14,
which is not a reminder, it is a rebuke.

**One alarm, not one per reminder.** Only the earliest future reminder is ever
scheduled; when it fires, everything due is announced and the next is scheduled.
A hundred reminders cost one alarm, and there is no incremental bookkeeping to
drift — `Reminders.refresh` recomputes the whole picture from the store, and is
called from everywhere anything could have changed: an edit, a foreground, a
background sync, boot, and an app update.

Boot and MY_PACKAGE_REPLACED both matter and both are easy to forget. Pending
alarms survive neither, and this app updates by APK from its own server, so
without that receiver a phone would silently stop reminding anyone of anything
after a restart — the worst kind of failure, because nothing appears wrong.

**Neither permission is treated as a prerequisite.**

SCHEDULE_EXACT_ALARM, not USE_EXACT_ALARM: the latter is granted at install with
no prompt and is reserved for apps whose whole purpose is an alarm clock or a
calendar, which this is not. Refusing the former costs precision, not the
feature — it falls back to an inexact alarm, because a reminder a few minutes
late beats no reminder.

POST_NOTIFICATIONS is asked for on the first launch where a reminder actually
exists, never at launch on an empty board. Android gives an app essentially one
chance at that dialog, and spending it before the person has any idea what this
app would send them is spending it on nothing. For anyone who refuses, or who
turns notifications off later in system settings, the Reminders view carries a
standing notice with a button to the right screen — a feature that silently does
nothing is worse than one that is plainly absent.

**A first run adopts overdue reminders silently.** The storm case is linking a
server and pulling months of history; a hundred notifications the moment someone
signs in is a good way to have the feature turned off before it is ever useful.
After that, a missed reminder is announced up to a day late — the web uses
fifteen minutes because an open tab has been polling every forty-five seconds,
but a phone can be switched off all night.

Done and Snooze act from the shade without opening the app. The dedupe key is
note id plus remind_at, the same one the web store uses, so snoozing produces a
new occurrence rather than one already dealt with.

Tapping a notification opens that note. The extra is CONSUMED when read: the
Activity keeps the intent it was launched with, so without that, rotating the
phone would replay it and reopen a note the person had already closed.

`Reminders` split into scheduling policy and `ReminderNotification` rendering
after detekt counted fourteen functions in one object — it was right, they answer
different questions and change for different reasons. `ForegroundTransitions`
moves to the ui package; the reminder notice needs it to re-read a permission the
person may have just changed in a system screen this app cannot observe.

Known gap, pre-existing and shared with every surface: `complete_reminder` in the
core clears a reminder without advancing recurrence — its own comment says so.
So tapping Done on a daily reminder ends it rather than moving it to tomorrow.
Not changed here because it is core behaviour the desktop and web also have, but
notifications make it much easier to hit, and it should be next.
2026-08-19 20:06:05 -04:00
bvandeusen 39170b715c android: leaving the composer keeps the note, and the board loses its dead space
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m5s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m23s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (debug APK) (push) Successful in 7m15s
Two things the operator hit on a real device.

**Capture threw work away.** Every exit from the compose sheet except Save
discarded it — tapping the board behind, swiping down, back, backgrounding the
app, and rotating the phone. That is the wrong default anywhere and the worst
possible one here: a sheet that loses a typed thought because you touched
outside it teaches people not to trust the app with a thought, and capture is
the one place this product cannot afford that.

Now every way out saves, which is the shape the editor already settled on. The
difference is that capture also has to be abandonable — tapping + and changing
your mind is normal — so Discard exists and is the only path that loses
anything. It is called Discard rather than Cancel because "cancel" means "undo
what I am doing", which is precisely what leaving no longer does; the word would
have described the one button it is not attached to. An empty draft needs
neither and is simply dropped: a blank note nobody asked for is worse than none.

Backgrounding persists but does NOT close an empty sheet. Someone who tapped +
and got distracted should find the composer where they left it.

Rotation was losing it twice over: the draft was `remember`, and so was the flag
saying the sheet is open. Both are `rememberSaveable` now, along with the sync
screen's — the editor never had the bug because the note it sits on lives in a
view model, and these were the only screen state that did not.

`FlushOnStop` moves out of NoteEditorScreen into its own file; the editor and
the capture sheet want the identical thing for the identical reason, and it was
about to be copied.

**The board had a centimetre of nothing above the search field.** `SearchBar`
applied `statusBarsPadding()` inside a `Scaffold` whose content padding already
carries the system-bar insets — `ScaffoldDefaults.contentWindowInsets` is
`systemBarsForVisualComponents`, checked in the material3 sources rather than
assumed. So the status bar height was reserved twice on the first screen anyone
sees. Insets get consumed once, by whichever component owns the edge.
2026-08-19 19:39:40 -04:00
bvandeusen 5680f046e3 android: name all four permissions WorkManager adds, not one
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m39s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m59s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (debug APK) (push) Successful in 7m4s
The note added with the previous commit said RECEIVE_BOOT_COMPLETED arrives in
the merged manifest via WorkManager. True, and incomplete — it brings four:
RECEIVE_BOOT_COMPLETED, ACCESS_NETWORK_STATE, WAKE_LOCK and FOREGROUND_SERVICE.

A comment whose whole job is "here is why the permission list has entries this
file does not declare" fails at that job if it accounts for one of them. Each
now says what it is for, checked against the built APK's merged manifest rather
than the library's — which is the version a person actually sees.
2026-08-19 19:11:19 -04:00
bvandeusen 452c66c8ef android: sync without being asked (M12 step 6)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m15s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (debug APK) (push) Canceled after 6m32s
Until now every sync was a button press. Pull-to-refresh made asking cheaper; it
did not stop the app needing to be asked, which on a phone means a note written
on the bus reaches the desktop whenever you next happen to open the app.

Three moments, and they are deliberately not the same job:

  * **Coming to the front**, if the last sync is over five minutes old or there
    is unsent work. Not on every foreground: stepping out to copy a link and
    stepping back is not a request for fresh notes, and syncing on every app
    switch spends someone's mobile data telling them what they are looking at.
  * **Going away with unsent work** — handed to WorkManager rather than run
    inline, because the process is about to stop being a priority and a sync
    started there would be killed halfway. This is the one that matters most: it
    is what gets a note off a phone that then goes into a pocket for the night.
  * **Every fifteen minutes**, network-constrained. Fifteen is not a preference,
    it is WorkManager's floor for periodic work; asking for less gets fifteen.

**An automatic sync must not raise an error banner.** Someone who pulled the
board down is owed an answer; someone who merely opened the app did not ask a
question, and answering it with a red banner about an unreachable server makes
their own notes look broken when nothing of theirs is. So `syncNow` and
`syncQuietly` differ in exactly one thing — whether failure is announced. The
quiet channel for a persistent problem is the drawer badge, from `has_pending`,
which does not care how the attempt was made.

**There is a switch, defaulting to on.** Linking a server IS the consent; a
person who paired a device and then had to find a second toggle before anything
moved would reasonably call that broken. It lives in SharedPreferences rather
than the store: everything else in sync state describes the PAIRING and must
survive a reinstall, while this describes how one handset behaves, and someone
turning it off on their phone is not asking their laptop to stop. The copy says
what "automatically" means in minutes and says that off is not off — a switch
next to a Disconnect button invites exactly that misreading.

The schedule is DECLARED as a function of (linked, switch) in a LaunchedEffect
rather than toggled from the places that change them. There are four routes to
"should not be syncing on its own" and a call at each is four chances to leave a
phone quietly syncing after it was told to stop.

`ON_START`/`ON_STOP`, not resume/pause — the same choice the editor's save-on-
leave makes, because pause fires for anything covering the window and a sync per
notification-shade pull is not automatic sync, it is a stutter.

RECEIVE_BOOT_COMPLETED now appears in the merged manifest. WorkManager
contributes it so the schedule survives a restart; commented in AndroidManifest
because it shows in the app's permission list and nothing else in that file
would explain it.

Two things read from artifacts rather than recalled, both of which memory would
have got wrong: `work-runtime-ktx` is an empty 6 KB stub as of 2.11 with
`CoroutineWorker` and `PeriodicWorkRequestBuilder` moved into `work-runtime`, so
the dependency is on the latter alone; and `Switch` is not experimental in
material3 1.4.0, so no `@OptIn` — an unnecessary one is itself a warning.

Also adds `android/tools/check-strings.py`, after this change added three
strings: `R` is generated, so `R.string.typo` type-checks whether or not the
string exists. It catches a missing name, `stringResource` on a plural or the
reverse, and a format taking more arguments than the call passes. Verified
against a tree with one of each fault — its first version counted Kotlin's
trailing commas as arguments and called three correct sites broken, which is the
failure that teaches you to ignore a tool.

Two comments in this change were wrong when written and are corrected here
rather than left: the flag check in SyncWorker does NOT avoid opening the store,
because Application.onCreate has already run by the time any Worker starts.
2026-08-19 19:04:45 -04:00
bvandeusen 64542ed6cb android: pull the board down to sync (M12 step 6)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m18s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m35s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Android / Kotlin + Rust (debug APK) (push) Successful in 7m56s
Every sync so far has been a button press on a screen you have to navigate to.
On a phone the gesture for "check if there's anything new" is a pull, and not
having it is the kind of absence people read as the app not syncing at all.

**The gesture is INERT when this device has no server.** `Modifier.pullToRefresh`
takes an `enabled`, which is why the modifier and the indicator are wired by hand
instead of using `PullToRefreshBox` — that wrapper is less code and offers no way
to turn the gesture off. An unlinked device has nowhere to pull from, and a
gesture that always comes back empty is how people learn a control is broken.
Same reasoning as the drawer badge staying silent when unlinked: local-only is
this app's resting state, not a fault.

**A failed refresh reaches the board.** Otherwise the spinner retracts and
nothing happens, which is indistinguishable from "you were already up to date" —
the one outcome it must not be confused with. It renders as a second banner
rather than replacing the store-error one: those are different facts about
different halves of the app, and hiding either behind the other reports the
wrong problem. Dismissing is honest — the note is still pending, `hasPending`
still says so, and the next cycle reports the same fault if it persists.

**The empty board is now a `LazyColumn` holding one centred item.** Pull-to-
refresh works through nested scroll, and a layout that never scrolls never
dispatches any, so on the old plain `Column` the gesture would have been dead on
exactly the screen where it matters most: linked, board empty, notes still on the
server. Looks identical.

The five sync facts the board needs arrive as one `BoardSync` rather than five
parameters, for the reason `EditorAction` exists: `summary` and `error` are both
`String?` and both about sync, so positionally they could be swapped with nothing
to catch it.

Still no automatic sync — no background cycle, no sync-on-resume. This is a
faster way to ask, not a decision to stop asking. TalkBack users cannot perform
a pull; the drawer's Sync → Sync now remains the accessible path, unchanged.

Verified against the real artifact rather than from memory, since `material3`
resolves through the BOM: 1.4.0's sources confirm `pullToRefresh` has `enabled`,
and that none of `pullToRefresh`, `rememberPullToRefreshState`, `Indicator` or
`PullToRefreshBox` is `@ExperimentalMaterial3Api` there — only two deprecated
members are. So no `@OptIn`, which is what keeps the build at zero warnings.
2026-08-19 16:36:56 -04:00
bvandeusen 65d8f5f9c6 android: the import ktlint and detekt cannot see
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m31s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m6s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (debug APK) (push) Successful in 7m26s
`750d11d` failed CI at `compileDebugKotlin` with `Unresolved reference 'Build'`.
`defaultDeviceName()` reads `android.os.Build`, and the import was lost when
`SyncPairing.kt` was split out of `SyncScreen.kt`. One line to fix.

The interesting part is that ktlint and detekt had both passed it, locally and
in CI. Neither resolves symbols — they parse — so a file that cannot compile is
indistinguishable to them from one that can. A clean analyzer run is not
evidence the code builds, and on this repo `compileDebugKotlin` is the only
gate that type-checks at all, since there is no Android SDK on the workstation.

So: `android/tools/check-symbols.py`, covering that one blind spot. It flags any
capitalised identifier that is neither imported, declared in the same package, a
type parameter, nor implicitly available. Not a type checker and not pretending
to be — a pre-push filter for the single mistake that survives every other local
gate, erring toward false positives.

Verified against a known-bad tree rather than trusted on a green: deleting the
`Build` import from a copy makes it fail with the same two references the Kotlin
compiler reported. That step is not ceremony. An earlier attempt at this check
stripped line comments with `re.S`, where `//.*` eats each file from its first
comment to EOF — it examined almost nothing and reported everything clean.

ci-requirements.md now documents all three Kotlin checks, and its claim that no
workflow consumes the Android image yet is gone; the lane has been running since
step 5.
2026-08-19 15:51:12 -04:00
bvandeusenandClaude Opus 5 750d11d32e android: connect a server from the phone (M12 step 6)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m0s
Android / Kotlin + Rust (debug APK) (push) Failing after 4m57s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m21s
Desktop (Tauri) / Update manifest (push) Successful in 4s
The plumbing has been bound since step 4 — probe, link by password or token,
unlink, sync — with nothing on top of it. Until this commit the phone was a
good standalone notes app that could not be the SAME notes as the desktop,
which is the point of the project.

Structurally a port of the desktop's SyncView.vue: same probe-then-link order,
same copy wherever the copy was already right. The two surfaces pair with the
same servers, and a difference in wording here would read as a difference in
behaviour.

BEING UNLINKED IS NOT A PROBLEM, and the screen is written around that. It
leads with "Working offline on this device" and says what connecting would
ADD. A local-first app that frames its resting state as unfinished setup is
lying about what it is. The drawer badge follows the same rule: it says
nothing at all when unlinked, rather than "Off".

Probe before credentials. A typo that reaches a stranger's server should cost
a round trip, not a password — so the address is checked first, what answered
is shown (name, version, compatibility), and only then does a sign-in form
appear. An incompatible server never gets one; the core would refuse the link
anyway, and collecting a password to throw away is worse than not asking.

CLEARTEXT IS NOW PERMITTED, deliberately and not silently. Android blocks
plain http from API 28, and the core explicitly supports a self-hosted server
on a LAN — `http://192.168.1.10:8000` is a case it has a test for. The
platform default would make this app unusable for exactly the people it is
built for, with a transport error they could do nothing about. A
network-security-config would be tighter in principle but matches domains and
IP literals, not CIDR ranges, so it cannot express "my own network". The other
half of the trade is a warning that appears the moment a probed address starts
with http:// and BEFORE any credential field: anyone on the same network can
read your password and your notes.

Credentials never enter the view model. The address, email and device name are
`rememberSaveable` so a rotation doesn't cost a retype; the password and the
token are plain `remember` on purpose — rememberSaveable persists into the
instance-state bundle, and a secret has no business being written there to
save four seconds of typing. They reach the core as a `Credentials` sealed
type and die with the composable.

That sealed type also fixed a bug detekt surfaced by complaining about a
six-parameter function: `link_with_token` takes NO device name (the token was
already minted against a named device in the web app), so the flat argument
list meant the form collected one in token mode and silently dropped it. The
field now exists only on the password path.

Threading, which differs by call and is easy to get wrong in one direction:
probe / linkWithPassword / linkWithToken / unlink / syncNow are Rust async
through uniffi, so Kotlin sees suspend functions already driven by tokio and
awaits them directly — wrapping them in Dispatchers.IO would park a thread to
wait on something that never blocks one. syncStatus and hasPending are
ordinary blocking FFI into SQLite and do need it.

A sync that changed anything tells the board to reload, because a pull can
have rewritten every note it is holding. Wired explicitly at the one place
that owns both view models rather than through a shared event bus. A no-op
sync deliberately does not, so the board never flashes its loading state for
nothing.

Sync results are kept RAW in state and turned into sentences in the UI, where
stringResource is in scope — the same split Time.kt draws for timestamps. The
summary counts what MOVED; batches, pages, noop and cursor are all real
numbers and none of them answer "are my notes in step". Rejections are
surfaced rather than swallowed: only a person can resolve them. So is a revoke
that didn't land — someone disconnecting to retire a phone has to be told a
live credential is still out there, and has to still find it when they come
back to check, so it is a persistent notice and not a toast.

Also here: `Panel`/`Notice` extracted as shared tinted chrome, drawn from the
same note palette the cards use rather than Material's errorContainer, so a
warning is the same yellow a note can be. `PlainTextField` gained a visual
transformation for the password field. `formatReminder` became `formatInstant`
now that "last synced" reads it too.

Verified locally per ci-requirements.md: ktlint and detekt clean in
ci-rust-android:1.97, uniffi bindings generated from a host build and read to
confirm ULong on the summary counters, `Compatibility.Ok`/`RevokeOutcome.
Unsupported` being objects, and all five sync calls being suspend. Every
R.string/R.plurals reference cross-checked for existence, kind and format
arity. A symbol-resolution pass over the whole package caught a composable a
bad edit had deleted — ktlint and detekt both parse without resolving, so
neither could see it.

Not done: no automatic sync. The desktop is manual-only too, so this is parity
rather than a gap, but pull-to-refresh on the board is the obvious phone-native
follow-up.

Worth an operator decision, not changed here: allowBackup is still true, so
Android's cloud backup now includes a device token as well as the notes. Good
for restoring to a new phone, and a wider blast radius than before this commit.

Scribe #2777

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 15:41:02 -04:00
bvandeusenandClaude Opus 5 cf0ce382a0 android: the note editor (M12 step 6)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m0s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m26s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (debug APK) (push) Successful in 7m25s
Tapping a card now opens something. Until this commit the phone could create,
find and navigate; it could not change anything.

A FULL SCREEN, not a sheet. Capture is a sheet because the board behind it is
reassurance that the thought landed; editing is a sustained task with the
keyboard up, and a sheet would spend the whole time fighting the IME for the
bottom half of the display. Full screen also puts the actions in a bottom bar,
which is where a thumb already is. The note's colour paints the whole screen,
so opening one reads as the same object growing to fill the display.

Text saves ONCE, on close — plus on ON_STOP, so app-switching mid-paragraph
doesn't lose it. Not debounced autosave: the core snapshots a revision on every
title/body change, so saving per typing pause would fill version history with
near-identical entries. A baseline check means opening a note and backing out
writes nothing at all, rather than bumping updated_at and marking it dirty for
sync. Same shape the web editor settled on, for the same reason.

The editor speaks in ACTIONS, not callbacks. The first version passed a bundle
of twenty lambdas and the doc comment on it was already worrying about two of
the same-shaped ones getting swapped, with nothing to catch it. `EditorAction`
plus one `(EditorAction) -> Unit` costs a `when` at the far end and buys
exhaustiveness: adding a variant breaks the dispatcher until it is handled.

Checklist rows are live here — real checkboxes, editable text, remove, and an
add row that keeps focus so a list types straight through. That is the answer
to the open question about list entry: the capture sheet stays one-item-per-
line because at capture time the list is already in your head and a tap per row
is the slow part; the editor is where a list is REVISED, and revising is
item-at-a-time. Row text commits on focus loss, not per keystroke — each commit
is a store write that reloads the note.

Colour, labels and reminders are bottom sheets. Reminders lead with presets
(later today / tomorrow / next week) and keep the exact picker one tap down:
the web's raw datetime-local is right for a desktop and three taps too many for
the common case on a phone. Recurrence only appears once there is a reminder to
recur from. The date picker reports UTC midnight of the calendar day tapped and
is read back in UTC — reading it in the device zone is the classic off-by-a-day
in that control.

Pin, labels, archive and delete live in the overflow as WORDS.
`material-icons-core` has no pin, archive or label glyph, and the alternatives
were pulling in the ~1,000-vector extended set for four icons or pressing
unrelated ones into service — a star meaning "pin" is a star meaning "favourite"
to everyone who has used another app. The colour button is a dot in the note's
current colour, which says what the colour IS as well as what the button does.

A trashed note renders read-only. Editing one would silently resurrect work
that was meant to be thrown away; Restore and Delete forever are the only
things to do with it. Deleting for good is the one irreversible action in the
app and gets the one confirmation in it.

`#tag` labels are never sent to `set_labels` and get no remove button. They are
owned by the body text and the core re-derives them on the next edit, so a
cross that undid itself a second later would look broken.

FFI additions: delete_note_forever, add_item, set_item_text, set_item_checked,
delete_item, complete_reminder, snooze_reminder, set_note_labels, create_label.
`set_item_text`/`set_item_checked` are split rather than exposing the core's
{text?, checked?} patch, for the same reason NoteEdit is a list — an
optional-field struct cannot say "leave this alone" in Kotlin without colliding
with "set it to null". Four new tests (11 total in the crate).

Found while extracting shared helpers: the card painted EVERY reminder blue,
so "you missed this" and "coming up Friday" looked identical. Now red when
overdue and neutral otherwise, matching the web card's exact pairs. And the
error banner was renderable only by the board — the one screen that needed it,
where the writes happen, was the one screen without it.

DRY, since three copies each had appeared: PlainTextField (the undecorated
field used by capture, editor, checklist rows and the search bar), Time.kt (the
RFC3339 seam), NoteKind.kt, ErrorBanner.

detekt: LongMethod and LongParameterList now ignore @Composable. Compose breaks
those rules' PREMISE, not just their thresholds — a composable's parameters are
its UI contract and its length tracks how many elements are on screen, not
branching. Two suppressions carry their reasoning at the site instead:
onEditorAction is sixty lines because EditorAction has twenty variants, and
splitting it would need an `else` that throws away the exhaustiveness; and
BoardViewModel stays one class because every editor mutation has to reload the
board behind it.

Verified locally before pushing, per ci-requirements.md: fmt/clippy/test in
ci-tauri:1.97 (89 + 11 + 11 tests, four crates present), ktlint and detekt in
ci-rust-android:1.97, uniffi bindings generated from a host build and read to
confirm every method and field name the Kotlin calls.

Still unbuilt: attachments, link previews, version history, and label
management (rename/recolour/delete). Setting up a server from the phone is next.

Scribe #2777

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:18:24 -04:00
bvandeusenandClaude Opus 5 64e016f32d android: phone-shaped chrome and the real note card (M12 step 6)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m47s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m52s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (debug APK) (push) Successful in 7m2s
Two things at once, because they answer one question: what should this look like,
and what should it look like ON A PHONE.

IDENTITY IS SHARED, INTERACTION IS NOT. The card now renders exactly what the web
and desktop render — note colour, checklists, label chips, reminders — using the
same palette values, so a note looks like your note on every surface. The chrome
does not: the desktop's title bar and sidebar are wrong for a thumb.

  * NoteTint.kt carries the Tailwind colours from frontend/src/notes/colors.ts
    VALUE FOR VALUE, generated from tailwindcss 3.4 rather than eyeballed. Dark
    tints keep the web's alpha (dark:bg-*-950/40) instead of a precomputed blend,
    because Compose composites translucency over the background exactly as CSS
    does.
  * Dynamic colour is GONE. It was the more Android-native choice and it made the
    app look like a different product — on a stock emulator with no wallpaper it
    renders as undifferentiated grey, which is what the operator saw. Three peer
    surfaces share one identity; the brand #F5C518 is the same value the web
    manifest and the launcher icon already use.
  * The board is a two-column staggered grid, the Compose equivalent of the CSS
    multi-column NoteGrid.vue uses.

PHONE ERGONOMICS, chosen with the operator:
  * Search IS the top bar. After writing a note, finding one is the most common
    thing you do, and burying it behind an icon costs a tap every time. Debounced
    180ms and cancelled per keystroke — without that a fast typist queues one
    full-text query per character and results land out of order.
  * A + button is the only way in. One obvious target beat a capture bar and a
    button competing for the same job.
  * Navigation moved into a drawer behind the search bar's menu icon, which is
    where archive/trash/labels/reminders now live. They had nowhere to go once
    search took the top bar, and would otherwise have been unreachable.
  * The compose sheet asks note-or-list up front. On a phone those are different
    typing tasks and switching halfway is worse than choosing at the start. A
    list takes one item per line — fast to type, versus a tap per row.

Three new bindings the UI needed: search_notes, reminder_notes, list_labels.
Search goes through the CORE so "what matches" cannot drift between surfaces;
filtering the loaded list in Kotlin would have been less code and a different
product. reminder_notes is its own call because the core models it that way —
"has a reminder" cuts across archived and active alike.

Empty states are per-destination. "Nothing here yet" is encouraging on an empty
board, wrong in Trash, and misleading after a search where the notes exist but
did not match.

Verified locally before pushing: bindings generated from a host .so and read back,
ktlint and detekt clean from the image's pinned CLIs, cargo fmt/clippy/test green
(107 tests). Two detekt findings were fixed by extraction rather than by relaxing
the rules — this is the first Compose code in the repo and the thresholds should
have to earn their exceptions.

Still unbuilt: tapping a card does nothing. The editor is next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 09:23:00 -04:00
bvandeusenandClaude Opus 5 eb3dc3d893 gitignore: don't let a downloaded APK into history
Debug APKs get pulled into the working tree for emulator testing. They are ~57 MB
and come from CI artifacts, so they are never a source — but nothing stopped
`git add -A` from committing one permanently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 08:31:44 -04:00
bvandeusenandClaude Opus 5 c8af808432 android: package only the ABIs we actually build for
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m11s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m12s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (debug APK) (push) Successful in 7m13s
The first working APK carried libjnidispatch.so for armeabi, mips and mips64 as
well as our four — JNA's .aar still ships those, and AGP packages whatever it
finds. Android dropped mips in NDK r17 and armeabi in r17 too; nothing that can
install this app can load them, so they are pure payload.

abiFilters pins the set to the four the Rust is actually cross-compiled for, so
the APK's ABI list matches the build's intent rather than the union of every
dependency's history.

Found by unpacking the artifact rather than trusting the green: the run said
"Upload debug APK ✓", which is true and says nothing about what is inside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:24:37 -04:00
bvandeusenandClaude Opus 5 5eab2dd0b3 android: the error enum has to be flat, or the bindings don't compile
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m30s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m9s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (debug APK) (push) Successful in 7m9s
Fifth run cleared ktlint and detekt and failed compiling the GENERATED Kotlin:

  'message' hides member of supertype 'Throwable' and needs an 'override'
  modifier

My design, surfacing one layer down. CoreError's variants carried a `message`
field, and uniffi turns an error enum into exception classes extending
Throwable — which already has `message`.

`#[uniffi(flat_error)]` is the right fix rather than renaming the field.
Renaming would dodge the collision and leave `e.message` null on the Kotlin side,
so every call site would have to know which variant it caught just to read the
text. Flat passes the Display string to the Throwable constructor, where Kotlin
expects it, and costs nothing that matters: each variant is still its own
subclass, so `catch (e: CoreException.NotLinked)` still works and a `when` is
still exhaustive. Only the fields stop crossing, and for every variant that has
one the field IS the Display string.

Confirmed by generating the bindings and reading them:

  sealed class CoreException(message: String): kotlin.Exception(message) {
      class NotLinked(message: String) : CoreException(message)
      class Store(message: String)     : CoreException(message)
      class Network(message: String)   : CoreException(message)
  }

That check is worth keeping. thoughtsync-ffi already builds a HOST .so as part
of the workspace, and `--library` mode reads metadata straight out of it — so
the exact Kotlin the Android lane will compile can be generated and inspected
here, with no Android toolchain involved. It also let me verify the app's call
sites against the real generated API rather than against my assumptions about
uniffi's naming: ThoughtSync(dataDir), createNote(draft), listNotes(query),
Note.displayTitle, and NoteDraft/NoteQuery's parameter names all match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:15:46 -04:00
bvandeusenandClaude Opus 5 dee71dffb3 android: teach the linters this codebase's conventions, and fix two real nits
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m33s
Android / Kotlin + Rust (debug APK) (push) Failing after 4m49s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m59s
Fourth run got the whole native pipeline through — cargo-ndk built all four
ABIs and uniffi generated the Kotlin — and then failed on style.

Two genuine mistakes, fixed:
  * BoardViewModel's constructor parameter needed its own line.
  * PaddingValues was written fully-qualified inline, which ktlint read as a
    method chain. Importing it is what the rule was actually asking for, and
    what the line should have said anyway.

The other ten were the tools not knowing this codebase:
  * @Composable functions are PascalCase by universal Compose convention.
    Exempted in BOTH .editorconfig (ktlint) and config/detekt.yml — they have to
    agree or one of them is always wrong.
  * MagicNumber on `private val Brand = Color(0xFFF5C518)`. The rule asks for a
    well-named constant; that line IS one. ignorePropertyDeclaration.
  * TooGenericExceptionCaught in the ViewModel and Application. Deliberate and
    already commented: a note that fails to save must become a visible error
    banner rather than a crash, and the store failing to open must still let the
    app start so it can explain itself. Scoped to those two paths, not disabled
    globally — everywhere else the rule is right.

Verified locally this time, both linters clean, using the SAME pinned CLIs from
ci-android:36 that the lane runs. ktlint and detekt are a formatter and a static
analyzer — the same category as cargo fmt and clippy, which is the precedent
ci-requirements already sets. No build was run locally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:04:40 -04:00
bvandeusenandClaude Opus 5 5d0de7a682 android: the binding generator gets its own crate, free of the app's deps
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m27s
Android / Kotlin + Rust (debug APK) (push) Failing after 3m51s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m55s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Third Android run got further than either before it — all four ABIs
cross-compiled, vendored OpenSSL and all — then the generator died:

  error: failed to run custom build command for `openssl-sys v0.9.117`

That is the HOST build. The generator was a [[bin]] inside thoughtsync-ffi, so
building it compiled that crate and therefore the core, reqwest, native-tls and
openssl-sys for linux. The vendored-OpenSSL block is scoped to
`cfg(target_os = "android")`, so the host build went looking for a system
OpenSSL that ci-rust-android has no reason to carry.

Adding libssl-dev to the image would have fixed it and been wrong: a code
generator has no business linking the app's TLS stack to emit Kotlin. Splitting
it into thoughtsync-uniffi-bindgen, whose only dependency is uniffi, removes the
entire chain. Verified from the dependency graph rather than from a build that
happened to succeed — `cargo tree -p thoughtsync-uniffi-bindgen` contains none of
openssl-sys, native-tls, reqwest, thoughtsync-core or rusqlite.

It stays a WORKSPACE MEMBER on purpose. Sharing one lockfile is what keeps uniffi
here and uniffi linked into the .so at one version; they are two halves of one
ABI, and a separate lockfile is precisely how they would drift apart. The cost is
that the desktop lane now compiles ~15 generator crates it never runs — cheap
next to Tauri, and better than leaving the crate unlinted.

Drops the `bindgen` feature and required-features bin from thoughtsync-ffi, which
existed only to keep those crates off the desktop lane and now have nothing to
gate.

Local fmt + clippy + test all green before pushing (107 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 15:54:10 -04:00
bvandeusenandClaude Opus 5 3d3df1beb0 android: register generated sources through the Variant API
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m16s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m40s
Android / Kotlin + Rust (debug APK) (push) Failing after 3m5s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Second Android run failed with AGP 9 refusing the previous fix by name:

  You cannot add Provider instances to the Android SourceSet API. [...] Instead
  you should use the Sources interface in the Variant API, in particular
  SourceDirectories.addGeneratedDirectory

AGP cannot tell from a Provider whether the directory holds generated
(read-only) or hand-written (read-write) files, which is a distinction the IDE
needs. `addGeneratedSourceDirectory` is the supported route and — unlike the
plain-path form the error offers as an escape hatch — it carries the task
dependency, so Kotlin still cannot compile before the bindings are generated and
the APK cannot package a stale .so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 15:43:57 -04:00
bvandeusenandClaude Opus 5 f179928c57 android: run ktlint and detekt from the image, not as Gradle plugins
Android / Kotlin + Rust (debug APK) (push) Failing after 1m47s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m2s
Desktop (Tauri) / Update manifest (push) Successful in 5s
First Android run failed at plugin resolution:

  Plugin [id: 'io.gitlab.arturbosch.detekt', version: '2.0.0-alpha.3'] was not
  found in any of the following sources

That version is published to neither Maven Central nor the plugin portal — the
latest detekt anywhere is 1.23.8. It was copied from Minstrel's catalog, where it
presumably resolves from a cached artifact; copying a pin without checking it
exists is what made it my problem.

Rather than chase a working plugin version, the analyzers now run from the CLIs
ci-rust-android already ships. That was the point of putting them in the image in
step 3, and going through Gradle plugins would have meant a SECOND pinned version
of each tool, resolved at build time, kept in lockstep with the image's by hand.
One less resolution step, and step 3's decision finally earns its keep.

Also replaces the source-ordering hack while here. Kotlin has to compile after
the bindings are generated, and the usual `tasks.withType<KotlinCompile>` cannot
be written in this build at all — AGP 9's built-in Kotlin means that class is not
on the buildscript classpath. Passing the TASK PROVIDERS to srcDir instead lets
Gradle read their @OutputDirectory and infer the ordering itself, which is the
idiomatic form and removes the dependsOn entirely.

Good news from the failed run: the Gradle wrapper check passed, so Gradle 9.1.0
on the image's JDK 25 works — the toolchain decision from step 3 holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 15:35:17 -04:00
bvandeusenandClaude Opus 5 20907abf6e android: a Kotlin/Compose app that drives the Rust core (M12 step 5)
Android / Kotlin + Rust (debug APK) (push) Failing after 1m20s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m0s
Desktop (Tauri) / Update manifest (push) Successful in 5s
The skeleton, and the lane that builds it. Gradle invokes cargo-ndk to
cross-compile thoughtsync-ffi for four ABIs, generates the Kotlin bindings from
the resulting .so, and packages both.

BUILT ON MINSTREL'S TOOLCHAIN, not a fresh guess. Gradle 9.1.0 / AGP 9.0.1 /
Kotlin 2.3.21 on JDK 25 is the combination already proven in this family on
ci-android, including the JDK 22+ native-access opt-in the launcher JVM needs
and the artifact-upload action pinned by SHA (issues 2255 / 2270). It also
independently confirms the JDK 25 call made on ci-rust-android in step 3.

Gradle wiring worth noting:

  * ExecOperations, not project.exec — the latter was REMOVED in Gradle 9, and
    touching `project` at execution time is also what breaks the configuration
    cache this build enables.
  * The cargo task's inputs are the Rust SOURCES, not the workspace directory.
    Declaring the directory would make Gradle hash target/, which is gigabytes.
  * Bindings are generated with `--library` against the built .so, so they can
    never describe a different version of the Rust than the one being packaged.
  * cargo runs --locked, so an Android build cannot silently re-resolve the
    lockfile the desktop lanes are gated on.

JNA is a real dependency, with the @aar classifier. The plain jar builds fine
and fails at runtime with UnsatisfiedLinkError, which is the worst way to learn
it. R8 keep rules for JNA and the bindings are in for the same reason — that
failure would otherwise appear only in a minified release.

The UI is a working board, not a debug screen: capture field, note list, empty
state, error banner, and an honest failure screen for a store that won't open.
Rules 23/24 — a surface ships at quality from the first commit. Capture uses the
IME action key because the north star is a thought captured in under a second,
and leaves the title empty so the core derives it from the first body line.

Every core call runs on Dispatchers.IO: they are blocking FFI into synchronous
SQLite, and running them on the main thread is exactly the jank going native was
meant to avoid.

The launcher icon reuses frontend/public/icon-maskable-512.png as an adaptive
foreground on the brand #F5C518 — the same asset and colour the web app already
ships, so the three surfaces wear one face.

No signing config. A release keystore that has passed through an agent session
or shell history is compromised by construction (task 2136); it has to be
generated by the operator and reach CI only as a secret. CI builds debug.

CI can only prove this BUILDS — a Linux runner cannot execute an APK, so feel
and on-device correctness remain an operator pass on an emulator.

Scribe #2739.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 15:24:30 -04:00
bvandeusenandClaude Opus 5 e7937ea87e ci-requirements: the Rust lane can be checked before pushing, not just after
fmt was already documented here. Operator authorised clippy and test through the
same pinned image on 2026-08-18, so the section now covers the whole pre-push
loop rather than a third of it.

The commands are byte-identical to the workflow's on purpose — a local check that
differs from CI is worse than no local check, because it produces confidence
without coverage. That this is a faithful proxy is not an assumption: the local
test binary hashes matched CI run 3931's exactly (thoughtsync_core-bbaae797…,
thoughtsync_desktop_lib-9d162263…, thoughtsync_ffi-fc557b96…). Same image, same
lockfile, same compilation units.

Also records that target/ persists on the host, which is why the second run costs
~30s rather than several minutes, and that it is gitignored and disposable.

Scope note in the text: this authorises fmt/clippy/test only. Not the bundle
build, not a local stack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 13:33:12 -04:00
bvandeusenandClaude Opus 5 b3309e29f8 ci: the Rust lane was only ever checking one crate of three
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m54s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m43s
Desktop (Tauri) / Update manifest (push) Successful in 4s
The Clippy, Test and fmt steps ran with `working-directory: desktop/src-tauri`,
so cargo scoped them to the desktop PACKAGE. That was right while the desktop
was the only Rust in the repo. Extracting the core (M12 step 1) made it wrong
and nothing said so:

  * the core's 89 tests have not run in CI since that extraction. They used to,
    as part of the desktop crate, and moving the files out of that directory
    quietly took them out of the lane.
  * `android/ffi` was never compiled at all. I claimed the previous commit was
    verified by this lane; it wasn't. Run 3928 went green without the word
    "uniffi" appearing anywhere in its log.

Both crates still COMPILE, because the desktop depends on the core — which is
precisely why the hole was invisible. A green run kept meaning less than it
looked like it meant, and the tell was there to be read: the test output listed
`thoughtsync_desktop_lib` and nothing else.

Now run from the repo root with `--workspace` / `--all`. The lockfile gate keeps
its place on the first cargo invocation.

ci-requirements gains the rule and the reason, plus a note to check a new member
actually appears in the `cargo test` output rather than trusting the green.

Also corrects the lockfile procedure there to `cargo fetch` rather than
`cargo generate-lockfile`: both update the lockfile, but generate re-resolves
from scratch and bumps unrelated crates, turning a two-line manifest edit into
an unreviewable diff. fetch resolves minimally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 11:18:54 -04:00
bvandeusenandClaude Opus 5 f90b9203a7 android: bind the core to Kotlin through uniffi (M12 step 4)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m9s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m49s
Desktop (Tauri) / Update manifest (push) Successful in 6s
`android/ffi` is to Android what `desktop/src-tauri/src/commands/` is to the
desktop: a shim over the shared core holding no logic of its own. Third workspace
member, so the desktop lane's `cargo clippy --all-targets` compiles and lints it
— which until the Android lane lands (step 5) is the only thing that does.

Three decisions worth stating.

MIRRORED RECORDS, NOT DERIVES ON THE CORE. The core's model structs are serde
shapes contracted with the shared Vue frontend, and one of them holds a
serde_json::Value, which has no uniffi representation. Hanging uniffi derives on
them would couple two unrelated consumers to one definition. The cost of
mirroring is drift — an Android client quietly missing a field the desktop
gained — so every conversion destructures the core struct exhaustively. Add a
field to core::local::models::Note and this crate stops compiling until Android
is told what to do with it.

NoteEdit IS A LIST, NOT A STRUCT OF NULLABLE FIELDS. The store's patch format
distinguishes three states: leave alone, set, and clear to null. Kotlin cannot
express the third with a nullable field — `title = null` in a data class is
indistinguishable from `title` unset — so the editor could never clear a title.
Explicit Clear* variants say it out loud and give Kotlin a sealed class.

ASYNC IS TOKIO-BACKED, AND CANCELLATION ALREADY WORKED. Exported async methods
become Kotlin suspend functions. When a coroutine is cancelled uniffi drops the
future, and no async path in the core holds the store lock across an await —
a std MutexGuard isn't Send, so the compiler has been enforcing that all along.
A cancelled sync leaves the store consistent and simply hasn't stamped
last_sync_at, which is only written after both halves of a cycle succeed.

Also here:

  * core gains Db::conn(). Every consumer was writing
    `db.0.lock().map_err(|e| e.to_string())?` by hand, and worse, any helper
    returning the guard had to NAME rusqlite::Connection — which would have made
    rusqlite a dependency of a layer whose whole point is not knowing what the
    store is made of. Same trap as the update.rs test module in step 1.
  * The uniffi `cli` feature is gated behind our own `bindgen` feature. It drags
    in clap, askama and goblin for a three-line binary, and the desktop lane
    should not compile a code generator it never runs.
  * The bindgen binary lives in this workspace on purpose: generated bindings and
    the linked uniffi runtime are two halves of one ABI, and compiling the
    generator against the same dependency keeps them in step by construction.
    That is why ci-rust-android ships no uniffi-bindgen.

Tests cover the round trip the Android skeleton needs (open a store in a
directory that does not exist yet, write a note, read it back), that a body-only
note still has a display_title, that set and clear are genuinely different
edits, and that an unlinked app reports NotLinked rather than an error.

Known and deliberate: the workspace sets panic = "abort", so a panic crossing the
FFI aborts instead of arriving in Kotlin as an exception. Same behaviour the
desktop already has; noted in the crate header rather than silently changed.

Scribe #2733.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 11:09:52 -04:00
bvandeusenandClaude Opus 5 9f981ca47e ci-requirements: the Android lane has an image again
`ci-tauri-android` was repurposed into `ci-rust-android:1.97` rather than
deleted (CI-runner dc802f2, PR #12) — tauri-cli out, cargo-ndk in, ktlint and
detekt added so the Kotlin analyzer lane needs no second image, and JDK 25 now
that we hand-write the Gradle project instead of letting Tauri generate one.

Two things recorded here because they are constraints ON THIS REPO, not on the
image: our Gradle wrapper has to be 9.1+ for that JDK, and the Rust pin is in
lockstep with ci-tauri and ci-tauri-win because all three build
thoughtsync-core from one workspace Cargo.lock under --locked.

M12 step 3 (Scribe #2732). No workflow consumes the image yet; the lane arrives
with the app skeleton.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 10:15:48 -04:00
bvandeusenandClaude Opus 5 e696b23417 core: give consumers an in-memory store instead of a rusqlite dependency
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m56s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m51s
Desktop (Tauri) / Update manifest (push) Successful in 5s
The extraction left update.rs's tests reaching for rusqlite and uuid directly to
build a Db — crates that now belong to the core alone, so clippy failed on
unresolved imports. The Windows job had already compiled the whole installer, so
this was only ever the test module.

Adding rusqlite as a dev-dependency of the desktop crate would have fixed it and
quietly undone part of the point: the desktop is not supposed to know what the
store is made of. So the core exposes open_in_memory() instead, which is what the
caller actually wanted, and the Android bindings will want the same thing when
they get tests.

uuid went the same way. It was generating unique scratch-directory names, which a
process id plus a counter does without a dependency — process id separates
concurrent cargo test runs, the counter separates tests within a run. The comment
right above it already said nothing there was worth a new dependency.

Verified the boundary holds in both directions afterwards: the desktop crate
references none of rusqlite/uuid/chrono/reqwest/sha2, and the core references no
tauri.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:22:24 -04:00
bvandeusenandClaude Opus 5 0a7480cf9b core: extract the store and sync engine into a shared crate (M12 step 1)
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 48s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m50s
Desktop (Tauri) / Update manifest (push) Skipped
Android becomes a native Kotlin client over this same code (Scribe note 2730), so
the local store and sync engine stop being modules of the desktop app and become
`thoughtsync-core`, a crate with no UI framework in it at all.

This is a move, not a rewrite, and the measurement is why: every file in local/
and sync/ already carried ZERO Tauri references — 4,980 of 6,372 lines. The
coupling was 473 lines of command shim, which stays behind in the desktop crate
as src/commands/. Kept as git renames so history follows the files.

The desktop imports them under their old names (`use thoughtsync_core::{local,
sync}`) so every call site reads exactly as before. What moved is where they
live, not what they are.

Two things a workspace changes that are easy to miss, both caught before pushing:

[profile.release] now lives at the workspace ROOT. Cargo silently ignores
profiles declared by a non-root member — leaving it in the desktop crate would
have dropped lto/strip/opt-level from every release build with only a warning.

And a workspace shares ONE target dir, so the bundles moved from
desktop/src-tauri/target to target/. Thirteen references across publish-release,
debundle-graphics, verify.sh, package-prebuilt and the workflow now point there.
Pinning target-dir back would have been the smaller diff, but the Android lane
also produces Rust artifacts and they do not belong under desktop/.

Also retires the Tauri Android lane in the same push rather than leaving a path
that is being replaced: gen/android, android.yml and docs/android-dev.md are
gone, the mobile_entry_point attribute with them, and the lib drops to rlib —
staticlib/cdylib existed for Tauri mobile, and the .so Android loads will be
built from the core crate instead. Rule 22, no parallel path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:12:26 -04:00
bvandeusenandClaude Opus 5 c28f2bc00e docs: how to run the Android client locally (task 1864)
Two things stop a fresh clone from opening in Android Studio, and both fail with
errors that name the wrong culprit — so they are written down rather than
rediscovered.

Android Studio runs Gradle on its bundled JDK 25, which Gradle 8.14.3 rejects
with an "Incompatible Gradle JVM version" message that reads like a project
misconfiguration. And settings.gradle applies tauri.settings.gradle, which is
generated per build and gitignored, so sync fails before anything can create it —
one CLI build fixes that permanently.

Also records why the Gradle pin is what it is, since the question came up and the
answer was not what it first looked like: the wrapper, the AGP pin and the
buildSrc file using the removed project.exec are all TRACKED in this repo. It is
scaffolding tauri android init wrote once, ours to bump when it is worth doing,
not a constraint of the framework. Tauri's own Android layer targets compileSdk
36 and registers back handling through OnBackPressedDispatcher — the library is
current, only the generated template trails.

Known gaps are listed so a tester does not file them as bugs: no safe-area
handling yet (2706), no enableOnBackInvokedCallback so predictive back will not
animate, and the templated app-wide usesCleartextTraffic that Minstrel already
hit as a Play Protect smell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:46:38 -04:00
bvandeusenandClaude Opus 5 40cb463be7 android: build the x86_64 ABI too, so an emulator can run it (task 1864)
Android (Tauri) / Android APK (debug) (push) Successful in 3m55s
arm64 is every real device, but a desktop emulator is x86_64 — an arm64-only APK
installs there and then dies unable to load its native library. A build nobody
can try on an emulator is a build nobody checks, which defeats the point of
producing an artifact at all while there is no phone in the loop.

armv7 and i686 stay out: 32-bit hardware we do not target, and the image carries
all four targets if that ever changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:01:42 -04:00
bvandeusenandClaude Opus 5 641999de58 frontend: reminders becomes a lens, and cards can clear a reminder (task 1913)
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 46s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m50s
Android (Tauri) / Android APK (debug) (push) Successful in 3m41s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m2s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Reminders was the last surface still reading as its own page — a bespoke row list
rather than the board's cards. It is now the same NoteGrid as every other lens.

The reason it wasn't already is that the list was a TRIAGE surface: one tap for
Done, 1h, 1d. Cards had none of that, so converting naively would have turned each
of those into open-act-close. Reminder upkeep is exactly the "maintenance must
stay dead simple or people stop coming back" case from the north star, so making
it three times more work to look tidier would have been a bad trade.

So the actions moved onto the card instead, shown wherever a note carries a
reminder — the board included. That turns out to be the better place for them
anyway: seeing something due while browsing and clearing it there is useful
outside the reminders lens. Always visible rather than hover-revealed, because a
finger cannot hover and these are the primary action on a due note; .chip-btn
takes the same coarse-pointer sizing rule as .icon-btn.

The card acts on the store directly, which the board picks up through reconcile.
The reminders lens fetches its own list, so it needs telling — hence the
reminder-changed event, which exists only for hosts that hold a list of their own.

Also carried recurrence (↻) onto the card. It was shown only in the reminders
list, so unifying would have silently dropped it; a repeating note now reads as
repeating on the board too. And the container went max-w-2xl → max-w-6xl, since a
narrower column would have reintroduced the different-page feeling the cards just
removed.

RemindersView is ~40 lines lighter for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 14:48:28 -04:00
bvandeusenandClaude Opus 5 c8c8ec4b4e frontend: the shell names the active lens (task 1913)
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 15s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Build & push image (push) Successful in 44s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m57s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m51s
Android (Tauri) / Android APK (debug) (push) Successful in 3m37s
Which lens you're looking at is a property of the space, not of a page you
navigated to — so the name now sits in the bar that never moves, beside the app
name, and stays put while everything beneath it re-filters.

It replaces three per-view <h1>s that each sat in a different place with slightly
different markup (timeline, reminders, graph) and, more to the point, were absent
entirely on the board and in search — the two lenses people spend the most time
in had no name at all. A label lens is named by the label itself, because
"Groceries" is what the user came looking for and "Label" tells them nothing.

Shown at every width rather than hidden on small screens, which was my first cut
and would have been a regression: deleting the per-view titles while hiding the
shell one leaves a phone with no lens name anywhere, and Android is a peer surface
now. Below `sm` the app name is already hidden, so the lens name simply takes the
space it vacates — you know which app you're in; what you need is which lens.

The h1s on Settings, Sync, Account, Login and Register are untouched: those routes
render outside the shell entirely, so they have no chrome to be named by.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 13:49:27 -04:00
bvandeusenandClaude Opus 5 67b9ea2938 frontend: one grid for every lens, and a cross-fade between surfaces (task 1913)
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 59s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m33s
Android (Tauri) / Android APK (debug) (push) Successful in 4m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m32s
Desktop (Tauri) / Update manifest (push) Successful in 5s
"The same board, re-filtered" has to be literally true to read as true. The
column classes were copy-pasted into five places — the board's pinned and other
sections, its non-board branch, search, and timeline — so a lens could drift from
home by a single edit. One already had: the FLIP reflow from 1914 landed on the
board's three grids and left search and timeline popping. NoteGrid is now the only
file that knows how the masonry is laid out or how it moves, and search and
timeline gained the motion by adopting it.

It takes activeId rather than an index. The board splits its notes across two
grids, so index-based focus made the call site do offset arithmetic
(focusedIndex === pinnedNotes.length + i) against a list the grid didn't own.

The lens cross-fade is deliberately UNKEYED, which is the whole trick. Board,
archive, trash and label all render the same BoardView; keying the transition on
the route would remount it, blanking the board and refetching — exactly the
page-change feeling this is meant to remove. Unkeyed, Vue transitions only when
the component TYPE changes (board to search to timeline to graph), and moving
between the board's own lenses stays an in-place reflow that NoteGrid animates.
The two behaviours fall out of one rule rather than needing to be special-cased.

Out is quicker than in because mode="out-in" makes the durations additive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 13:39:49 -04:00
bvandeusenandClaude Opus 5 18a58fb5da frontend: the board glides and the editor grows from its card (task 1914)
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 1m0s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m30s
Android (Tauri) / Android APK (debug) (push) Successful in 4m25s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m8s
Two of M7's motion targets. prefers-reduced-motion was already in place from the
1999 pass and gates both of these for free.

FILTERED REFLOW. The three card grids become TransitionGroups sharing one
transition name, so "how the board moves" is defined once in CSS rather than
three times in markup. Vue's TransitionGroup does the FLIP itself — measure
before, measure after, transition the difference away — so no animation
dependency, which the task called for.

Leavers are deliberately NOT pulled out of flow with position:absolute, the usual
TransitionGroup trick. This masonry is CSS multi-column, and an absolutely
positioned child escapes its column to the container's origin: a note would fly
diagonally across the board on its way out. Keeping leavers in flow costs a small
settle when the element is finally removed, so the leave is the shortest of the
three durations.

EDITOR CONTINUITY. useNoteEditor.open() is the one place that knows which card
was clicked, so that is where the card's on-screen centre is captured; the editor
panel then scales from that point. Deliberately not a true shared-element morph:
scaling by the real card-to-panel ratio distorts the text on the way, and a card
is often a third of the modal, so an honest ratio reads as a zoom rather than a
transition. The task sanctioned a good-enough scale/position tween; this is that.

A point rather than a rect, because nothing needs the card's size and a point
survives the card being filtered away while the editor is open. Consumed on read,
so a compose — which has no card — cannot inherit the origin of whatever was
edited before it and grow from an arbitrary corner.

The animation lives inside NoteEditor rather than in the five views that render
it: the leave has to finish BEFORE the host unmounts, so the component owns its
own visibility and tells the host when it is done. visible starts true with
`appear`, because the panel lives inside that v-if and would not exist to measure
otherwise. The origin is measured with offsetLeft/offsetTop rather than
getBoundingClientRect — enter-from has already applied scale(0.94) by then, so
the bounding rect is of the shrunken panel and the origin would land off by a few
pixels. Offsets are layout geometry and ignore transforms.

Durations are 140-220ms. The brief is continuity, so a card should read as having
moved, not as having performed.

NOT verified: motion is a visual property and there is no frontend test lane, no
device, and no app run here. vue-tsc proves it compiles. Whether it FEELS right
is an operator live pass, which is what M7's own verification section asks for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 12:55:21 -04:00
bvandeusenandClaude Opus 5 e8d6a4f423 android: vendor OpenSSL so the Rust core links (task 1864)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m53s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m41s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android (Tauri) / Android APK (debug) (push) Successful in 3m40s
First Android build failed at openssl-sys: "Could not find directory of OpenSSL
installation". reqwest is pinned to native-tls, which is right for Windows — it
resolves to schannel there and keeps C and assembly out of the cross-compile —
but on Android it resolves to OpenSSL, and there is no Android OpenSSL in the
image to link against.

Vendored rather than rustls. rustls builds faster and was the obvious fix, but it
ships its own root store, so the phone would trust a different set of
certificates than the desktop: a self-hosted server behind a private or
enterprise CA would work on one surface and fail on another. Peer surfaces that
quietly disagree about who to trust is a worse outcome than a slower build, so
one TLS stack stays everywhere and OpenSSL gets compiled from source with the NDK
toolchain — which is what perl and make are in ci-tauri-android for.

Scoped to cfg(target_os = "android") so nothing changes for the Linux, Windows or
web lanes; declared as a direct dependency purely to flip the feature, since
cargo's unification then applies it to the copy native-tls pulls in.

Cargo.lock regenerated in the same commit, per the documented procedure — the
--locked gates in every lane fail otherwise. openssl-src 300.6.1+3.6.3 joins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 23:11:24 -04:00
bvandeusenandClaude Opus 5 1f140c7457 android: scaffold the Tauri mobile lane and build a debug APK in CI
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m38s
Android (Tauri) / Android APK (debug) (push) Failing after 21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m26s
Desktop (Tauri) / Update manifest (push) Successful in 5s
The phone client is Tauri v2 mobile (operator decision), so it reuses the Vue
frontend and the Rust store and sync engine that already exist rather than
becoming a third implementation to keep in step by hand.

gen/android is committed. tauri android init generated it, its own .gitignore
already excludes the build outputs and every keystore file, and CI must not have
to regenerate a project that manifest edits will accumulate in.

What the scaffold confirms is that the image's JDK pin was load-bearing rather
than incidental: Tauri templated Gradle 8.14.3 with AGP 8.11.0, and CI-android's
versions.env records that JDK 25 needs Gradle 9.1.0+ and that anything older
fails with an opaque "25.0.3" message. Picking 17 for ci-tauri-android avoided
exactly that. namespace and applicationId came out as com.fabledsword.thoughtsync,
matching the desktop identifier, so the app-data story stays consistent.

The lane builds a DEBUG APK for arm64 only. Release APKs need signing, and the
keystore has to be generated by the operator and never pass through CI logs or an
agent session — the constraint recorded for the updater key applies unchanged.
Gradle's throwaway debug keystore needs nothing from anyone, so this can prove the
app compiles and packages today and grow a signed job when a key exists. arm64 is
every real device; the image carries the other three ABIs, so widening is a word.

Triggered by frontend/** as well as desktop/**, because generate_context! compiles
the frontend into the app — the same reasoning that widened desktop.yml. Android,
desktop and web are peers on one quality bar, and a frontend commit that skipped
this lane would ship a stale phone build.

Green here will mean it BUILT. A Linux runner cannot execute an APK, so nothing in
this lane proves the app runs, renders, or is usable by finger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 23:01:32 -04:00
bvandeusenandClaude Opus 5 be0eb94225 frontend: reorder cards with Pointer Events so touch can do it at all (task 2697)
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m12s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Native HTML5 drag-and-drop never fires from touch — the API predates it and was
never wired to it — so on a phone reordering did nothing whatsoever, and the grip
that starts it was hover-gated on top of that. Pointer Events cover mouse, touch
and stylus on one code path instead of two.

The awkward part is hit-testing. Native DnD routed dragover/drop to whatever was
under the cursor, so each card learned on its own that it was the target. A
captured pointer sends every move to the element that captured it, so the dragged
card has to hit-test for itself and publish the result where the other cards can
see it — hence the shared refs in useCardDrag. It reads the DOM via
elementFromPoint rather than tracking geometry because the board is a CSS masonry:
visual order isn't derivable from model order, and cards reflow as the column
count changes. Asking the browser what is actually under the finger is the only
answer that stays true.

Capture is what makes the gesture survive crossing a card boundary; touch-action:
none claims it from the browser's scrolling; a 6px threshold keeps a tap from
becoming a drag; and pointercancel is handled so a system interruption leaves no
half-set state.

The parent contract is unchanged apart from `drop` now carrying the target's ID
rather than its note — the dragged card finds its target in the DOM, so an id is
all it can know without a second lookup. BoardView keeps its own tracking of what
was picked up; that it now duplicates the composable's draggingId is real, and
noted for the DRY pass rather than expanded into here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 16:39:37 -04:00
bvandeusenandClaude Opus 5 f5837cd985 frontend: hover-revealed controls stay put where hovering is impossible (task 2697)
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build & push image (push) Successful in 29s
Desktop (Tauri) / Tauri desktop (Linux) (push) Canceled after 2m36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Canceled after 2m36s
Desktop (Tauri) / Update manifest (push) Canceled after 0s
A finger cannot hover, and on a note card the hover toolbar is the only way to
pin, colour or archive — so on a phone those notes could not be acted on at all.
Same for deleting a checklist item, a saved view, an attachment or a preview.

Marked rather than rewritten inline: one `.hover-reveal` class on the five
elements and a single rule that says what it is for. The `group-hover:` reveal
stays in the markup because the trigger differs per component (named groups);
only the fallback is shared. `@media (hover: none)` asks the device directly,
which is more honest than inferring from viewport width — a narrow window on a
laptop still hovers, and a large tablet still doesn't. It sits after the Tailwind
directives so it beats the opacity-0/pointer-events-none utilities on source
order without !important.

Tap targets follow the same shape: p-1.5 around an 18px icon lands near 30px,
which is fine for a cursor and too small for a thumb. Bumped to 44px on coarse
pointers only, so desktop chrome doesn't inflate.

The drag grip is deliberately NOT revealed yet. Reordering still uses HTML5
drag-and-drop, which never fires from touch, so showing the handle would only
promise something that does nothing. It comes with the pointer-events rewrite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 16:36:58 -04:00
bvandeusenandClaude Opus 5 e7ee16c6cf frontend: dialogs keep focus, and a skip link past the chrome (task 1999)
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m14s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m11s
Desktop (Tauri) / Update manifest (push) Successful in 5s
BaseModal declared role="dialog" aria-modal="true" and then enforced none of it.
Focus never moved into the panel, so Escape — handled ON the panel — did nothing
at all in LabelsModal, the integration prompt and the shortcuts modal. Only the
command palette escaped correctly, and only because it happens to focus its own
input. Tab walked straight out of the dialog into the page that aria-modal had
just told assistive tech was inert, and closing dropped focus to <body> so the
next Tab restarted from the top of the document.

All three are one contract, so it lives in BaseModal rather than in each of the
four callers: focus in on open, Tab trapped, focus restored to the opener. The
panel takes tabindex="-1" so it can hold focus itself when it wraps nothing
focusable. CommandPalette's input focus still wins, because a child's mounted
hook runs before its parent's.

The skip link is the other half. The header and sidebar are a dozen-odd tab stops
that repeat on every navigation, and a keyboard user walked all of them again to
reach their notes. <main> takes tabindex="-1" as well, because several browsers
scroll to a bare anchor without moving focus to it — which would have made the
link look like it worked while leaving the next Tab back at the top.

The rest of the audit came back clean: no click handlers on non-focusable
elements, and all 30 focus:outline-none uses already pair with a focus-visible
ring. M3.5's keyboard pass held up; the gaps were in focus management, not
styling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 12:54:08 -04:00
bvandeusenandClaude Opus 5 d6646a64fb desktop: remove two dead ends from the shell, and stop the launch flash (task 1999)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 12s
CI & Build / Build & push image (push) Successful in 39s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m38s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m21s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Sign out was a trap on the desktop, not an action. It nulls the synthetic local
user and redirects to /login, but the offline adapter rejects every sign-in with
"there's no account to sign in to" — so the only way back into your own notes was
to restart the app. There is nothing to sign out of; the notes are on this
machine either way.

Linked devices was a quieter version of the same thing: it lists the tokens a
SERVER has issued to native clients, and the desktop is one of those clients, so
offline the list is always empty and issuing a token rejects. Its actual
relationship with a server already has a home at /sync. Also hid the account
name, which named a login the app doesn't have.

/account is now blocked in the router too, not merely hidden — the mirror of the
existing requiresDesktop guard — so a typed URL or a restored history entry
can't reach the dead end either. Deliberately not applied to /login and
/register: bouncing those on desktop would loop against the requiresAuth guard
whenever a session is missing.

The launch flash is the window painting before the webview does, showing the
platform default white through the gap — worst on a dark-mode desktop, and
widened by the software rendering we force on Linux. Set from the live system
theme rather than app.windows[].backgroundColor, because that config carries one
static colour and either choice would fix half of users while introducing the
same flash for the other half.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 12:46:38 -04:00
bvandeusenandClaude Opus 5 3a1496e5fa frontend: honor prefers-reduced-motion, and let frontend work reach the desktop
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Build & push image (push) Successful in 31s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m38s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m33s
Two halves of the same gap. The app had no reduced-motion handling at all — the
setting appeared nowhere in the frontend — and the desktop build didn't rebuild
on frontend changes, so shared UI work shipped to the web and silently never
reached the desktop app.

The CSS guard is global and blunt so it catches every Tailwind `transition`
already scattered through the components, and catches M7's motion work without
each new component having to remember. Near-zero durations rather than `none`,
so transitionend/animationend still fire and nothing waiting on them hangs.
useReducedMotion covers what CSS can't reach: JS-driven motion, where the honest
response to the preference is no animation at all rather than a faster one. It's
reactive because the setting can change while the app is open.

The path filter was narrowed to the adapter/bridge directories against a
"~20-40 min" build cost recorded in the header. Measured runs are 4-5 minutes,
so that cost isn't there, and the frontend is compiled into the binary by
generate_context! — any part of it changing means the shipped desktop app is
stale. Desktop, web and Android are peer surfaces on one quality bar, so shared
frontend work has to reach all of them by construction rather than by whichever
directory it happened to touch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 11:51:07 -04:00
bvandeusenandClaude Opus 5 659237ccc6 desktop: the empty board explains where your notes live (task 1999)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 25s
A fresh desktop install has no login — auth_me returns a synthetic local user so
the shared router's guard resolves — but nothing said so. You landed on a bare
board with no way to tell whether the app was storing your thoughts on this
machine, waiting for a credential, or quietly shipping them somewhere.

The start state is the empty board itself, not a welcome modal or an onboarding
gate. The product exists to take a thought in under a second; spending that
second on a dialog taxes the one thing it is for. It also means there is no
"seen it" flag to persist, migrate, or let drift out of step with reality — the
message retires itself the moment a first note exists, which is exactly when it
stops being true that you have nothing here.

Shown only when the app is unlinked: offering to connect a server to someone who
already has one is noise. The status read is best-effort and never awaited, so
the board renders at full speed regardless; if it fails we keep showing the
offline copy, which is the honest reading of "we know of no server".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 11:37:29 -04:00
bvandeusenandClaude Opus 5 c883fd2eb6 desktop: one name across all three install channels (issue 2075)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m6s
Desktop (Tauri) / Update manifest (push) Successful in 6s
The app answered to three different names depending on how it arrived, and the
part that actually hurt was WM_CLASS. Reading tauri-bundler settles what it is:
the generated .desktop template writes StartupWMClass={{exec}} where exec is
main_binary_name, and tao creates its GtkApplication with a NULL app id
(enableGTKAppId defaults off), so GTK falls back to the program name. WM_CLASS
is the binary name, nothing else.

Which inverts this issue's premise. The rename could not break grouping,
because two channels weren't grouping in the first place: pacman ships
/usr/bin/thoughtsync and the AppImage's AppRun execs thoughtsync-desktop, while
all three hand-written entries hardcoded StartupWMClass=ThoughtSync — a string
no binary in any channel has ever reported. Only the .deb worked, and only
because Tauri generates its entry from the binary and never consulted us.

So: thoughtsync everywhere, carried by the build target itself via Cargo [[bin]]
plus mainBinaryName rather than by the install path, since the target name is
what the desktop reads. The pacman package sheds its -desktop suffix and
declares conflict+replaces so an upgrade retires the old one instead of landing
beside it and fighting over /usr/bin/thoughtsync.

The .deb verifier now asserts binary path, Exec and StartupWMClass all agree,
which is the part that keeps this fixed: the .deb's entry is the one no human
writes, so it's the one that drifts silently.

Package: thought-sync stays. tauri-bundler derives it as kebab-case(productName)
with no override, and rewriting a control archive on every build is a poor trade
for one uninstall command.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 10:57:17 -04:00
bvandeusenandClaude Opus 5 5c1ae574f6 desktop: commit Cargo.lock and gate CI on it (issue 2102)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m37s
Desktop (Tauri) / Update manifest (push) Successful in 5s
The desktop crate is a binary, and binaries commit their lockfile. Without one
every run re-resolved the graph: a tagged .deb/.AppImage/.exe couldn't be
rebuilt from its tag, any semver-compatible upstream release landed
automatically on the next build — the failure mode hardest to read, because the
commit that broke it changed nothing relevant — and Renovate had no lockfile to
bump, leaving Rust dependency movement invisible to the Dashboard.

Generated with cargo generate-lockfile inside ci-tauri:1.97, the same image CI
builds in, so the format and the picked versions are what CI would have chosen
itself. That takes the artifact-upload round-trip the issue proposed off the
table: ci-requirements.md already blesses the image for cargo fmt, and resolving
a dependency graph is no more a build than formatting is. 503 packages.

Enforcement goes on each job's FIRST cargo invocation rather than the bundle
build: cargo clippy --locked on Linux, and its own cargo fetch --locked step on
Windows, whose only crate-graph command is otherwise the cross-compile itself.
Drift fails in the first thirty seconds instead of thirty minutes in, and
everything after the gate in that job compiles the recorded versions anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 10:41:18 -04:00
bvandeusenandClaude Opus 5 2cfe049f9c sync: unlinking a device now revokes its token on the server (issue 2110)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m9s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Unlink was local-only. It cleared the server URL, token and cursor from the
device, and left the bearer token valid on the server indefinitely — so someone
who unlinked because the laptop was being sold or handed on believed they had
revoked access when they hadn't.

The blocker was identification, not intent: a token pasted from the web app
never carried a device id, and /api/auth/me describes the user, not the device
row, so DELETE /devices/<id> could only ever have worked for one of the two ways
this app can be linked. DELETE /api/auth/devices/self keys off the token in the
Authorization header instead, which the caller always holds — one route that
works for both paths, owner-scoped like the rest, and no local schema change.

Unlinking is never blocked on the network. Wanting to stop syncing is a local
decision, so the revoke is attempted first, its outcome carried back, and the
link cleared either way. When the token survives — server unreachable, or older
than the route — the Sync screen says so in place, with where to revoke it. A
toast would have been the wrong shape for that: it disappears, and this is
exactly what someone returns to the screen to check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 10:31:06 -04:00
bvandeusen edf52da97f desktop: the installer's channel choice now reaches the app (issue 2183)
Desktop (Tauri) / Update manifest (push) Successful in 4s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m19s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m3s
`install.sh --channel dev` set the channel in the installer and nowhere
else. The app kept its `stable` default, stable advertises 0.1.0, and
0.1.0 is older than any dev build — so every update check said "up to
date", forever, and the user had to know to go set it themselves.

The installer now records the channel as a plain file in the app-data
dir; the app adopts it at startup. A file rather than a write into the
app's SQLite store, because shell has no business knowing that schema.

Adoption compares against the value last adopted, not against "is the
pref unset". Seeding only when unset would have fixed the first install
and left the second silently wrong: install stable, then install dev,
and the pref is already set so dev never takes. Comparing to the last
marker makes both directions work — an in-app channel switch survives
the next launch, and re-running the installer on a different channel is
honoured.

An unreadable marker is ignored rather than read as `stable`, so a
truncated file can't move someone off the channel they're on.
2026-08-15 21:38:58 -04:00
bvandeusen c1464228df docs: Fabled-Git, not Forgejo, where the instance is meant
Four references to "Forgejo" actually meant this instance, which has run Gitea
since the migration: the registry push, the missing /releases/latest/download
route, the API a packaging script resolves URLs against, and the 422 on an
illegal JSON escape.

Kept as-is — these are genuinely about the upstream Forgejo project, not us:
the `forgejo/upload-artifact` mirror and "the Forgejo project's fork".

Prose only — no workflow, path, or script change. Scribe issue #2272.
2026-07-31 23:44:29 -04:00
bvandeusenandClaude Opus 5 505904b1e5 ci: swap artifact upload to the mirrored action (issue 2270)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m42s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m32s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Both desktop upload steps used actions/upload-artifact@v3, which reports
success while Gitea stores the result in a format its v4-only artifact API
will never serve back — 110 artifacts on this repo are on disk, have valid
DB rows, and are invisible to the REST API, the web download route and the
MCP tools alike. Green jobs producing nothing retrievable.

Point both at bvandeusen/upload-artifact (pull mirror of the Forgejo
project's fork, GHES refusal disabled), pinned by SHA because the mirror
auto-syncs. Not actions/upload-artifact@v4: its isGhes() throws on the
hostname before opening a connection, so no server-side change reaches it.

Also drop continue-on-error and set if-no-files-found: error on both steps.
Between them, a failed or empty upload was reported as a green run — the
same silence that let this go unnoticed for a month.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:27:57 -04:00
bvandeusenandClaude Opus 5 13e48672c0 packaging: bare backticks — a heredoc's backslash isn't the JSON's
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m5s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m16s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Run 2981 built everything and then died posting the release: HTTP 422,
"invalid escape sequence \`". The body's other backticks are written \` because
they sit in an UNQUOTED heredoc, where that backslash is the shell's and is gone
before any JSON exists. Copying the idiom into a single-quoted variable changed
what it meant — single quotes already stop substitution, so the backslash
survived into the body as an escape JSON has no rule for.

bash -n passes either way; it checks syntax, not what a string becomes. So parse
the assembled body for every branch it can take instead, and write down the
recipe next to the one for formatting Rust.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKsUY9Z45KQd34V956hZ9Q
2026-07-27 23:10:42 -04:00
bvandeusenandClaude Opus 5 8b6dfab3a7 ci-requirements: the two things that cost a cycle each to rediscover
`git push origin dev` fails outright now that the rolling channel put a TAG
named `dev` beside the branch, and the error names neither. And nothing in CI
lints the packaging shell scripts, so a broken installer surfaces when a user
runs it rather than when it's built — record how to check them locally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKsUY9Z45KQd34V956hZ9Q
2026-07-27 23:04:41 -04:00
bvandeusenandClaude Opus 5 d8b0cd9b96 packaging: the installer learns the same two channels the app updates on
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 2m36s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 4m32s
Desktop (Tauri) / Update manifest (push) Has been skipped
install.sh asked /releases/latest and installed whatever came back. That is
v0.1.0 today, which predates the updater, and it was about to get worse: the
`stable` pointer release write-manifest.sh creates is non-prerelease and holds
only latest.json, so from the next v* tag onward it would have WON
/releases/latest and the installer would have found nothing to install.

So resolve a channel instead of a "latest". `--channel stable|dev` (or
TS_CHANNEL), default stable, named to match update.rs's Channel exactly. dev
reads /releases/tags/dev. stable reads the pointer's own latest.json, takes its
version, and installs that v* release — the same file the app reads, so the
installer and the updater cannot disagree about what stable means.

Two things found on the way. The dev release's description still told people to
run the stable command, and always would have: publish-release.sh writes a body
only when it CREATES a release, and a fixed-tag release is only created once, so
the text froze at the first build. The 409 path now PATCHes it. And the asset
greps were unanchored, so a .AppImage.sig URL could match as the bundle URL —
harmless by coincidence, not by construction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKsUY9Z45KQd34V956hZ9Q
2026-07-27 23:03:12 -04:00
bvandeusenandClaude Opus 5 6f47af8d96 ci: point the manifest at THIS build, and stop the dev release growing forever
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m40s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m29s
Desktop (Tauri) / Update manifest (push) Successful in 7s
Two halves of one mistake, both visible on the dev release right now: the
manifest said 0.1.134 and pointed at ThoughtSync_0.1.132_amd64.AppImage.

The rolling channel accumulates every build's assets, and the manifest picked
its bundle by file extension with `head -1` — the OLDEST match. A client would
have been told 0.1.134 was available, downloaded 0.1.132, installed it, and
been offered 0.1.134 again. Forever.

Signature verification could not have caught it. The old bundle's signature is
perfectly valid for the old bundle; nothing about it says "this isn't the build
the manifest claims". Selection is now matched on the build's own version
string, so the manifest can only ever describe the binary it was written for.

The accumulation is the other half. Nothing can reach a superseded build once
the manifest moves on, and an AppImage is ~100 MB — three pushes had already
left 300 MB of unreachable binaries on the Git host. A rolling channel now
prunes everything but the current build once the manifest points at it.
Versioned releases are untouched: that IS the archive, and the stable pointer's
URLs aim into it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-27 15:40:54 -04:00
bvandeusenandClaude Opus 5 acff95f920 ci: re-sign the AppImage after de-bundling, or Linux updates can never verify
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m45s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m48s
Desktop (Tauri) / Update manifest (push) Successful in 4s
The de-bundle step deletes the AppImage and repackages it without the host
graphics libraries — necessary, and it runs AFTER tauri signed the original.
So the .sig published on the release described a file that no longer existed,
and every Linux in-app update would have failed signature verification.

Worth naming the failure mode: the error would have said the signature didn't
match, which points at the key, the manifest, or the download — anywhere except
"a later build step rewrote the file after signing it". The Windows lane hid it
too, because nothing post-processes the NSIS installer, so the one platform
already verified working was the one platform that couldn't reveal the bug.

Signs the file that actually ships, and fails the build if no .sig comes out
rather than quietly publishing an unverifiable bundle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-27 15:16:13 -04:00
bvandeusenandClaude Opus 5 3ca3eba6d5 packaging: stamp the pacman package with the version actually built
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m32s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m26s
Desktop (Tauri) / Update manifest (push) Successful in 4s
It read the version straight out of tauri.conf.json, which was correct until
dev builds started overriding the version on the command line — the file still
says 0.1.0, so release `dev` came out carrying a pacman package labelled 0.1.0
around a binary that reports 0.1.132.

Nothing breaks from it (a pacman install can't self-update anyway), but a
package that lies about its version is exactly what makes a later "which build
is this?" impossible to answer. Now uses the same build-version.sh the bundles
and the manifest do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-27 15:07:40 -04:00
bvandeusenandClaude Opus 5 02c932260e Updater signing key, a rising dev version, and a production compose
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m33s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m37s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Three things, all needed before the update loop can be tested.

The public signing key is committed. Verified before trusting it: algorithm
`Ed`, key ID 90E96FEA2F6D9B6A matching its own comment, 32-byte Ed25519 key.

Dev builds now carry a version that RISES. Every build took its version from
Cargo.toml, so each one was 0.1.0 — an installed 0.1.0 would read a manifest
advertising 0.1.0, conclude it was current, and never update. The rolling
channel would have looked broken while working exactly as written. Dev builds
are now 0.1.<ci-run-number>, from one helper shared by both bundle jobs and the
manifest writer, because three separate derivations of "what version is this"
is three chances for the binary and the manifest to disagree.

Plain semver, not a `-dev.N` prerelease: prerelease versions sort BELOW the
release they qualify, so a tagged build would never update to a newer dev one,
and Windows installer metadata wants a numeric X.Y.Z regardless. Bumping the
minor still beats any dev build on the old line — 0.2.0 > 0.1.2932.

The Windows job also gets the signing environment it was missing, so its NSIS
installer is signed too. Without that the manifest would have had a Linux entry
and nothing for the platform actually being tested.

docker-compose.yml is now the production stack, per request: it pulls the
published image instead of building, keeps Postgres OFF the host network, sets
restart policies, health checks and log rotation, and refuses to start without
a POSTGRES_PASSWORD rather than shipping a known one. Volume names are
deliberately unchanged so an existing deployment upgrades in place instead of
silently coming up against an empty database. Development keeps its own
clearly-named file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-27 10:33:17 -04:00
bvandeusenandClaude Opus 5 1f294c4ad8 ci-requirements: record how to format the Rust lane without a local toolchain
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 19:27:39 -04:00
bvandeusenandClaude Opus 5 2e8717a057 desktop: rustfmt the two new preference helpers
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m11s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m4s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Verified locally this time rather than in CI. The ci-tauri image is already on
this machine, so `cargo fmt --check` can run in a throwaway container against
the exact toolchain CI uses — no test run, no build, no local stack, just the
formatter. Four consecutive pushes had failed on formatting alone; that class
of failure is now catchable before it costs a cycle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 19:27:27 -04:00
bvandeusenandClaude Opus 5 d6734cf7a0 desktop: in-app updates, two channels, signed, fed by fixed-tag releases
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 30s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m59s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m23s
Desktop (Tauri) / Update manifest (push) Has been skipped
There was no in-place update anywhere. The app never checked, downloaded or
applied anything, and the only published release predates the whole sync arc —
so `install.sh` would hand out a build with no sync in it. Installing from
per-run CI artifacts, which is what's been happening, is not something an
updater can point at: ephemeral, auth-gated, no stable URL.

Two channels, switchable in the app: `stable` follows tagged releases, `dev`
follows every green push.

The feed is a Fabled-Git release asset, not a ThoughtSync server route. This
reverses the lean recorded in task 1998, and the reason matters — a
server-hosted feed can only reach a desktop that has linked a server, and
local-first-with-no-server is the whole premise. An unlinked install has to be
able to update itself.

Each channel reads a `latest.json` on a release whose TAG NEVER MOVES.
That's forced, not stylistic: Forgejo has no /releases/latest/download/<asset>
route (verified — it 404s with no redirect), so "newest" cannot be named in a
URL. `dev` carries the rolling bundles; `stable` is a pointer release holding
only the manifest, whose URLs aim at the versioned release's assets, so nothing
is duplicated.

The manifest is written by a third job that runs after both bundle jobs. They
build in separate workspaces and neither can see the other's output, but one
manifest has to describe both platforms — generating it inside either job would
silently omit the other, and a missing platform reads to a user as "no update
available" rather than as a broken feed. It reads what actually landed on the
release, so it can never advertise a bundle that failed to upload.

Signing is gated on the secret existing, in the script rather than an `if:`
(the secrets context isn't reliably available to step conditions). No key means
no updater artifacts and no publish: a feed the app would refuse to verify is
worse than no feed, because it looks like it works. CI stays green until the
key lands.

On Linux the updater can only replace an AppImage — a deb or pacman install is
owned by its package manager and must never be overwritten underneath it. The
app detects that case up front and says so, instead of failing halfway through
with a permissions error nobody can read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 19:03:12 -04:00
bvandeusenandClaude Opus 5 b7c0820230 desktop: rustfmt the blob-store literal in the scheme handler
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m49s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m53s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 17:39:12 -04:00
bvandeusenandClaude Opus 5 c40263967d desktop: render synced attachments instead of broken images (task 2114)
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m31s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m47s
A synced note carried the SERVER's relative attachment path
(/api/notes/<id>/attachments/<aid>). In the webview that resolves against the
app origin and 404s, so every synced image rendered broken even though the
bytes were already on disk from M10.7d. The absolute server URL wouldn't have
worked either: that route wants a bearer token the webview never sends, and it
would put an offline app on the network to show a file it already has.

The bytes now come off disk over a custom URI scheme, served straight from the
content-addressed blob store. The webview caches and range-requests them like
any other resource — which a data: URI would have thrown away — and the URL is
immutable-cacheable because a content address can never describe different
bytes.

Two things worth knowing about the shape of this:

The URL is rewritten in `load_attachments`, the single place the desktop
builds an attachment for the UI. NoteCard and NoteEditor are untouched, so
there's no second render site to drift.

The scheme's URL form is NOT the same on every platform: `scheme://localhost/`
on Linux and macOS, `http://scheme.localhost/` on Windows and Android. Getting
it wrong breaks exactly one channel, silently, and a headless CI runner can
never tell you.

The mime rides in the URL, and this scheme is an origin of its own, so an
attachment claiming to be text/html would run as a document there. Only media
families are echoed back; everything else is served as an opaque download,
which is the right treatment for an arbitrary file anyway. Path safety is
inherited rather than re-implemented — the handler reads through BlobStore,
which already refuses anything that isn't a bare sha256.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 17:16:44 -04:00
bvandeusenandClaude Opus 5 d634801bd3 desktop: rustfmt the retention query and one assert
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m55s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m53s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 16:46:45 -04:00
bvandeusenandClaude Opus 5 7a77a0e1b9 desktop: fix a retention test that raced the wall clock
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m31s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m50s
`a_note_exactly_at_the_boundary_survives` stamped a note 30 days ago and then
asked the sweep — which reads `now` microseconds later — whether it was
strictly older than 30 days. It was, by those microseconds. The assertion was
wrong, not the code: an exact tie isn't observable against a wall clock.

Now stamps a note with a minute of its window still to run, which is the
property actually worth pinning: the comparison is strictly-older, so a note
inside the window is kept.

Also rewrote the row scan as plain statements. The `filter_map` over
`query_map` swallowed real rusqlite errors through `.ok()?` on the way to
skipping unparseable timestamps — the two cases deserve different treatment,
and only the second should be silent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 16:25:32 -04:00
bvandeusenandClaude Opus 5 e64d67e904 Expire trash after 30 days, and make the deadline something you can see
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 12s
CI & Build / Build & push image (push) Successful in 44s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m45s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m12s
Trash had no end. A note sat in /trash until someone emptied it by hand, and
its attachment BYTES sat on disk the whole time — the pile-up the operator
asked about. Nothing purged; there was no scheduler at all.

Retention is server-owned: `trash_retention_days` (default 30, 0 = keep
forever) in the settings registry, so it lands in admin Settings with no
migration and takes effect without a restart. A background sweep started in
before_serving does the work. Clients learn about a purge the way they learn
about any deletion — as a tombstone on the delta feed.

An auto-purge nobody can see coming is data loss on a timer, so the window is
now visible: /api/config publishes it, notes carry `deleted_at`, Trash leads
with the policy, and each card counts down. The countdown rounds DOWN — saying
"1 day left" for a note with ten minutes on the clock is the one error here
that actually costs someone a note.

Three things this turned up on the way:

- `DELETE /api/notes/<id>` hard-deleted the row, leaving no tombstone at all.
  A permanent delete in the web UI never reached a linked device, which would
  keep its copy forever and push it back on the next edit. It now purges
  through the same path as everything else.
- The purge left `note_revisions` and `note_link_previews` behind. A revision
  holds the full body, so the text of a "permanently deleted" note was still
  sitting in the database.
- `deleted_at` now SURVIVES a purge instead of being cleared. It's still true,
  and it means every query that says "not trashed" excludes tombstones for
  free — without it a content-less row reads as a perfectly normal active note
  and shows up on the board as a blank card.

Desktop keeps its own clock only when there's nobody else to keep one: the
sweep runs at startup on an UNLINKED device and refuses otherwise. A linked
client that expired notes on its own schedule could destroy something the
server was deliberately keeping, then push that delete upstream. Local policy
must never outrank the server's — so it also adopts the server's window for
the countdown rather than showing its offline default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 16:20:13 -04:00
bvandeusenandClaude Opus 5 6f35e6e6d8 Confirm irreversible deletes, which sync just made far more consequential
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 31s
The trash model itself was already right and needed no change: notes soft-
delete (`trashed` locally, `deleted_at` server-side), Trash is a real view,
restore works, permanent deletion is a separate second step only offered on
an already-trashed note, `trash()` shows an Undo toast, and nothing auto-
purges — trash persists until someone acts. Sync carries all of it: a trashed
note syncs WITH its content, and only `purged_at` deletes a client's copy.

What was missing is the guard on the irreversible step. "Delete forever" and
label deletion were one click, silent, with no confirmation — and M10.7 has
changed what that costs. Before, a mis-click lost a note on one machine.
Now it pushes a tombstone that deletes it from every linked device, and the
local tombstone survives to make sure it gets there.

Both guards live in the STORE, not the call sites: NoteCard and NoteEditor
both offer delete-forever, and duplicating the copy is how two prompts drift
until one of them stops matching what actually happens.

The copy names the real consequence — "deleted from every device you sync
with" — because that's the part a user cannot infer from a button in a Trash
view. The label prompt also says the notes themselves are kept, since that's
what people actually worry about when deleting a label.

Labels deliberately get a confirmation but NOT a trash of their own. A label
is organization, not content; the reversible middle step notes get would be
ceremony around something that costs nothing to recreate.

Saved-filter deletion already confirmed (AppShell), so these two were the
outliers, not a new convention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 15:40:33 -04:00
bvandeusenandClaude Opus 5 810da43f56 desktop: rustfmt the blob-store test
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m51s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m57s
One hunk from run 2911. Clippy and all 67 tests — including the six new blob
tests and the path-traversal guard — had already passed on the same code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 00:46:25 -04:00
bvandeusenandClaude Opus 5 ed623a7bef M10.7d: download attachment bytes into a content-addressed store (task 2107)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 35s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m29s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m47s
The client half of task 1942's server work. Metadata already rides the delta
feed; this fetches the payload so a synced image exists on the device.

Blobs are filed under their own sha256, so the same image attached to five
notes is stored once and re-downloading it is free — the dedupe the task asks
for falls out of content addressing rather than needing bookkeeping.

The hash is also the integrity check, applied on the way IN. Bytes that don't
hash to what the server advertised are refused rather than filed under a name
that lies about them — and because the blob then still counts as missing, the
next sync simply tries again.

SECURITY: the hash arrives in a server response and becomes a FILENAME, so it
is validated as 64 hex characters before touching the filesystem. Without
that, a hostile or buggy server could send "../../..." and steer a write
outside the blob directory. Tested.

A failed attachment never fails the sync. Notes are the primary data and have
already landed; aborting here would let one unreachable file block every
future sync. Counted, logged, surfaced in the UI as "they'll retry on the
next sync", and retried because the blob is still absent.

sha2 is pure Rust, so the Windows cross-compile lane pays nothing for it —
the constraint recorded in ci-requirements.md.

SPLIT, deliberately: this stores the bytes but does NOT yet render them in
the webview. That half needs a custom URI scheme or the asset protocol, whose
URL form differs by platform (Windows uses http://scheme.localhost/, others
scheme://localhost/) — and CI cannot verify webview rendering at all, being
headless with no webview. Guessing at it here would ship an unverifiable
change on the most fragile lane. Follow-up filed; synced images will show as
broken until it lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 00:43:34 -04:00
bvandeusenandClaude Opus 5 6bef07ff83 desktop: rustfmt the SyncOutcome literal
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m2s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m56s
One hunk from run 2908. Clippy, all 61 tests, and vue-tsc (run 2907) had
already passed on the same code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 00:33:35 -04:00
bvandeusenandClaude Opus 5 fe683595df M10.7e: desktop Sync settings screen (task 2108)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 33s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m32s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m4s
The surface that turns the engine into a feature (rule 27). Desktop-only —
the web build IS a server's UI, so a "connect a server" screen there would be
nonsense; the route redirects to the board and the nav entry is hidden.

UNLINKED IS THE RESTING STATE, not an incomplete setup. The empty case leads
with "Working offline on this device — everything works without a server",
because a screen that framed the default as a problem would push people into
configuring something they may never need. The app is local-first; this is
opt-in.

Probe before credentials. "Check" shows who actually answered — site name,
version, and the M10.6 verdict — before any password or token is typed. An
incompatible server is shown in red and the sign-in fields never appear, so
you cannot hand a credential to something that can't use it. `degraded` names
the missing capabilities rather than staying quiet and letting a feature
mysteriously do nothing.

Both credential paths, matching the Rust side: email+password (a fresh
install has no session to mint a token from) or a pasted device token (for
anyone who'd rather not type a password into a desktop app). Secrets are
cleared from component state the moment they're exchanged.

Disconnect states plainly that the token stays valid server-side and points
at Account -> Linked devices, rather than implying a remote revoke that
didn't happen (issue 2110). Wording avoids "revoke" for exactly that reason.

Push rejections are surfaced verbatim after a sync, never swallowed — a
duplicate label name is the realistic case and only a person can resolve it.

Adds schema v3: last_sync_at. The cursor can't answer "am I 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, so "synced a moment ago, nothing new" would
be indistinguishable from "never synced". Stamped only after BOTH halves of
the cycle succeed; a stamp after a partial cycle would claim currency the
data doesn't have. Cleared on unlink so a new server can't inherit it.

run_cycle now returns the post-cycle status, so the UI updates from one
round-trip instead of chasing every sync with a status call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 00:30:37 -04:00
bvandeusenandClaude Opus 5 75b2d096ec desktop: rustfmt the push module
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m12s
Seven hunks, applied verbatim from run 2903's cargo fmt --check diff.

The reordered job already paid off: clippy and all 60 tests ran and passed
in that same run, so this is known to be formatting only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 00:23:38 -04:00
bvandeusenandClaude Opus 5 b5f7dc2635 M10.7c: push + the full sync cycle (task 2106)
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m28s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m46s
Local -> server, then push-then-pull as the only ordering the UI can invoke.

LOCAL TOMBSTONES (schema v2). Found while writing push: delete_forever and
remove_label just DROPPED the row, leaving no record it existed. Offline that
means the delete can never be pushed — and the next pull faithfully
resurrects the note from the server. A deletion that undoes itself is about
the worst thing sync can do, so deletes now record into pending_deletes until
the server acknowledges them. merge_labels had the same hole.

merge_labels also moved memberships without marking the affected notes dirty.
A note's label set only reaches the server via the note itself, so a merge
looked done locally and never synced. Now marked before the delete cascades
the rows away.

Result handling, per status:
  created/applied -> clear dirty, store the returned sync_revision
  noop            -> clear dirty, drop the tombstone (a row the server never
                     saw, created and deleted entirely offline)
  kept            -> clear dirty WITHOUT touching content. Re-pushing would
                     lose the same last-write-wins comparison forever; the
                     following pull adopts the server's version.
  rejected        -> stay dirty and surface the reason. A duplicate label name
                     is the realistic case and only a human can resolve it.

The subtle one is `kept` plus a skewed clock. Normally the server's kept
revision sits above our cursor, so the next pull fetches it anyway. If the
clock makes a genuinely later local edit look older, that revision can be
BELOW the cursor — the pull skips it and the stale local copy stays on screen
with nothing marking it wrong. So a kept result at or below the cursor
rewinds the cursor to re-fetch that note. Both directions tested.

label_ids carries MANUAL memberships only. Tag-sourced ones are re-derived
server-side from the body; sending them would convert them into manual
assignments that no longer disappear when the #tag is deleted from the text.

engine::run_cycle is push-then-pull, and a failed push ABORTS before the
pull — pulling anyway would overwrite the exact rows we just failed to save,
turning a recoverable network error into lost work. sync_pull is removed from
the command surface accordingly: offering a bare pull would hand the UI a way
to discard unsent edits. sync_now and sync_has_pending replace it.

Both loops have anti-spin guards: push stops when a batch clears nothing,
pull stops when the cursor doesn't advance.

15 push tests against an in-memory database.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 00:20:09 -04:00
bvandeusenandClaude Opus 5 2e32ecda6e desktop: rustfmt the pull tests; run fmt after clippy/test
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m20s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m8s
Two macro-argument splits and a stray blank line, applied verbatim from run
2900's cargo fmt --check diff.

Also reorders the Linux job so `cargo fmt --check` runs AFTER clippy and the
tests. Fail-fast ordering would normally put the cheapest check first, but
there is no Rust toolchain on the workstation, so this lane is verified
entirely in CI — and a formatting nit failing first SKIPS clippy and the
tests, making a whole cycle teach nothing but whitespace. That has now cost
four cycles in this session alone. It still runs before the 20-40 minute
bundle build, so a fmt failure doesn't burn that either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 00:09:07 -04:00
bvandeusenandClaude Opus 5 dc8b2d360d M10.7b: pull the change feed into the local store (task 2105)
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 26s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m53s
Server -> local. sync/wire.rs mirrors the delta-feed JSON exactly as
notes/serialize.py sends it; sync/pull.rs applies it.

ATOMICITY IS THE POINT. The cursor is written in the SAME transaction as the
page it describes. A cursor committed ahead of its data would skip those rows
forever while reporting a clean sync — the worst kind of failure, because
nothing looks wrong. A test forces a mid-page failure and asserts the cursor
stayed put.

Every degradation leans toward re-downloading rather than skipping: an
unparseable cursor means full sync, wire fields are all defaulted so a newer
server adding a field (or an older one omitting one) yields a partial note
instead of a rejected page, and a page that fails rolls back whole.

Labels are applied before notes so a membership never references a row that
doesn't exist. A note also carries enough of its labels to materialize them,
because notes and labels page from ONE shared sequence and a note can arrive
referencing a label whose own delta landed in an earlier page.

via_tag is applied verbatim rather than re-deriving #tags from the body. The
server already reconciled them on save, and re-deriving would go through the
local find-or-create path, which marks new labels dirty — pushing them
straight back. Sync churn manufactured out of nothing.

Duplicate-label merge, the subtle one: a label created offline can collide by
name with one the server already had under a different id. Both sides enforce
one label per name, so the server's row has to win — but simply deleting the
local duplicate would CASCADE its note_labels away, stripping the label off
notes this pull never mentions, with no later page to repair it. So we free
the name, insert the server's row, re-point the memberships, then drop the
husk. Tested.

Children (items/attachments/previews/labels) are replaced wholesale rather
than diffed: a delta carries the note's FULL state, so what arrived IS the
complete set, and diffing could strand a row the server no longer has.

The loop trusts the data over the flag — a server claiming has_more without
advancing its cursor stops with an error instead of spinning forever.

Pull can overwrite a row with unpushed local edits. The documented cycle is
push-then-pull (M10.7c), so that should never happen; when it does it's
counted as clobbered_dirty and logged rather than hidden.

17 tests, all against an in-memory database.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 00:05:27 -04:00
bvandeusenandClaude Opus 5 7d9a6509f3 desktop: rustfmt the M10.7a state tests
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m55s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m52s
Four macro-argument splits, applied verbatim from run 2895's
cargo fmt --check diff. No logic change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-25 23:38:49 -04:00
bvandeusenandClaude Opus 5 bbb2fd9b1c M10.7a: link/unlink a server — device auth + sync_state (task 2104)
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 27s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m55s
The pairing step. Nothing else in the sync arc can move until this works.

sync/state.rs owns the link record in the sync_state row M10.4 already put
in the local schema. Two safety properties are the reason it isn't just
three setters:

- Linking a DIFFERENT server resets the change-feed cursor. A cursor is only
  meaningful against the server that issued it; carrying one across would
  silently skip every change on the new server below that watermark — data
  loss wearing the costume of a successful sync. Re-linking the SAME server
  (a token refresh) keeps it, so a routine re-auth doesn't force a full
  re-download.
- Unlink clears the cursor too, so a later link can't inherit a watermark
  from a server that never issued it.

An unparseable or absent cursor reads as 0 (full sync). That direction is
always safe: a redundant re-sync costs time, a too-high cursor costs notes.
Likewise a half-written row (server but no token) reports NOT linked.

state::Status deliberately has no device_token field — it crosses into the
webview, and a long-lived bearer token has no business reachable from page
scripts. A test asserts the token never appears in its serialization.

Token lives in the app-data SQLite file, not an OS keyring: the keyring
crate needs libsecret/DBus on Linux, which adds a C dependency to a binary
that has to cross-compile and fails outright on headless/minimal-WM setups —
the same class of environment assumption behind the black-window bug.

sync_link runs the M10.6 handshake FIRST and refuses an incompatible server
before any credential is sent. Two credential paths, because neither covers
everyone: device-login (a fresh install has no session to mint a token from)
and a pasted token (some users would rather not type a password into a
desktop app). A pasted token is verified against /api/auth/me before being
stored — auth.py's login_required accepts bearer — since an unverified paste
would turn a copy/paste slip into a failure surfacing at the next sync, far
from its cause.

The store lock is taken only after all network work: a std MutexGuard isn't
Send so it cannot cross an await, and holding the store for a round-trip
would freeze every note operation in the UI.

Unlink is LOCAL only — the token stays valid server-side until revoked under
Account -> Linked devices. A pasted token arrives without its device id, so
a reliable remote revoke isn't possible from here; the UI must say so rather
than imply a revoke that didn't happen. Follow-up filed.

No UI yet — that's M10.7e.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-25 23:35:51 -04:00
bvandeusenandClaude Opus 5 9118680bb1 docs: record the CI consequences of the M10.6 TLS dependency
ci-requirements.md is the contract with CI-Runner (rule 39), so the two
things a future image change could silently break belong in it:

libssl-dev + pkg-config in ci-tauri are now load-bearing — native-tls
compiles against OpenSSL on Linux, so a slim-down of that image would fail
the Rust build at openssl-sys rather than anywhere obvious.

The TLS backend choice is a property of the WINDOWS lane, not a dependency
detail: native-tls resolves to schannel on windows-msvc, keeping C/assembly
out of the cross-compile. Swapping to rustls would pull in ring/aws-lc-rs
and their assembler — the same class of dependency that broke that lane
before. Flagged so it's treated as a lane change, not a version bump.

Also documented why libssl3 is left covered TRANSITIVELY rather than
declared. dpkg-shlibdeps now lists it, and verify.sh passes it through
webkit's recursive closure. Declaring it directly would be worse, not
better: the package name is release-dependent (libssl3 on bookworm,
libssl3t64 after the time_t transition), so hardcoding it freezes the .deb
to the build distro, whereas webkit's closure adapts. verify.sh fails loudly
if webkit ever stops pulling OpenSSL, which is what makes that safe.

Docs only — triggers no workflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-25 22:53:31 -04:00
bvandeusenandClaude Opus 5 4eb92942d0 M10.6: HTTP transport for the handshake (task 1995)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m4s
Adds the client's first outbound call: GET {server}/api/config, carrying
X-ThoughtSync-Client and X-ThoughtSync-Protocol, feeding compat::evaluate.

Deliberately its OWN commit. This introduces the first HTTP+TLS stack into a
crate that cross-compiles to Windows from Linux via cargo-xwin — the lane
that has already broken once on a transitive C dependency (libsqlite3-sys
needing llvm-lib). Landing it alone means a failure here has exactly one
possible cause, instead of surfacing mid-way through M10.7's much larger
change where it would be expensive to bisect.

TLS backend is native-tls, NOT rustls, and that is the whole point of the
choice: on x86_64-pc-windows-msvc native-tls resolves to `schannel`, which
is pure-Rust bindings to the OS TLS stack, so nothing C or assembly has to
cross-compile on the fragile lane. rustls would pull in ring/aws-lc-rs and
their assembler. On Linux native-tls uses OpenSSL, whose headers ci-tauri
already ships (libssl-dev, part of Tauri's own Linux prerequisites).

Verified from run 2884's log rather than assumed: tokio and http are already
in the Windows tree via tauri, but no HTTP client and no TLS stack were —
so this genuinely is new surface there, not a no-op.

probe() distinguishes "never got a usable answer" (Err) from "answered, but
we can't work with it" (Ok + verdict). Those need very different messages:
one is "check what you typed", the other is "update something". Transport
errors are translated out of reqwest's Display, which is accurate but reads
like a stack trace.

Still no UI — M10.7 owns the link/settings surface that calls server_probe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-25 22:47:10 -04:00
bvandeusenandClaude Opus 5 4b4bfe67ad desktop: rustfmt the client-header tuple
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m55s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m49s
Applied verbatim from run 2886's cargo fmt --check diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-25 22:42:57 -04:00
bvandeusenandClaude Opus 5 fbbe877c46 M10.6: client↔server sync protocol handshake (task 1995)
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 42s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m34s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 14s
Version the sync WIRE PROTOCOL separately from either program's release
version, so a self-hosted server and the desktop app can sit on different
releases and still work out whether they can talk.

Each side declares two numbers — what it speaks, and the oldest counterpart
it accepts. Either side can therefore mark a change breaking without the
other shipping in step, which is the whole point: no app↔server lockstep.

Server advertises on the existing public /api/config (a client must be able
to ask "can I talk to you?" before it holds a device token, or even has an
account): sync_protocol_version, min_client_protocol_version, sync_features.

sync_features exists because a version number can only say newer/older. An
ADDITIVE change earns a capability name instead of a minimum bump, so a
newer client meeting an older server drops that one feature and syncs the
rest, rather than refusing. Raising a minimum is reserved for genuinely
breaking changes — it's the switch that hard-blocks the other side.

Client half is pure decision logic (sync/compat.rs), no I/O, so every branch
is unit-testable — there's no live-server lane in CI. Three outcomes: ok /
degraded{unavailable} / incompatible{reason, client_must_update}. The last
names which side can fix it, so the message is actionable. A server that
predates the handshake sends no protocol fields at all; that reads as
"update the server", deliberately not as a parse error, which would look to
the user like they mistyped the URL.

normalize_base_url defaults a bare host to https://, never http:// —
silently downgrading would put a long-lived device token on the wire in
cleartext because someone omitted five characters. Plain HTTP on a trusted
LAN stays supported; the user types http:// and thereby chooses it.

Transport (the actual fetch) lands next, separately: it needs an HTTP/TLS
stack, and that's a real risk to the Windows cross-compile lane, so it gets
its own CI run to bisect against rather than riding along with this.

No UI here by design — the link/settings surface it feeds is M10.7's, per
this task's own sequencing.

Policy documented in docs/sync.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-25 22:40:25 -04:00
bvandeusenandClaude Opus 5 5b471f5dd4 desktop: generate the Windows icon set in the cross-compile job
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m49s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m37s
tauri-build needs icons/icon.ico to emit the Windows Resource file, and the
repo only carries the PNG set the Linux bundles use — run 2881 failed with
"icons/icon.ico not found".

Generated in-job from the committed 1024px app-icon.png rather than committing
a hand-made .ico, so there stays one icon of record that can't silently drift
from the brand art. Scoped to the windows job; the Linux bundles don't need it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-25 20:37:33 -04:00
bvandeusenandClaude Opus 5 ab961f13ce desktop: cross-compiled Windows NSIS installer lane (task 2015)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 48s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m40s
Adds a `windows` job to desktop.yml on the new ci-tauri-win image, producing a
Windows -setup.exe without any Windows hardware. A Windows container can't run
on a Linux host, so cross-compilation is the only route: --runner cargo-xwin
supplies the MSVC CRT/SDK (pre-warmed into the image) and links with lld-link,
and makensis builds the installer.

NSIS only. .msi needs WiX v3, a Windows program — per Tauri, ".msi installers
can only be created on Windows". It comes back if a Windows node ever exists.

Kept as a separate job so a Windows-side failure can never block the Linux
artifacts, which are the primary product today. publish-release.sh now globs
the windows target root too; nullglob means each job uploads only what its own
workspace contains, and the release is created once and reused via the 409
path, so both jobs can publish to the same release safely.

No app code changes were needed. The AppImage self-integration UI already
gates on is_appimage (AccountView.vue:131, DesktopIntegrationPrompt.vue:22),
and $APPIMAGE is never set on Windows, so the OOBE prompt and Settings toggle
hide themselves.

Recorded plainly in ci-requirements.md that this is the weakest-verified lane
we have: Tauri calls Linux->Windows cross-compilation "not tested as much" and
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,
and installers are unsigned until a certificate exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-25 20:14:55 -04:00
bvandeusenandClaude Opus 5 dc68386d1a desktop: point the install command at a branch that exists
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m27s
The advertised curl URL referenced raw/branch/main, but main has never been
created (creating it is rejected by a branch-protection rule that matches the
name even with no branch behind it), so the one-command install 404'd. dev is
currently the repo's only branch and serves the script fine now that the repo
is public — verified 200, with all three v0.1.0 release assets resolving and
the AppImage downloading in full.

Flagged in the header to move back to main once that branch exists, so the
public install command stops tracking day-to-day work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-25 19:19:05 -04:00
216 changed files with 33304 additions and 3133 deletions
+64
View File
@@ -0,0 +1,64 @@
# ThoughtSync production settings. Copy to `.env` and edit:
#
# cp .env.example .env
#
# Only POSTGRES_PASSWORD has no default — compose refuses to start without it.
# Everything else here is optional. Anything NOT in this file (site name, signups,
# attachment limits, trash retention, link previews) is configured in the admin
# Settings UI and stored in the database, not here.
# --- required ---------------------------------------------------------------
# Generate one and keep it: changing it later means also changing it inside the
# database, or Postgres will reject the app's connection.
#
# openssl rand -base64 24 | tr -d '/+=' | head -c 32
#
# Stick to letters and digits. This value goes into a connection URL, so a `@`,
# `/`, `:` or `#` in it will be misparsed as URL structure rather than password.
POSTGRES_PASSWORD=
# --- optional ---------------------------------------------------------------
# Which build to run.
#
# latest tracks the `main` branch — the production line (default)
# dev tracks the `dev` branch — newer, less settled
# <commit sha> pins one exact build; every push publishes one, and this is
# the rollback lever when an upgrade misbehaves
#
# NOTE: `main` can sit well behind `dev`. If a feature you expect is missing,
# check which branch it actually landed on before assuming a bug.
#THOUGHTSYNC_TAG=latest
# The host port the app is published on.
#THOUGHTSYNC_PORT=5000
# Which interface to bind. The default (all interfaces) is what lets desktop
# clients on your network reach the server. Behind a reverse proxy, set this to
# 127.0.0.1 so only the proxy can talk to it.
#THOUGHTSYNC_BIND=0.0.0.0
# NOTE: how many proxies sit in front of this app is a SETTING, not an env var —
# Settings → Security → "Trusted proxy hops" in the admin UI. It defaults to 1 (one
# reverse proxy terminating HTTPS) and belongs there because it is something you may
# need to change while the server is running, alongside the sign-in limits.
# How much the app says. Credential events (sign-ins, failures, throttles, new
# accounts, device tokens issued) are logged at INFO and read with
# `docker compose logs app`.
#THOUGHTSYNC_LOG_LEVEL=INFO
# Database identity. Changing these AFTER the first start does not rename anything
# that already exists — the volume keeps whatever the first run created.
#POSTGRES_USER=thoughtsync
#POSTGRES_DB=thoughtsync
# --- a note on HTTPS --------------------------------------------------------
#
# The app marks its session cookie Secure automatically when a request arrives over
# HTTPS, directly or via a proxy setting X-Forwarded-Proto — no setting needed.
#
# Worth knowing if you use the desktop app: typing a bare hostname there defaults to
# https://, deliberately, so a device token never crosses the wire in cleartext by
# accident. Serving over plain HTTP means typing the `http://` yourself.
+307
View File
@@ -0,0 +1,307 @@
name: Android
# The native Kotlin/Compose client over the shared Rust core (M12).
#
# Replaces the Tauri-mobile lane deleted in step 2. What changed is what this
# builds, not that Android has a lane: the UI is Compose, and the store and sync
# engine are `thoughtsync-core` cross-compiled by cargo-ndk and loaded through
# uniffi.
#
# CI can only prove this BUILDS. A Linux runner cannot execute an APK, so anything
# about feel, touch or on-device correctness is an operator pass on an emulator or
# phone.
#
# The artifact is a SIGNED RELEASE APK when the keystore secret is present, and an
# unsigned debug one when it is not. That distinction is not cosmetic: two builds
# signed with different keys cannot replace one another, and bridging that gap
# means uninstalling first — which deletes the app's database and every local note
# with it (Scribe issue 2803).
on:
push:
# NO `paths:` FILTER — the `decide` job below reads the real file set instead.
# See desktop.yml for why, and 85ead4d for what the duplication cost.
branches: [dev, main]
workflow_dispatch:
concurrency:
group: android-${{ github.ref }}
cancel-in-progress: true
env:
# Silences the JDK 22+ "restricted method in java.lang.System has been called"
# warning that Gradle 9.1's bundled native-platform jar trips at launch. This
# targets the LAUNCHER JVM, which is why org.gradle.jvmargs in
# gradle.properties is not enough on its own (Minstrel hit the same thing).
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
jobs:
# Does the APK need rebuilding, or is the channel already serving this source?
# See the equivalent job in desktop.yml — same reasoning, same replacement of a
# hand-kept `paths:` filter with the one file set in `packaging/version.sh`.
#
# The guard runs here so it covers the skip path too (§6.3).
#
# NOTE THE COUPLING WITH ci.yml: when this lane builds, its last step dispatches
# ci.yml so the image bakes in the APK just published. When it SKIPS, no dispatch
# happens — and that is correct, because ci.yml's `gate` stands down only when the
# push touched Android's files, which is the same condition that makes this build.
# The two decisions agree because they read the same fact; they are still two
# readers of it, which is why the gate's grep carries a comment pointing here.
decide:
name: Build, or is the channel already serving this?
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
outputs:
build: ${{ steps.d.outputs.build }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Decide
id: d
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
case "$GITHUB_REF_NAME" in
main) channel=stable ;;
*) channel=dev ;;
esac
sh packaging/guard-forward.sh android "$channel"
echo "build=$(sh packaging/should-build.sh android "$channel")" >> $GITHUB_OUTPUT
build:
name: Kotlin + Rust (APK)
needs: [decide]
if: needs.decide.outputs.build == 'true'
# runs-on is only a scheduling label (Label Model B). flutter-ci is the
# proven-working label that can pull our container images.
runs-on: flutter-ci
container:
# The image repurposed from ci-tauri-android in M12 step 3: Rust + the four
# Android ABIs + cargo-ndk + SDK/NDK + JDK 25 + ktlint + detekt.
image: git.fabledsword.com/bvandeusen/ci-rust-android:1.97
permissions:
contents: write
# For the dispatch at the end: this lane starts the server image build.
actions: write
defaults:
run:
working-directory: android
steps:
- uses: actions/checkout@v4
with:
# Derives a version, so it needs the whole history — see the note in
# desktop.yml. Depth-1 is silently wrong here, not loudly broken (§6.1).
fetch-depth: 0
- name: Cache Gradle and Cargo
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.kotlin
target
key: android-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/*.gradle.kts', 'Cargo.lock') }}
restore-keys: |
android-
# Everything downstream keys off this: the variant to build, the Cargo
# profile to build it with, and the version it carries. Decided once so no
# two Gradle invocations in this run can disagree and force a second
# four-minute cross-compile.
- name: Signing key, variant and version
id: build
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
# TWO CLOCKS, ON PURPOSE (note 3127 §2). The NAME answers "is this the
# same code?", so it comes from the COMMIT and a dev build and the main
# build of one commit read identically. The CODE answers "may this be
# installed over that?" and must be monotonic BY CONSTRUCTION, because
# Android hard-fails a downgrade with INSTALL_FAILED_VERSION_DOWNGRADE and
# leaves a channel you cannot get out of — so it comes from BUILD time,
# which cannot go backwards. Commit time can.
version="$(sh ../packaging/version.sh display android)"
code="$(sh ../packaging/version.sh key android)"
echo "name=$version" >> $GITHUB_OUTPUT
echo "code=$code" >> $GITHUB_OUTPUT
if [ -n "${ANDROID_KEYSTORE_BASE64:-}" ]; then
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/thoughtsync-release.jks
echo "variant=Release" >> $GITHUB_OUTPUT
echo "label=release" >> $GITHUB_OUTPUT
# DEBUG profile, in a release APK, deliberately — see the note above
# the cargoNdk task. The release profile strips the symbols uniffi
# reads its metadata out of, so `generateUniffiBindings` fails
# outright (run 4077). Unpicking that is worth doing and is not worth
# blocking signed builds on.
echo "profile=debug" >> $GITHUB_OUTPUT
echo "keystore=/tmp/thoughtsync-release.jks" >> $GITHUB_OUTPUT
echo "apk=android/app/build/outputs/apk/release/app-release.apk" >> $GITHUB_OUTPUT
echo "Signed release build — $version (versionCode $code)"
else
echo "::warning::No ANDROID_KEYSTORE_BASE64 secret. Building an UNSIGNED DEBUG APK: it cannot be installed over a signed build and cannot self-update."
echo "variant=Debug" >> $GITHUB_OUTPUT
echo "label=debug" >> $GITHUB_OUTPUT
echo "profile=debug" >> $GITHUB_OUTPUT
echo "keystore=" >> $GITHUB_OUTPUT
echo "apk=android/app/build/outputs/apk/debug/app-debug.apk" >> $GITHUB_OUTPUT
fi
- name: Make gradlew executable
run: chmod +x ./gradlew
# Fails loudly here if the wrapper and the image's JDK disagree, rather
# than thirty seconds into a compile with an opaque version message.
- name: Gradle wrapper check
run: ./gradlew --version
# Cross-compiles the core for four ABIs and generates the Kotlin bindings
# from the built .so. Run as its own step so a Rust failure is legible as a
# Rust failure instead of arriving inside a Gradle stack trace.
- name: Build the native library and bindings
run: ./gradlew generateUniffiBindings -PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }}
# 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
# 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.
#
# Scoped to src/main: the generated uniffi bindings live under build/ and
# are not ours to style.
- name: ktlint
run: ktlint "app/src/main/**/*.kt"
- name: detekt
run: detekt --build-upon-default-config --config config/detekt.yml --input app/src/main/java
- name: Unit tests
# Host-JVM tests only. Anything touching the core needs an Android
# runtime to load the .so, so those are instrumented tests and belong on
# an emulator, not here — the Rust side is covered by the workspace
# tests in the desktop lane.
#
# DEBUG regardless of what is being packaged: AGP creates unit-test tasks
# only for `testBuildType`, which is debug, so `testReleaseUnitTest` does
# not exist (run 4082). It costs one extra Kotlin compile and buys the
# type-check on the debug variant, which is the one an emulator build
# would use.
run: ./gradlew testDebugUnitTest -PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }}
- name: Assemble the APK
env:
# Empty on the unsigned path, which build.gradle.kts reads as "no
# signing config" rather than as a path to a missing file.
ANDROID_KEYSTORE_FILE: ${{ steps.build.outputs.keystore }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
run: |
./gradlew assemble${{ steps.build.outputs.variant }} \
-PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }} \
-PTHOUGHTSYNC_VERSION_NAME=${{ steps.build.outputs.name }} \
-PTHOUGHTSYNC_VERSION_CODE=${{ steps.build.outputs.code }}
# Prints the certificate the APK was actually signed with, so the operator
# can compare it against the fingerprint recorded when the key was
# generated. Signing with the WRONG key produces a perfectly valid APK that
# simply refuses to install over the app already on the phone — a failure
# that otherwise only shows up on the device, after the run is green.
- name: Show the signing certificate
if: steps.build.outputs.keystore != ''
run: |
apksigner="$(ls /opt/android-sdk/build-tools/*/apksigner | head -1)"
"$apksigner" verify --print-certs "app/build/outputs/apk/release/app-release.apk"
# Staged with a STABLE name plus the sidecar the server reads its version
# out of — an APK keeps that in a binary manifest Python cannot parse, and
# `aapt` is not on a Quart server. Computed here, where the real values are
# already known.
- name: Stage the client for distribution
if: steps.build.outputs.keystore != ''
run: |
mkdir -p dist
cp "app/build/outputs/apk/release/app-release.apk" dist/thoughtsync.apk
size="$(wc -c < dist/thoughtsync.apk | tr -d ' ')"
sha="$(sha256sum dist/thoughtsync.apk | cut -d' ' -f1)"
cat > dist/thoughtsync-android.json <<JSON
{
"version_name": "${{ steps.build.outputs.name }}",
"version_code": ${{ steps.build.outputs.code }},
"size": $size,
"sha256": "$sha"
}
JSON
cat dist/thoughtsync-android.json
# The rolling channel for this branch, the same fixed-tag releases the desktop
# bundles use. CI artifacts are per-run and auth-gated, so they are no use as a
# fetch target; a release asset has a permanent URL. Only ever a SIGNED build —
# publishing an unsigned APK would offer people something they cannot install
# over what they already have.
#
# `stable` from main is new in M314 step 3, and it is what lets the server image
# bake in a client that matches its own channel: a :latest image fetches the APK
# from `stable`, a :dev image from `dev`. Before this, main published no APK at
# all and every image — stable included — baked in the dev one.
- name: Publish to the channel for this branch
if: (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && steps.build.outputs.keystore != ''
working-directory: .
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
case "$GITHUB_REF_NAME" in
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
esac
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing the APK to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh
- name: Upload the APK
# Mirrored action, never actions/upload-artifact. @v4+ throws
# GHESNotSupportedError client-side on this hostname, and @v3 is worse —
# it reports success while Gitea serves artifacts back only through the
# v4 API, so the upload is stored and invisible. Pinned by SHA because
# the mirror auto-syncs; full URL because DEFAULT_ACTIONS_URL sends bare
# owner/repo to github.com. See Scribe issues 2255 / 2270.
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
with:
# The APK's variant, NOT the Cargo profile — those are the same word
# for different things and the profile is pinned to debug (#2810).
name: thoughtsync-android-${{ steps.build.outputs.label }}-${{ github.sha }}
path: ${{ steps.build.outputs.apk }}
if-no-files-found: error
# The server image bakes in whatever client the dev release holds, so it has
# to be built AFTER this lane, not alongside it. `ci.yml` stands down on any
# push that touches the Android app (its `gate` job) and waits to be called
# from here — that is the other half of this.
#
# `always()`: a FAILED Android build must still let the server image through.
# There is no new client in that case, so it bakes in the previous one, which
# is exactly right — the alternative is a broken Android lane silently
# blocking server delivery.
#
# Not `if: success()` and not skipped on tags either: every ref that builds an
# image needs the call, or nothing builds one at all.
- name: Build the server image now the client is published
if: always() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
working-directory: .
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
# Loud on failure rather than `|| true`: if this call stops working, the
# symptom is server images silently never being built for Android pushes,
# which is invisible until someone wonders why the app never updates.
curl -fsS -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Content-Type: application/json" \
-d '{"ref":"${{ github.ref_name }}"}' \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/workflows/ci.yml/dispatches"
echo "Dispatched ci.yml on ${{ github.ref_name }}."
+273 -38
View File
@@ -1,12 +1,21 @@
# CI runs first; build only proceeds if lint + typecheck pass.
#
# Push to dev: typecheck + lint + test + build :dev + :<sha>
# Push to main: typecheck + lint + test + build :latest + :<sha>
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
# Push to dev: typecheck + lint + test + build :dev
# Push to main: typecheck + lint + test + build :latest + :<sha>
#
# main is the production line, so a merge to main rebuilds and moves :latest to its
# tip (family rule 46) — no version release required. The :<sha> image is the
# immutable rollback unit for every build.
# THAT IS THE COMPLETE TAG SET (rule 145). No version-shaped image tag in any lane:
# nothing pins one — verified by looking for a consumer, not for whether one is
# imaginable — and the git release tag is a different object in a different system
# (step 7). The image is addressed by CHANNEL or by COMMIT; the release by date.
#
# A `v*` tag builds nothing at all. The merge to main already published everything,
# so a tag rebuilding that same source would re-push :<sha> with different bytes,
# which rule 145 forbids even when they match.
#
# main is the production line, so a merge moves :latest to its tip (family rule 46)
# — no version release required. :<sha> is the immutable rollback unit, and it is
# on main ONLY: a sha tag per dev push is a rollback target nobody has ever pulled,
# accumulating forever, for a channel whose entire contract is that it moves.
#
# Required secret (repo -> Settings -> Secrets -> Actions):
# REGISTRY_TOKEN -- Forgejo PAT with write:packages scope
@@ -16,23 +25,29 @@ name: CI & Build
on:
push:
# NO `paths:` FILTER, and unlike the client lanes this one does not skip either —
# the image ALWAYS builds. Two reasons:
#
# * Rule 145 promises that every push to `main` publishes a `:<sha>`, so any
# production commit is addressable. A path filter quietly broke that promise
# for a docs-only merge: no trigger, no image, no sha tag for that commit.
# * It is the artifact most exposed to base-image staleness (`python:3.12-slim`
# is a floating tag and this can face the internet), and building every push
# picks those updates up. That is why note 3127 §4's base tension does not
# bite here — the one artifact it would apply to never skips.
#
# 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.
branches: [dev, main]
tags: ["v*"]
paths:
- "src/**"
- "frontend/**"
- "tests/**"
- "pyproject.toml"
- "alembic/**"
- "alembic.ini"
- "Dockerfile"
- ".forgejo/workflows/ci.yml"
# Dispatched by the Android lane once it has published a client, so the image
# that bakes it in is built AFTER the APK exists rather than racing it. See the
# `gate` job below for the other half.
workflow_dispatch:
# Cancel older runs on the same branch when a newer push lands. Tag runs get their
# own group implicitly and are never cancelled.
# Cancel older runs on the same branch when a newer push lands.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
cancel-in-progress: true
permissions:
contents: read
@@ -42,9 +57,95 @@ env:
IMAGE: git.fabledsword.com/bvandeusen/thoughtsync
jobs:
# Should this push build an image now, or is the Android lane about to publish a
# client that the image ought to contain?
#
# A push touching the Android app runs BOTH workflows at once. Building here
# would bake in the PREVIOUS client and then, when the new one landed, there
# would be no second build — `:<sha>` is the immutable rollback unit (rule 46)
# and rebuilding it with different content would make it neither.
#
# So on such a push this workflow stands down, and the Android lane dispatches it
# when it is finished. Exactly one image per commit, containing the client from
# that commit.
#
# The path list below MUST match android.yml's trigger. Two places holding one
# decision is the recurring failure in this repo (issues 2181-2183); it is here
# because a workflow cannot read another's filters, and it is a `git diff` rather
# than a config so at least it is inspectable in the log.
gate:
name: Build now, or wait for Android?
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
outputs:
build: ${{ steps.decide.outputs.build }}
steps:
- uses: actions/checkout@v6
with:
# Full history: the diff below spans the whole PUSHED RANGE, not just the
# tip. A push of three commits whose Android change sits in the first
# would otherwise look Android-free, and the race this job exists to
# prevent would happen anyway — silently, which is the worst version.
fetch-depth: 0
- name: Decide
id: decide
run: |
# A dispatched run IS the Android lane calling back. Always build.
if [ "${{ github.event_name }}" != "push" ]; then
echo "Dispatched by the Android lane — building."
echo "build=true" >> $GITHUB_OUTPUT
exit 0
fi
# No parent (first commit, or a force-push that orphaned it) — nothing to
# compare, so build rather than stall.
if ! git rev-parse --verify -q HEAD^ >/dev/null; then
echo "No parent commit to diff against — building."
echo "build=true" >> $GITHUB_OUTPUT
exit 0
fi
# The whole push, not just its tip. `before` is what the ref pointed at
# beforehand; it is absent or all-zeros for a brand-new branch, and may
# be unreachable after a force-push — fall back to the tip commit then.
before="${{ github.event.before }}"
if [ -n "$before" ] \
&& [ "$before" != "0000000000000000000000000000000000000000" ] \
&& git cat-file -e "$before^{commit}" 2>/dev/null; then
range="$before..HEAD"
else
range="HEAD^..HEAD"
fi
echo "Comparing $range"
changed="$(git diff --name-only $range)"
echo "Changed in this push:"
echo "$changed" | sed 's/^/ /'
# MUST match android's file set in packaging/version.sh. `packaging/` was
# missing here after step 4 added it there — so a packaging-only push had
# the Android lane rebuild and dispatch while this gate ALSO let the image
# build, producing two images for one commit and, on main, a second push of
# the same :<sha> with different bytes. Rule 145's exact prohibition.
if echo "$changed" | grep -qE '^(android/|core/|packaging/|Cargo\.toml$|Cargo\.lock$|\.forgejo/workflows/android\.yml$)'; then
echo ""
echo "This push also changes the Android client. Standing down: the"
echo "Android lane will publish a new APK and dispatch this workflow,"
echo "so the image is built once, with the client from this commit."
echo "build=false" >> $GITHUB_OUTPUT
else
echo ""
echo "No Android change — the newest published client is already the"
echo "right one to bake in. Building."
echo "build=true" >> $GITHUB_OUTPUT
fi
typecheck:
name: TypeScript typecheck
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -61,7 +162,7 @@ jobs:
lint:
name: Python lint
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -74,7 +175,7 @@ jobs:
test:
name: Python tests
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -88,15 +189,93 @@ jobs:
run: uv pip install --python /opt/venv/bin/python -e ".[dev]"
- name: Run tests
run: /opt/venv/bin/python -m pytest tests/ -q
# DB-free by design. Anything needing a real Postgres is marked `integration`
# and runs in the job below.
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration"
# Real-Postgres lane (family rule 6). Until this existed, `alembic upgrade head` ran
# for the first time when the operator's container started — 26 revisions, none of
# them ever executed by CI — and the schema the migrations build had never been
# checked against the models that read it.
#
# 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
# derives the service-container name from the truncated job display name, and the
# discovery step below filters `docker ps` by it. Service hostnames are not routable
# on this runner (rule 79), so the step resolves the container's bridge IP.
integration:
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
services:
postgres:
# Same image the production compose runs, so the schema is proven against the
# Postgres it will actually meet.
image: postgres:16-alpine
env:
POSTGRES_USER: thoughtsync
POSTGRES_PASSWORD: ci_integration
POSTGRES_DB: thoughtsync_test
options: >-
--health-cmd "pg_isready -U thoughtsync"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v6
- name: Create virtual environment
run: uv venv /opt/venv
# Same install as the unit lane — the two must agree on versions, or
# "unit green, integration red" stops being a signal about the code.
- name: Install package with dev deps
run: uv pip install --python /opt/venv/bin/python -e ".[dev]"
- name: Integration suite (resolve service IP, migrate, test)
run: |
set -eux
echo "=== container landscape (diagnostic for the name filter) ==="
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
PG=$(docker ps --filter "name=integration" --filter "ancestor=postgres:16-alpine" -q | head -n1)
test -n "$PG"
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
test -n "$PG_IP"
export THOUGHTSYNC_DATABASE_URL="postgresql+asyncpg://thoughtsync:ci_integration@${PG_IP}:5432/thoughtsync_test"
# Wait for Postgres to accept connections. `run:` is busybox sh (rule 81) —
# no bash /dev/tcp — so use the Python that is always present here.
/opt/venv/bin/python - "$PG_IP" <<'PY'
import socket, sys, time
for _ in range(30):
try:
socket.create_connection((sys.argv[1], 5432), timeout=2).close()
break
except OSError:
time.sleep(1)
else:
sys.exit("postgres did not become reachable")
PY
# Real migrations build the schema, never metadata.create_all (rule 82) —
# testing a schema no deployment has ever seen would prove nothing. This
# step IS the migration test: a broken revision fails the job here.
/opt/venv/bin/alembic upgrade head
/opt/venv/bin/python -m pytest tests/ -v -m integration
build:
name: Build & push image
# Build gates on lint + typecheck. The `test` job runs in parallel for
# visibility but does not block dev image builds (DB-backed integration
# testing happens against the dev image manually, not on every push).
needs: [typecheck, lint]
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
# Every lane gates the build. This once stopped at lint + typecheck, on the
# reasoning that DB-backed testing happened manually against the dev image
# rather than on every push — true until 6f21db8 added the integration lane,
# and false since.
#
# What that gap cost: run 4293 failed `test` and published :dev and :<sha>
# anyway, so the deployed server ran a build whose test lane was red. An image
# tag is the rollback substrate (family rule 46); one that can be published
# from a failing run is not a substrate you can roll back TO.
needs: [gate, typecheck, lint, test, integration]
if: needs.gate.outputs.build == 'true'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -105,27 +284,35 @@ jobs:
packages: write
steps:
- uses: actions/checkout@v6
with:
# Derives a version — see the note in desktop.yml. Depth-1 sees one commit
# and produces a too-low value silently, with the lane green (§6.1).
fetch-depth: 0
- name: Generate image tags and version
id: tags
# run: steps execute under busybox sh (family rule 81), so use POSIX `case`,
# NOT bash `[[ ]]`.
run: |
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
BUILD_VERSION="dev"
# The image's version is DERIVED from its own shipped files — including the
# Android client it bakes in, which is why an APK-only change re-versions
# it. One value and no ordering key: nothing compares a server image, so
# §2 says do not invent one just because the other artifacts have one.
#
# This was a short sha on main and the literal "dev" elsewhere, which could
# not answer "how old is this instance?" — the question that actually gets
# asked of a self-hosted app running in several places.
BUILD_VERSION="$(sh packaging/version.sh display server)"
case "${{ github.ref }}" in
refs/heads/dev)
TAGS="$TAGS,${{ env.IMAGE }}:dev"
TAGS="${{ env.IMAGE }}:dev"
;;
refs/heads/main)
# Production line: :latest tracks main's tip (rule 46). No :main tag;
# the :<sha> above is the rollback unit. Version label = short sha.
TAGS="$TAGS,${{ env.IMAGE }}:latest"
BUILD_VERSION="$(echo ${{ github.sha }} | cut -c1-7)"
TAGS="${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.sha }}"
;;
refs/tags/*)
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
BUILD_VERSION="${{ github.ref_name }}"
*)
echo "::error::This lane builds images for dev and main only."
exit 1
;;
esac
echo "value=$TAGS" >> $GITHUB_OUTPUT
@@ -136,6 +323,54 @@ jobs:
docker system prune -af || true
docker builder prune --keep-storage 5g -f || true
# Bake the Android client in, on EVERY image build, so :dev, :latest and
# :<version> all carry one and a `docker compose pull` delivers a new client
# along with the new server.
#
# Always the rolling `dev` release — the newest build there is. A versioned
# image therefore carries the newest client rather than one pinned to that
# version; the two negotiate a sync protocol version before linking, so
# "newest" is safe in a way "matching" would not buy anything over.
#
# Fetched by the JOB, not by the Dockerfile: the release is private, and a
# token used inside a build lands in the context or a layer.
#
# NEVER fails the build. An image with no Android client advertises none and
# hides the download — a supported state, and the only one available before
# the first Android build has ever published.
- name: Fetch the Android client to bake in
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
mkdir -p client
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves the dev
# client; :latest serves the stable one. This read `download/dev`
# unconditionally until M314 step 3, on every branch — so every stable
# server shipped a dev-channel APK to anyone who downloaded the client
# from it. Not a versioning gap; a plain defect, fixed here because this
# is the step that gave `stable` an APK to point at.
case "${{ github.ref_name }}" in
main) channel=stable ;;
*) channel=dev ;;
esac
echo "Baking in the $channel client."
base="${{ github.server_url }}/${{ github.repository }}/releases/download/$channel"
ok=1
for f in thoughtsync.apk thoughtsync-android.json; do
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "client/$f" "$base/$f" || ok=0
done
if [ "$ok" = 1 ]; then
echo "Baking in:"
cat client/thoughtsync-android.json
ls -l client/thoughtsync.apk
else
# Both or neither. Half a pair is worse than none: the server would
# read a sidecar describing an APK that isn't there, or an APK it
# cannot state a version for.
echo "::warning::No Android client on the dev release — this image ships without one."
rm -f client/thoughtsync.apk client/thoughtsync-android.json
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
+375 -40
View File
@@ -1,6 +1,13 @@
# Tauri desktop (Linux) build — SEPARATE from ci.yml on purpose: this is a heavy
# Rust + AppImage build (~20-40 min) that should NOT run on backend/frontend-only
# pushes. Scoped to desktop/** (+ this file). Produces the .deb and .AppImage.
# Tauri desktop (Linux) build — SEPARATE from ci.yml on purpose: a Rust + AppImage
# build that shouldn't run on server-only pushes. Produces the .deb and .AppImage.
#
# It DOES run on frontend changes. tauri's generate_context! embeds the built
# frontend in the binary, so a frontend commit that never triggers this ships to
# the web and silently never reaches the desktop app — and desktop, web and Android
# are peer surfaces held to one quality bar, not a primary and its fallbacks. The
# filter was once narrowed to the adapter/bridge directories against a "~20-40 min"
# build; measured runs are 4-5 minutes, so the cost that justified the narrowing
# isn't there.
#
# 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
@@ -9,20 +16,20 @@ name: Desktop (Tauri)
on:
push:
# NO `paths:` FILTER. It was a second, independent statement of this artifact's
# file set, hand-kept beside the one in `packaging/version.sh`, and it drifted
# from it within a day (85ead4d). The `decide` job below reads the real set and
# skips in seconds when nothing moved — one definition, one reader (§3).
#
# 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
# silently fail to run.
branches: [dev, main]
tags: ["v*"]
paths:
- "desktop/**"
# The desktop app embeds the frontend, and the data seam / Tauri bridge are
# what the offline core rides on — rebuild the app when those change too.
- "frontend/src/adapters/**"
- "frontend/src/desktop/**"
- ".forgejo/workflows/desktop.yml"
workflow_dispatch:
concurrency:
group: desktop-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
cancel-in-progress: true
permissions:
# write (not read) so the tag build can publish a Release with the bundles
@@ -31,9 +38,47 @@ permissions:
contents: write
jobs:
# Does anything need building at all?
#
# ONE reader of ONE definition — the file sets in `packaging/version.sh` — replacing
# the `paths:` filters that used to state the same fact a second time. They drifted
# from it within a day: `packaging/` was added to the sets and not to the filters,
# so the commit fixing a derivation bug never ran on the two lanes it fixed
# (85ead4d). Note 3127 §3 warns about exactly that duplication.
#
# THE GUARD RUNS HERE, so it runs on every path INCLUDING the skip one (§6.3).
# Skipping because "the channel already serves this version" is indistinguishable
# from "we derived a stale value that happens to match" unless something checks.
decide:
name: Build, or is the channel already serving this?
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
outputs:
build: ${{ steps.d.outputs.build }}
steps:
- uses: actions/checkout@v6
with:
# Derives a version — depth-1 is silently wrong (§6.1).
fetch-depth: 0
- name: Decide
id: d
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
case "$GITHUB_REF_NAME" in
main) channel=stable ;;
*) channel=dev ;;
esac
sh packaging/guard-forward.sh desktop "$channel"
echo "build=$(sh packaging/should-build.sh desktop "$channel")" >> $GITHUB_OUTPUT
build:
name: Tauri desktop (Linux)
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
needs: [decide]
if: needs.decide.outputs.build == 'true'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
@@ -44,6 +89,14 @@ jobs:
APPIMAGE_EXTRACT_AND_RUN: "1"
steps:
- uses: actions/checkout@v6
with:
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
# the lane green — note 3127 §6.1, and the direction you cannot recover
# from. `packaging/version.sh` fails loudly on an empty result rather than
# emitting something plausible, which is what turns this into a red lane
# if it is ever dropped.
fetch-depth: 0
# tauri's generate_context! embeds the built frontend at compile time, so the
# frontend must exist before any cargo compile (clippy/test/build), not just
@@ -52,21 +105,67 @@ jobs:
run: npm ci && npm run build
working-directory: frontend
- name: Rust format check
run: cargo fmt --check
working-directory: desktop/src-tauri
# --locked on the FIRST cargo invocation of the job is the lockfile gate: it
# fails the run if Cargo.toml and the committed Cargo.lock disagree, instead
# of silently re-resolving. Everything after it in this job then compiles the
# exact versions recorded in the lockfile, so the flag isn't repeated on the
# bundle build (issue 2102).
#
# Run from the REPO ROOT with --workspace, not from desktop/src-tauri.
#
# These three steps used to run inside the desktop crate, which was right when
# it was the only Rust in the repo. After the core was extracted (M12 step 1)
# it silently stopped being right: cargo scoped to the desktop PACKAGE, so the
# core's 89 tests stopped running and nothing lints the Android uniffi shim at
# all. Both crates are dependencies of the desktop, so they still COMPILED —
# which is exactly why the gap was invisible, and why a green run kept meaning
# less than it looked like it meant.
- name: Clippy
run: cargo clippy --all-targets -- -D warnings
working-directory: desktop/src-tauri
run: cargo clippy --locked --workspace --all-targets -- -D warnings
- name: Test
run: cargo test
working-directory: desktop/src-tauri
run: cargo test --locked --workspace
# Deliberately AFTER clippy + test, not before.
#
# It's the cheapest check, so fail-fast ordering would normally put it first —
# but there is no Rust toolchain on the workstation (the desktop lane is
# verified entirely here), so a formatting nit failing first SKIPS clippy and
# the tests, and one CI cycle teaches nothing but whitespace. Running it here
# means every push reports its real problems too. Still before the ~20-40 min
# bundle build, so a fmt failure doesn't burn that.
- name: Rust format check
run: cargo fmt --all --check
# Frontend already built above; skip the beforeBuildCommand rebuild.
#
# createUpdaterArtifacts is applied only when a signing key exists (M10.9):
# tauri FAILS the build if it's asked to produce updater artifacts with no key,
# so making it conditional is what lets the pipeline stay green before the
# operator has added the secret. With the key present, each bundle gets a
# `.sig` beside it — the file the updater actually verifies against.
- name: Tauri build (deb + AppImage)
run: cargo tauri build --config '{"build":{"beforeBuildCommand":""}}'
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
updater='{}'
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
echo "Signing key present — producing updater artifacts."
updater='{"bundle":{"createUpdaterArtifacts":true}}'
else
echo "No TAURI_SIGNING_PRIVATE_KEY — building unsigned, no updater artifacts."
fi
# The ORDERING KEY, not the display version: this string is what Tauri's
# updater parses as semver, and what it stamps into bundle FILENAMES that
# `write-manifest.sh` then selects on. The human-readable version is a
# separate value and arrives with the UI that shows it (#3181).
version="$(sh ../../packaging/version.sh key desktop)"
echo "Building desktop ordering key $version"
cargo tauri build \
--config '{"build":{"beforeBuildCommand":""}}' \
--config "{\"version\":\"$version\"}" \
--config "$updater"
working-directory: desktop/src-tauri
# Tauri's AppImage bundles the build host's graphics/display libs
@@ -78,6 +177,28 @@ jobs:
- name: De-bundle AppImage graphics libraries
run: bash desktop/packaging/appimage/debundle-graphics.sh
# MUST run after de-bundling, not before. The step above DELETES the AppImage
# and repackages it, so the signature tauri produced during the build now
# describes a file that no longer exists. Publishing that stale .sig would make
# every Linux update fail verification — and the error names a signature
# mismatch, which points nowhere near "a later build step rewrote the file".
# Windows needs no equivalent: nothing post-processes the NSIS installer.
- name: Re-sign the de-bundled AppImage
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
echo "No signing key — the build produced no signature to replace."
exit 0
fi
appimage="$(find target/release/bundle/appimage -name '*.AppImage' -type f | head -1)"
[ -n "$appimage" ] || { echo "ERROR: no AppImage found to re-sign" >&2; exit 1; }
rm -f "$appimage.sig"
cargo tauri signer sign "$appimage"
[ -s "$appimage.sig" ] || { echo "ERROR: re-signing produced no .sig" >&2; exit 1; }
echo "Re-signed $(basename "$appimage")"
# install.sh hands the .deb to every Debian/Ubuntu user, so the package's
# Depends must be right BEFORE a release exists. Prints the generated
# control file and cross-checks it against what the ELF actually needs
@@ -101,28 +222,242 @@ jobs:
run: bash desktop/packaging/arch/package-prebuilt.sh
# Make the built .deb + .AppImage downloadable from the run (for hand-testing).
# continue-on-error: the Forgejo artifact backend may not be configured yet; a
# failed upload must not fail the build itself.
# Forgejo doesn't support the v4 artifact protocol (@actions/artifact v2+),
# so pin v3, which uses the older protocol the instance accepts.
# Mirrored action, never actions/upload-artifact: @v4+ throws
# GHESNotSupportedError on the hostname before it connects, and @v3 uploads
# something Gitea stores but will never serve back (it returns artifacts only
# through the v4 API, which filters on content_encoding='application/zip').
# Pinned by SHA — the mirror auto-syncs, so a moved upstream tag would
# silently change what runs. See Scribe issues 2255 / 2270.
# No continue-on-error: a swallowed upload failure is exactly how 110
# unreachable artifacts accumulated here unnoticed. Fail loudly instead.
- name: Upload bundles
continue-on-error: true
uses: actions/upload-artifact@v3
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
with:
name: thoughtsync-linux
path: |
desktop/src-tauri/target/release/bundle/appimage/*.AppImage
desktop/src-tauri/target/release/bundle/deb/*.deb
desktop/src-tauri/target/release/bundle/arch/*.pkg.tar.*
if-no-files-found: warn
target/release/bundle/appimage/*.AppImage
target/release/bundle/deb/*.deb
target/release/bundle/arch/*.pkg.tar.*
# error, not warn: a build that bundles nothing should report as a
# failure, not as a green run with an empty artifact.
if-no-files-found: error
# Tag builds only: publish a real, versioned Fabled-Git Release with the
# AppImage + .deb attached — the stable fetch target the install script and
# the in-app updater consume (Actions artifacts above are ephemeral/test).
# Cutting the tag is the operator's action (rule 2); this only publishes a
# Release for a tag that already exists. Dormant on dev/main pushes.
- name: Publish release
if: startsWith(github.ref, 'refs/tags/v')
# The rolling channel for this branch: `dev` from dev, `stable` from main. Both
# are releases whose tag never moves, so the updater has a permanent URL to
# read — Forgejo has no /releases/latest/download/<asset> route, so "newest"
# cannot be named in a URL.
#
# MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until
# M314 step 3 this job built on main and published nothing, so the stable
# channel moved only when somebody cut a tag — that section's diagnostic
# failing outright: main publishing was not sufficient for a user to receive
# the build.
#
# 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
# bundles the app would then refuse to verify is worse than publishing nothing:
# it looks like a working feed.
- name: Publish to the channel for this branch
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
env:
GITHUB_TOKEN: ${{ github.token }}
run: bash desktop/packaging/publish-release.sh
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
run: |
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
exit 0
fi
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
# `prerelease` is true for dev so it does not read as a supported build,
# and false for stable, which is the real thing.
case "$GITHUB_REF_NAME" in
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
esac
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh
# Windows installer, CROSS-COMPILED from Linux — there is no Windows build host.
# A Windows container can't run on a Linux host (containers share the host
# kernel), so cross-compiling is the only route without Windows hardware:
# cargo-xwin + LLVM's lld-link + makensis are Linux programs that emit Windows
# PE output. That toolchain is why this needs its own image rather than ci-tauri.
#
# NSIS only. `.msi` needs WiX v3, which is a Windows program — Tauri: ".msi
# installers can only be created on Windows". It returns if a Windows node does.
#
# A separate job, so a Windows-side failure never blocks the Linux artifacts that
# are the primary product today. Tauri calls this path "not tested as much" and a
# last resort, and nothing here can LAUNCH a Windows binary — green means it
# built, not that it runs. A real-machine check stays mandatory before trusting it.
windows:
name: Windows installer (cross-compiled)
needs: [decide]
if: needs.decide.outputs.build == 'true'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-tauri-win:1.97
steps:
- uses: actions/checkout@v6
with:
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
# the lane green — note 3127 §6.1, and the direction you cannot recover
# from. `packaging/version.sh` fails loudly on an empty result rather than
# emitting something plausible, which is what turns this into a red lane
# if it is ever dropped.
fetch-depth: 0
# Same reason as the Linux job: generate_context! embeds the built frontend
# at compile time, so it must exist before cargo runs.
- name: Build the shared frontend
run: npm ci && npm run build
working-directory: frontend
# tauri-build generates a Windows Resource file and needs `icons/icon.ico`,
# which the repo doesn't carry — only the PNG set the Linux bundles use.
# Generating it from the committed 1024px source keeps one icon of record
# instead of a hand-made .ico that could silently drift from the brand art.
# Linux doesn't need this step, which is why it lives here and not in `build`.
- name: Generate the Windows icon set
run: cargo tauri icon app-icon.png
working-directory: desktop/src-tauri
# This lane's lockfile gate (the Linux job gets it from `cargo clippy
# --locked`). It has to be its own step here because the build is this job's
# only crate-graph command, and discovering the drift 30 minutes into a
# cross-compile is the expensive way to learn it. Fetching for the Windows
# target also pre-warms exactly the crates the build will want.
- name: Verify the lockfile and fetch dependencies
run: cargo fetch --locked --target x86_64-pc-windows-msvc
working-directory: desktop/src-tauri
# --runner cargo-xwin swaps cargo for the cross-compiling driver (it supplies
# the MSVC CRT/SDK, pre-warmed into the image, and links with lld-link).
# Frontend already built above; skip the beforeBuildCommand rebuild.
- name: Tauri build (NSIS installer)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
# The ORDERING KEY, not the display version: this string is what Tauri's
# updater parses as semver, and what it stamps into bundle FILENAMES that
# `write-manifest.sh` then selects on. The human-readable version is a
# separate value and arrives with the UI that shows it (#3181).
version="$(sh ../../packaging/version.sh key desktop)"
echo "Building desktop ordering key $version"
updater='{}'
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
updater='{"bundle":{"createUpdaterArtifacts":true}}'
fi
cargo tauri build \
--runner cargo-xwin \
--target x86_64-pc-windows-msvc \
--bundles nsis \
--config '{"build":{"beforeBuildCommand":""}}' \
--config "{\"version\":\"$version\"}" \
--config "$updater"
working-directory: desktop/src-tauri
# Mirrored action, never actions/upload-artifact — see the Linux job's
# Upload bundles step for the full reasoning. Pinned by SHA because the
# mirror auto-syncs.
- name: Upload installer
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
with:
name: thoughtsync-windows
path: target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
if-no-files-found: error
# The rolling channel for this branch: `dev` from dev, `stable` from main. Both
# are releases whose tag never moves, so the updater has a permanent URL to
# read — Forgejo has no /releases/latest/download/<asset> route, so "newest"
# cannot be named in a URL.
#
# MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until
# M314 step 3 this job built on main and published nothing, so the stable
# channel moved only when somebody cut a tag — that section's diagnostic
# failing outright: main publishing was not sufficient for a user to receive
# the build.
#
# 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
# bundles the app would then refuse to verify is worse than publishing nothing:
# it looks like a working feed.
- name: Publish to the channel for this branch
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
env:
GITHUB_TOKEN: ${{ github.token }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
run: |
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
exit 0
fi
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
# `prerelease` is true for dev so it does not read as a supported build,
# and false for stable, which is the real thing.
case "$GITHUB_REF_NAME" in
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
esac
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh
# The updater manifest, written AFTER both bundle jobs — they run in separate
# workspaces and neither can see the other's output, but one latest.json has to
# describe both platforms. Building it inside either job would silently omit the
# other, and a missing platform reads to a user as "no update available" rather
# than as a broken feed.
#
# Reads what actually landed on the channel release, so it can never advertise a
# bundle that failed to upload.
manifest:
name: Update manifest
needs: [build, windows]
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
steps:
- uses: actions/checkout@v6
with:
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
# the lane green — note 3127 §6.1, and the direction you cannot recover
# from. `packaging/version.sh` fails loudly on an empty result rather than
# emitting something plausible, which is what turns this into a red lane
# if it is ever dropped.
fetch-depth: 0
- name: Write and publish latest.json
env:
GITHUB_TOKEN: ${{ github.token }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
run: |
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
echo "No TAURI_SIGNING_PRIVATE_KEY — nothing was signed, so there is no"
echo "manifest to write. Add the secret to enable in-app updates."
exit 0
fi
# The SAME helper AND the same request the bundles were built with — a
# second derivation here could drift, and a manifest whose version doesn't
# match the binary it points at is an updater that never settles. It must
# be `key`: this value is matched against bundle filenames.
version="$(sh packaging/version.sh key desktop)"
# Both channels are rolling: the manifest lands on the same release that
# holds the bundles, and the previous build's bundles are dropped once it
# points at this one. Nothing can reach them, and they are ~100 MB a push.
#
# No tag arm any more. A `v*` tag does not reach this workflow at all — it
# triggers release.yml, which writes a changelog and builds nothing.
case "${GITHUB_REF_NAME}" in
main) export RELEASE_TAG=stable
export RELEASE_NOTES="Stable build from ${GITHUB_SHA}" ;;
*) export RELEASE_TAG=dev
export RELEASE_NOTES="Development build from ${GITHUB_SHA}" ;;
esac
export PRUNE_OLD_ASSETS=true
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
+68
View File
@@ -0,0 +1,68 @@
name: Release
# A RELEASE BUILDS NOTHING. That is the whole point of this lane (M314 step 7).
#
# The merge to `main` already published everything a user can receive: the server
# image as `:latest` + `:<sha>`, the desktop bundles and the APK to the `stable`
# channel, and the updater manifest that advertises them. A tag rebuilding that same
# source would produce identical artifacts under identical names, and would re-push
# `:<sha>` with different bytes — which rule 145 forbids even when they match.
#
# 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
# the version answers only the first:
#
# which build is this? the footer, /api/config, the APK's versionName
# what changed since the one ← this
# I was running last month?
#
# Cutting the tag is the operator's act (rule 2). This only responds to one.
#
# THE TAG IS NOT AN IMAGE TAG and never becomes one. `ci.yml` does not trigger on
# tags at all. The image is addressed by channel or by commit; the release by date.
# Same string as the artifact version (rule 148, `vYYYY.MM.DD.HHMM`), different
# system.
on:
push:
tags: ["v*"]
permissions:
contents: write
jobs:
notes:
name: Write the changelog
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
- uses: actions/checkout@v6
with:
# The whole history AND every tag: the notes are the commit range between
# this tag and the previous `v*` one, and neither end exists in a shallow
# clone. A depth-limited checkout here does not fail — it produces a
# shorter changelog, which is the kind of wrong nobody notices.
fetch-depth: 0
- name: Publish the release notes
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
notes="$(sh packaging/release-notes.sh "$GITHUB_REF_NAME")"
echo "$notes"
echo "---"
# JSON-escaped HERE rather than in publish-release.sh, which cannot assume
# python3 is on PATH in the three images that call it. `json.dumps` then
# strip the surrounding quotes — the script supplies those.
RELEASE_BODY_JSON="$(printf '%s' "$notes" \
| python3 -c 'import json,sys; print(json.dumps(sys.stdin.read())[1:-1])')"
export RELEASE_BODY_JSON
# Through publish-release.sh for its create-or-PATCH-on-409 path: a
# release that is only ever POSTed keeps whatever body its first run
# wrote (#2182), so re-tagging or re-running must rewrite it. No bundles
# exist in this workspace, so its asset globs match nothing and it
# uploads none — which is the intended behaviour, not a side effect.
RELEASE_TAG="$GITHUB_REF_NAME" bash desktop/packaging/publish-release.sh
+37
View File
@@ -174,3 +174,40 @@ cython_debug/
# PyPI configuration file
.pypirc
# Rust workspace build output (one target dir for core + desktop + android)
/target/
# Android / Gradle build output.
#
# `local.properties` holds the machine's SDK path — it is per-workstation and
# must never be committed; CI gets the SDK from ANDROID_HOME in the image.
# The wrapper JAR is deliberately NOT ignored: it is how a clean checkout gets
# the right Gradle without one installed first.
android/.gradle/
android/build/
android/app/build/
android/local.properties
.kotlin/
# Locally-downloaded APKs for emulator/device testing.
#
# CI builds these and attaches them to the run as artifacts; a copy sitting in
# the working tree is a convenience, never a source. Ignored because they are
# ~57 MB and `git add -A` would otherwise put one in history forever.
*.apk
# Signing material. NEVER committed — an Android signing key cannot be rotated
# without the original (v3 lineage needs it), so a leaked or lost one means every
# install has to be removed and replaced by hand. Listed before any keystore
# exists so that generating one in this directory cannot go wrong.
*.jks
*.keystore
*.p12
*.b64
# The Android client CI bakes into the server image. Fetched fresh on every image
# build, so it is never worth 55 MiB of git history. client/.keep IS tracked, so
# the Dockerfile's COPY always has a directory to copy.
client/thoughtsync.apk
client/thoughtsync-android.json
Generated
+5709
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
# Rust workspace. The framework-free client core, and the two shims that wrap it:
# the Tauri desktop app and the uniffi bindings the native Android client loads.
# Neither shim owns the core — that is the reason it is a crate at all rather than a
# module inside the desktop app (Scribe note 2730).
[workspace]
resolver = "2"
members = ["core", "desktop/src-tauri", "android/ffi", "android/bindgen"]
# Shared pins, so two consumers of the core cannot drift onto different versions of
# the same dependency and resolve differently.
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
log = "0.4"
# Tauri's default release profile: smaller, faster shipped binaries.
#
# At the WORKSPACE root, not in the desktop member: cargo ignores profiles declared
# by a non-root package, so leaving it there would silently drop lto/strip/opt-level
# from every release build with only a warning to say so.
[profile.release]
codegen-units = 1
lto = true
opt-level = "s"
panic = "abort"
strip = true
+13
View File
@@ -24,6 +24,19 @@ COPY --from=build-frontend /build/dist/ src/thoughtsync/static/
COPY alembic.ini .
COPY alembic/ alembic/
# The Android client this server hands out. CI fetches the newest published build
# into ./client immediately before this runs (ci.yml), so every image tag — :dev,
# :latest and :<version> alike — ships a client, and a `docker compose pull`
# delivers a new one with no file copying by hand.
#
# Fetched by the JOB rather than here on purpose: the release is private, and a
# token used inside a build ends up in the build context or a layer.
#
# The directory is tracked (client/.keep) so this COPY cannot fail on a tree where
# that step never ran. An image with no APK is a supported state — the server
# advertises nothing and the web UI hides the download (client_dist.py).
COPY client/ src/thoughtsync/client/
ENV PYTHONPATH=/app/src
ARG BUILD_VERSION=dev
+9 -2
View File
@@ -99,8 +99,15 @@ Then open `http://<host>:5000` and register — **the first account becomes the
unset, a signing key is generated and persisted in the database (sessions survive restarts).
- Uploaded images live under the `thoughtsync-data` volume at `/var/thoughtsync`.
- 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>` (immutable, for pinning / rollback).
- **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):** ThoughtSync 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
@@ -0,0 +1,67 @@
"""note_links.target_id — resolve [[links]] to a note, not to a string (M13 step 1)
Revision ID: 0023
Revises: 0022
Create Date: 2026-08-22
A wiki-link stored only as normalized TEXT means a note's name IS the edge: rename
the note and every inbound link stops matching. The old answer was to rewrite the
`[[Old Name]]` text inside every note that linked to it — workable while an explicit
title existed to hold still, untenable once a note's name is just its first body
line (M13).
`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.
The backfill is safe to run bluntly because note_links is DERIVED data — every row
is recomputed from the source body on the next save regardless.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0023"
down_revision = "0022"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"note_links",
sa.Column("target_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
"fk_note_links_target",
"note_links",
"notes",
["target_id"],
["id"],
# A deleted target un-resolves its inbound links rather than deleting them:
# the link text is still in the source's body, and it should read as pointing
# at something that isn't there — which is also what lets it re-resolve if a
# note of that name appears again.
ondelete="SET NULL",
)
op.create_index("ix_note_links_target_id", "note_links", ["target_id"])
# Resolve what can be resolved right now, scoped to the source's owner so a link
# can never bind to another user's note.
op.execute(
"""
UPDATE note_links AS nl
SET target_id = t.id
FROM notes AS src, notes AS t
WHERE nl.source_id = src.id
AND t.owner_id = src.owner_id
AND t.deleted_at IS NULL
AND lower(btrim(t.display_title)) = nl.target_norm
AND t.id <> src.id
"""
)
def downgrade() -> None:
op.drop_index("ix_note_links_target_id", table_name="note_links")
op.drop_constraint("fk_note_links_target", "note_links", type_="foreignkey")
op.drop_column("note_links", "target_id")
+54
View File
@@ -0,0 +1,54 @@
"""drop note_links — [[wiki-links]] are removed (note 2897)
Revision ID: 0024
Revises: 0023
Create Date: 2026-08-22
ThoughtSync is an intermediary surface for capture and recall; a linking system is
organization, which is not what it is for. Backlinks, the graph and the name index
went with it.
0023 (which added `note_links.target_id`) is deliberately left in the chain rather
than deleted. It shipped in an image and may already be applied, and removing an
applied revision would strand a database's alembic_version pointer. So the column is
dropped here along with the table it lived on, and the history stays honest about the
fact that it existed for a day.
No down-migration data concern: note_links was always DERIVED from note bodies. The
`[[text]]` is still sitting in every body it was written in; nothing a person typed is
lost by this.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0024"
down_revision = "0023"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_table("note_links")
def downgrade() -> None:
op.create_table(
"note_links",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"source_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("notes.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"target_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("notes.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("target_norm", sa.Text(), nullable=False),
)
op.create_index("ix_note_links_target", "note_links", ["target_norm"])
op.create_index("ix_note_links_target_id", "note_links", ["target_id"])
+35
View File
@@ -0,0 +1,35 @@
"""drop notes.kind — a checklist is something a note HAS (M13 step 2)
Revision ID: 0025
Revises: 0024
Create Date: 2026-08-22
`kind` was never a type: a plain TEXT column with no enum and no CHECK, compared
against a hardcoded ("text", "list") tuple in six places. `note_items` was always an
ordinary child table keyed by note_id, serialization always emitted `items` whatever
the kind, and the Android editor already toggled between the two losslessly. The
storage has modelled "a body plus optional checkable items" the whole time; only the
gates forbade it.
Nothing is lost. Items were already rows in their own table, and a note that was
`kind = 'list'` keeps every one of them — it just stops being a different sort of
thing from the note next to it.
"""
from alembic import op
import sqlalchemy as sa
revision = "0025"
down_revision = "0024"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column("notes", "kind")
def downgrade() -> None:
# server_default so existing rows get a value; every note comes back as 'text',
# which is right — a restored note with items would previously have hidden its
# body, and there is no record of which ones were once lists.
op.add_column("notes", sa.Column("kind", sa.Text(), nullable=False, server_default="text"))
+82
View File
@@ -0,0 +1,82 @@
"""drop notes.title and note_revisions.title — a note's name is its first line
Revision ID: 0026
Revises: 0025
Create Date: 2026-08-22
M13 step 3. A note is a body plus optional checkable items; its NAME is the first
non-empty line of that body, falling back to its first checklist item. There is no
separate field to type into, and `display_title` (already persisted, already what
search results and export filenames read) carries the name.
## The search vector has to be rebuilt, not just left alone
`notes.search_vector` is a STORED GENERATED column whose expression names `title`
(migration 0005, weight A) — Postgres will refuse to drop a column another generated
column depends on, and even if it didn't, the weighting would be wrong. So it is
dropped and recreated over `display_title` instead, which keeps the original
intent: the note's NAME ranks above the rest of its body.
Rebuilding a stored generated column re-computes every row, and the GIN index is
rebuilt with it. On a personal instance that is milliseconds; it is worth knowing
before running this against something large.
## What happens to existing titles
Nothing preserves them, deliberately: `display_title` was already derived from the
title when one was set, so every note keeps the NAME it had. What is lost is the
distinction between "this note has an explicit title" and "this note's first line is
its name" — which is the distinction being removed.
Imports are the exception and are handled in code, not here: a Keep note's title, or
one in an export taken before this, is folded in as the note's first body line rather
than dropped (see `_create_imported_note`).
"""
from alembic import op
import sqlalchemy as sa
revision = "0026"
down_revision = "0025"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Order matters: the generated column depends on `title`, so it goes first.
op.execute("DROP INDEX IF EXISTS ix_notes_search")
op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS search_vector")
op.drop_column("notes", "title")
op.drop_column("note_revisions", "title")
op.execute(
"""
ALTER TABLE notes ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(display_title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(body, '')), 'B')
) STORED
"""
)
op.execute("CREATE INDEX ix_notes_search ON notes USING GIN (search_vector)")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_notes_search")
op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS search_vector")
# Comes back empty. The text is not gone — it is the first line of every body —
# but which notes once had an explicit title is not recorded anywhere.
op.add_column("notes", sa.Column("title", sa.Text(), nullable=True))
op.add_column("note_revisions", sa.Column("title", sa.Text(), nullable=True))
op.execute(
"""
ALTER TABLE notes ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(body, '')), 'B')
) STORED
"""
)
op.execute("CREATE INDEX ix_notes_search ON notes USING GIN (search_vector)")
@@ -0,0 +1,128 @@
"""fold note_items into the note body and drop the table
Revision ID: 0027
Revises: 0026
Create Date: 2026-08-24
M304. A checklist item becomes a `- [ ] milk` line of `notes.body`, and `note_items`
goes. The reason is positional, not cosmetic: a row had a position in a table and no
position in the text, so a separate list could only ever render AFTER the prose. With
the items in the body, a list can sit between two paragraphs — which is the thing that
could not be built before and no amount of restyling would have delivered.
## This migration rewrites note bodies
Every note that has items gets its body appended to. The rules below are strict
because rewriting somebody's text deserves it — not, as an earlier draft of this
docstring claimed, because this instance holds imported Google Keep notes. It does
not; note 2916's headline is that nothing here is anyone's work but the operator's
test data. What 2916 actually says about imports is conditional — text arriving from
another app WOULD be real, and any import path has to treat it that way — and the
importer this migration shares a format with is one nobody here has run.
Careful was still the right call. It cost little, and the same care is what the rule
demands the day someone does import something:
* Rows are read BEFORE the table is dropped, in this one transaction.
* The existing body is never rewritten, only appended to.
* The layout — a blank line between prose and the list, nothing between consecutive
items — is byte-for-byte what `_note_markdown` has always exported and what
`derive::append_item` produces on every client. All three landing on the same text
is what lets the clients migrate their own SQLite stores independently and still
agree with the server, with no sync required to reconcile them.
## The fold is inlined on purpose
`notes/checklist.py` has this same function and this migration deliberately does not
import it. A migration has to keep producing what it produced the day it ran; if the
app's spacing rule ever changes, this file must not change with it.
## `updated_at` is left alone, and that is load-bearing
Raw SQL, so SQLAlchemy's `onupdate` never fires. Two reasons, and the second matters
more than the first. Every client folds the same rows the same way, so the new body is
news to nobody. And a client holding an UNPUSHED body edit still has the newer
`updated_at`, so when it pulls the migrated note last-write-wins keeps its edit instead
of the migration silently winning.
The `notes` row's own `sync_revision` trigger (migration 0015) does fire, so every
migrated note becomes pullable once. That is wanted: it is what makes a client whose
local fold somehow differed converge on the server's text.
## The downgrade is not a true inverse, and says so
It recreates an empty `note_items` and leaves the bodies alone. Nothing is lost —
every item is still there as text, which is where this migration put it — but the old
code would show those notes as prose with no checklist. A faithful inverse is not
possible: once the items are lines, nothing distinguishes a line this migration wrote
from one somebody typed, and a downgrade that guessed would eat hand-written task
lists. The real rollback is a database restore.
Recreating the table is not decoration, though. Migration 0015's downgrade runs
`DROP TRIGGER IF EXISTS trg_note_items_bump_note ON note_items`, and `IF EXISTS`
covers the trigger, not the table — against a missing table that statement errors. So
this is what keeps the migration chain runnable all the way back down.
"""
import re
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision = "0027"
down_revision = "0026"
branch_labels = None
depends_on = None
_TASK_RE = re.compile(r"^\s*[-*] +\[[ xX]\](?: +.*)?$")
def _append_item(body: str, text: str, checked: bool) -> str:
mark = "x" if checked else " "
text = (text or "").strip()
line = f"- [{mark}] {text}" if text else f"- [{mark}]"
trimmed = (body or "").rstrip("\n")
if not trimmed.strip():
return line
follows_a_list = bool(_TASK_RE.match(trimmed.split("\n")[-1]))
return f"{trimmed}\n{line}" if follows_a_list else f"{trimmed}\n\n{line}"
def upgrade():
bind = op.get_bind()
rows = bind.execute(
sa.text("SELECT note_id, text, checked FROM note_items ORDER BY note_id, position, created_at")
).fetchall()
grouped: dict = {}
for note_id, text, checked in rows:
grouped.setdefault(note_id, []).append((text, bool(checked)))
for note_id, items in grouped.items():
body = bind.execute(sa.text("SELECT body FROM notes WHERE id = :id"), {"id": note_id}).scalar()
# An item whose note is already gone has nothing to fold into. The foreign key
# should make this impossible; skipping costs nothing and failing here would
# leave the database half-migrated.
if body is None:
continue
for text, checked in items:
body = _append_item(body, text, checked)
bind.execute(sa.text("UPDATE notes SET body = :body WHERE id = :id"), {"body": body, "id": note_id})
op.drop_table("note_items")
def downgrade():
# Column-for-column as migration 0006 created it, index name included: 0015's
# downgrade names both the table and its trigger, so a near-enough copy is not
# good enough.
op.create_table(
"note_items",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("note_id", UUID(as_uuid=True), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
sa.Column("text", sa.Text(), nullable=False),
sa.Column("checked", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_note_items_note", "note_items", ["note_id"])
@@ -0,0 +1,168 @@
"""lift standalone #tags out of note bodies
Revision ID: 0028
Revises: 0027
Create Date: 2026-08-26
M311. A `#tag` was being shown twice — once as the text you typed and once as a chip —
and with the chip moved to the top of the card the text is redundant. This removes it,
but only from notes where the tag was standing on its own.
## This migration rewrites note bodies
The rule is deliberately narrow, and the same one `notes/tags.py:split_body_tags`
applies from here on:
* A line containing nothing but tags and whitespace is REMOVED.
* Every other line is left exactly as written.
So `#todo` on its own line goes, and `remember to call #mom tomorrow` does not. The
looser reading — also stripping a trailing tag off a prose line — was rejected because
the text does not say which kind it is: `buy milk #grocery` is filing, `remember to
call #mom` is the sentence's object, and lifting the second leaves "remember to call".
Rewriting somebody's words to save a duplicate chip is a bad trade, and a migration is
the worst possible place to make it.
Two guards, both of which cost a note nothing:
* A line inside a ``` fence is never touched. A `#tag` there is a shell comment in a
snippet somebody pasted, and deleting it would eat a line of their example.
* A note that is NOTHING but tags keeps its text. Lifting would leave a blank card,
which is worse than the duplication this fixes.
## The label rows have to graduate in the same transaction
A `via_tag` row means "this label is backed by text still in the body". Once the text
is gone that is false, and leaving it true is not cosmetic: `_lift_and_reconcile_tags`
detaches any `via_tag` row it cannot find a `#tag` for, so the note would lose the tag
on its very next save. The flip to `via_tag = false` is what makes the label the record
instead — and what makes the chip's × appear in both editors, which is now the only way
to remove a tag whose text no longer exists.
## The transform is inlined, like 0027's
`split_body_tags` is deliberately NOT imported. A migration has to keep producing what
it produced the day it ran; if the app's rule is ever loosened, this file must not
loosen with it and start eating prose it previously left alone.
`_display_title` is inlined for the same reason, and is only recomputed for a note whose
body actually moved — a note named after a `#todo` line needs a new name, and reading it
from the app would couple this migration to a rule that has already changed once (M13).
## `updated_at` is left alone, and that is load-bearing
Raw SQL, so SQLAlchemy's `onupdate` never fires. A client holding an UNPUSHED body edit
keeps the newer `updated_at`, so when it pulls the migrated note last-write-wins keeps
its edit instead of the migration silently winning.
The `sync_revision` trigger (migration 0015) does fire, so every rewritten note becomes
pullable once and clients converge on the server's text. That is wanted here: unlike
0027, the clients do NOT yet apply this rule locally, so the server's copy is the only
correct one until they do.
## The downgrade is not a true inverse, and says so
It cannot be. Nothing distinguishes a `#todo` line this migration deleted from one that
was never there, and putting one back would be guessing at where in the note it went.
Nothing is lost, though, which is why that is acceptable: the tag still exists as a
label on the note, and the chip still shows it. What a downgrade cannot restore is the
DUPLICATE — which is the thing this migration set out to remove. Rolling the rows back
to `via_tag = true` would be actively harmful: the text that flag claims to be backed by
is gone, so the next save would detach the label and lose the tag for real. So the
downgrade leaves both alone. The real rollback is a database restore.
"""
import re
import sqlalchemy as sa
from alembic import op
revision = "0028"
down_revision = "0027"
branch_labels = None
depends_on = None
# Frozen copies. See "The transform is inlined" above — these must not follow the app.
_TAG_RE = re.compile(r"(?:^|(?<=\s))#(\w[\w-]*)")
_FENCE_RE = re.compile(r"^\s*(?:```|~~~)")
_TASK_RE = re.compile(r"^(?P<indent>\s*)(?P<bullet>[-*]) +\[(?P<mark>[ xX])\](?: +(?P<text>.*))?$")
_DISPLAY_TITLE_CAP = 200
def _is_tag(name: str) -> bool:
"""A tag must contain a letter, so #2024 and #_ are not tags — and a line holding
only those is therefore not a tag-only line and is left alone."""
return any(c.isalpha() for c in name)
def _split(body: str) -> tuple[list[str], str]:
"""(standalone tag names, body with their lines removed)."""
standalone: list[str] = []
kept: list[str] = []
in_fence = False
for line in body.split("\n"):
if _FENCE_RE.match(line):
in_fence = not in_fence
kept.append(line)
continue
matches = [m for m in _TAG_RE.finditer(line) if _is_tag(m.group(1))]
remainder = line
for m in reversed(matches):
remainder = remainder[: m.start()] + remainder[m.end() :]
if in_fence or not matches or remainder.strip():
kept.append(line)
else:
standalone.extend(m.group(1) for m in matches)
lifted = re.sub(r"\n{3,}", "\n\n", "\n".join(kept)).strip("\n")
if body.strip() and not lifted.strip():
return [], body # nothing but tags: keep the note readable
# A tag still written in prose somewhere keeps its text, so it stays derived.
still_in_prose = {m.group(1).lower() for m in _TAG_RE.finditer(lifted) if _is_tag(m.group(1))}
return [n for n in standalone if n.lower() not in still_in_prose], lifted
def _display_title(body: str) -> str:
for line in body.splitlines():
stripped = line.strip()
match = _TASK_RE.match(stripped)
text = (match.group("text") or "") if match else stripped
text = text.strip()
if text:
return text[:_DISPLAY_TITLE_CAP]
return ""
def upgrade():
bind = op.get_bind()
rows = bind.execute(sa.text("SELECT id, body FROM notes WHERE body LIKE '%#%'")).fetchall()
flip = sa.text(
"UPDATE note_labels nl SET via_tag = false "
"FROM labels l "
"WHERE nl.label_id = l.id AND nl.note_id = :nid AND nl.via_tag = true "
"AND lower(l.name) IN :names"
).bindparams(sa.bindparam("names", expanding=True))
for note_id, body in rows:
if not body:
continue
standalone, lifted = _split(body)
if lifted != body:
bind.execute(
sa.text("UPDATE notes SET body = :body, display_title = :title WHERE id = :id"),
{"body": lifted, "title": _display_title(lifted), "id": note_id},
)
# Even when the body did not move, a tag can be standalone only in the sense
# that its line was already removed by an earlier pass — so the flip is driven
# by the tag list, not by whether the text changed.
if standalone:
bind.execute(flip, {"nid": note_id, "names": [n.lower() for n in standalone]})
def downgrade():
"""Deliberately empty — see the module docstring.
Restoring the deleted lines would be guessing, and flipping the rows back to
`via_tag = true` would be worse than doing nothing: the text that flag claims backs
them is gone, so the next save would detach the label and lose the tag for real.
"""
+93
View File
@@ -0,0 +1,93 @@
"""drop notes.color — a card is one neutral surface, colour lives on the tag
Revision ID: 0029
Revises: 0028
Create Date: 2026-08-28
M315 step 3. A note's colour was set by a picker and read by three card renderers.
Steps 1 and 2 stopped every one of those reads: the card is one neutral per theme and
the only coloured thing on a board is a tag. This drops the column that nothing has
been reading since, and the picker goes with it.
`labels.color` is untouched. That is the colour that survived, and the one the whole
milestone was about keeping.
## What is lost, and why that is the change rather than a cost of it
Any colour a note was explicitly given. There is nowhere to preserve it TO — the field
it would be preserved in is the one being dropped — and nothing renders it, so a
preserved value would be a column kept warm for a feature that was deliberately
removed. A note that had a colour now takes its identity from its tags, which is what
the operator asked for: "strip color from the cards ... and keep the color for tags
just on the tag."
The palette itself is not lost. `NOTE_COLORS` moved from `models/note.py` to
`colors.py` in the same change — labels still name a colour, and leaving the vocabulary
defined on the model that lost one would be an invitation to put the column back.
## The saved-filter sweep is not optional
`saved_filters.params` is opaque JSON mirroring the `GET /api/notes` facet query, and
a stored view could carry `"color": "teal"`. With the facet gone that key would sit
there forever, and `clean_params` only guards what is written FROM here on. A view that
silently filters on a field the app no longer has is worse than one that visibly lost a
criterion, so the stored rows are swept too.
Done in Python rather than as `params::jsonb - 'color'`, deliberately. Postgres has no
try-cast: one malformed blob would abort the whole migration, and these rows are
somebody's saved views. `json.loads` in a try/except lets a corrupt row keep whatever it
holds and lets every other row be fixed.
## Search is not affected
`notes.search_vector` is a stored generated column over `display_title` and `body`
(rebuilt in 0026). It never named `color`, so unlike the title drop there is nothing
here to tear down and recreate.
## Downgrade
Restores the column, empty, at its old default. The values are not recoverable — see
above. It is the schema that comes back, not the data.
"""
import json
from alembic import op
import sqlalchemy as sa
revision = "0029"
down_revision = "0028"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column("notes", "color")
bind = op.get_bind()
rows = bind.execute(
sa.text("SELECT id, params FROM saved_filters WHERE params LIKE '%color%'")
).fetchall()
for sf_id, params in rows:
try:
parsed = json.loads(params)
except (ValueError, TypeError):
# A blob that does not parse cannot be edited safely. Leaving it is
# correct: it was already unreadable by the app, and this migration is not
# the place to decide what it should have said.
continue
if not isinstance(parsed, dict) or "color" not in parsed:
continue
parsed.pop("color")
bind.execute(
sa.text("UPDATE saved_filters SET params = :p WHERE id = :id"),
{"p": json.dumps(parsed), "id": sf_id},
)
def downgrade() -> None:
# Comes back at the default every note would have had anyway. Which notes once
# carried a chosen colour is not recorded anywhere after the upgrade.
op.add_column(
"notes",
sa.Column("color", sa.Text(), nullable=False, server_default="default"),
)
+15
View File
@@ -0,0 +1,15 @@
root = true
[*.{kt,kts}]
# ktlint's standard function-naming rule doesn't know about Compose, where
# PascalCase @Composable functions are the universal convention — every
# mainstream Compose codebase would fail it. This is ktlint's own supported
# exemption, and it mirrors the equivalent detekt override in config/detekt.yml.
ktlint_function_naming_ignore_when_annotated_with = Composable
# 120 rather than ktlint's looser default: this is a phone UI with deeply nested
# Compose calls, and a hard-ish ceiling is what keeps the nesting from becoming
# unreadable rather than merely long.
max_line_length = 120
indent_size = 4
insert_final_newline = true
+292
View File
@@ -0,0 +1,292 @@
import java.io.File
import javax.inject.Inject
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.compose.compiler)
}
// The Cargo workspace root — two levels up from android/app.
val workspaceRoot: Directory = layout.projectDirectory.dir("../..")
// The ABIs a release APK carries. arm64 is essentially every real device; armv7
// covers older 32-bit hardware; the two x86 targets are what emulators run on, and
// dropping them would make the app untestable on a desktop emulator (the reason
// x86_64 was added to the old Tauri lane in task 1864).
val androidAbis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
/**
* Cross-compile `thoughtsync-ffi` for each Android ABI and drop the resulting
* `.so` into jniLibs, where AGP packages it.
*
* `ExecOperations` injected rather than `project.exec`: the latter was REMOVED in
* Gradle 9, and reaching for `project` at execution time is also what breaks the
* configuration cache this build has enabled.
*/
abstract class CargoNdkBuild : DefaultTask() {
@get:Inject
abstract val execOps: ExecOperations
@get:InputFiles
abstract val rustSources: ConfigurableFileCollection
@get:Input
abstract val abis: ListProperty<String>
@get:Input
abstract val cargoProfile: Property<String>
@get:Internal
abstract val workspaceDir: DirectoryProperty
@get:OutputDirectory
abstract val jniLibsDir: DirectoryProperty
@TaskAction
fun build() {
val args = mutableListOf("ndk")
abis.get().forEach { abi ->
args += "-t"
args += abi
}
args += listOf("-o", jniLibsDir.get().asFile.absolutePath, "build", "-p", "thoughtsync-ffi")
// --locked so an Android build cannot silently re-resolve the workspace
// lockfile the desktop lanes are gated on.
args += "--locked"
if (cargoProfile.get() == "release") args += "--release"
execOps.exec {
commandLine(listOf("cargo") + args)
workingDir = workspaceDir.get().asFile
}
}
}
/**
* Generate the Kotlin bindings FROM the freshly built `.so`.
*
* `--library` mode reads uniffi's metadata straight out of the compiled artifact,
* so the bindings can never describe a different version of the Rust than the one
* being packaged — which is the failure the whole in-workspace generator setup
* exists to prevent.
*/
abstract class UniffiBindgen : DefaultTask() {
@get:Inject
abstract val execOps: ExecOperations
@get:InputFile
abstract val libraryFile: RegularFileProperty
@get:Internal
abstract val workspaceDir: DirectoryProperty
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun generate() {
val out = outputDir.get().asFile
out.deleteRecursively()
out.mkdirs()
execOps.exec {
commandLine(
"cargo",
"run",
"--locked",
"-p",
"thoughtsync-uniffi-bindgen",
"--",
"generate",
"--library",
libraryFile.get().asFile.absolutePath,
"--language",
"kotlin",
"--out-dir",
out.absolutePath,
)
workingDir = workspaceDir.get().asFile
}
}
}
// Only the Rust that actually affects the .so. Deliberately NOT the workspace
// directory: that would make Gradle hash target/, which is gigabytes.
val rustInputs =
files(
workspaceRoot.dir("core/src"),
workspaceRoot.dir("android/ffi/src"),
workspaceRoot.file("core/Cargo.toml"),
workspaceRoot.file("android/ffi/Cargo.toml"),
workspaceRoot.file("Cargo.toml"),
workspaceRoot.file("Cargo.lock"),
)
/**
* Which Cargo profile the `.so` is built with.
*
* A property rather than a debug/release task PAIR, deliberately. This runner has
* no working Gradle or Cargo cache (`reserveCache failed` on every run), so a cold
* cross-compile of four ABIs costs about four minutes — and a lane that both
* type-checks and packages would pay that twice if the two used different
* profiles. `android.yml` picks one profile and uses it for every Gradle call in
* the run.
*
* CI currently passes `debug` even for a release APK, which is not where this
* should end up: an unoptimised store and sync engine is a real difference on a
* phone, not a theoretical one. The blocker is that the workspace's release
* profile sets `strip = true`, which removes the symbols uniffi reads its
* interface metadata from — `generateUniffiBindings` then fails with "No UniFFI
* metadata found" (run 4077). Fixing it means either an Android-specific profile
* that keeps symbols or generating the bindings from a separate unstripped
* build, and neither is worth holding signed APKs up for. Scribe #2810.
*/
val rustProfile =
(project.findProperty("THOUGHTSYNC_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() }
?: "debug"
val jniLibsOut = layout.buildDirectory.dir("rustJniLibs")
val bindingsOut = layout.buildDirectory.dir("generated/uniffi")
val cargoNdk =
tasks.register<CargoNdkBuild>("cargoNdk") {
description = "Cross-compile thoughtsync-ffi for the Android ABIs."
rustSources.from(rustInputs)
abis.set(androidAbis)
cargoProfile.set(rustProfile)
workspaceDir.set(workspaceRoot)
jniLibsDir.set(jniLibsOut)
}
val generateBindings =
tasks.register<UniffiBindgen>("generateUniffiBindings") {
description = "Generate the Kotlin bindings from the compiled .so."
dependsOn(cargoNdk)
// arm64 is arbitrary — every ABI carries the same uniffi metadata, and
// reading one is cheaper than reading four.
libraryFile.set(jniLibsOut.map { it.file("arm64-v8a/libthoughtsync_ffi.so") })
workspaceDir.set(workspaceRoot)
outputDir.set(bindingsOut)
}
android {
namespace = "com.fabledsword.thoughtsync"
compileSdk = 36
defaultConfig {
applicationId = "com.fabledsword.thoughtsync"
// 26 (Android 8, 2017) matches Minstrel and clears the NDK's floor with
// room to spare.
minSdk = 26
targetSdk = 36
// Injected by CI from the git tag + commit count for a release; "dev"
// locally so the About screen reads honestly rather than claiming 1.0.
val nameOverride =
(project.findProperty("THOUGHTSYNC_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
val codeOverride =
(project.findProperty("THOUGHTSYNC_VERSION_CODE") as String?)?.toIntOrNull()
versionCode = codeOverride ?: 1
versionName = nameOverride ?: "dev"
// Package ONLY the ABIs we build for.
//
// Without this the APK also carries armeabi, mips and mips64 — dead
// architectures Android dropped years ago, which arrive because JNA's
// .aar still ships a libjnidispatch.so for each. They can never be
// loaded on any device this app supports, so they are pure payload.
ndk {
abiFilters += androidAbis
}
}
// The signing key reaches this build only through the environment: CI decodes
// it from a secret into a file and points ANDROID_KEYSTORE_FILE at that path.
// It is never in the repo and never in this file. Generated by the operator
// and never seen by an agent session, because an Android signing key cannot be
// rotated without the original — v3 lineage needs it — so a leaked or lost one
// means every install has to be removed and replaced by hand.
val keystoreFile = System.getenv("ANDROID_KEYSTORE_FILE")?.takeIf { it.isNotBlank() }
val keystorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")?.takeIf { it.isNotBlank() }
signingConfigs {
if (keystoreFile != null && keystorePassword != null) {
create("release") {
storeFile = File(keystoreFile)
storePassword = keystorePassword
// Hardcoded, and NOT a secret: the alias is fixed for the life of
// this app and is written into the certificate every install
// already carries. Hiding it would buy nothing and stop this file
// describing its own signing setup.
keyAlias = "thoughtsync"
// PKCS12 cannot hold a key password distinct from the store
// password — keytool refuses to set one — so this is the same
// value by necessity rather than by shortcut.
keyPassword = keystorePassword
}
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
// Null when no keystore reached this build, which leaves the APK
// unsigned and therefore uninstallable. `android.yml` builds debug in
// that case rather than producing an artifact nobody can put on a
// phone.
signingConfig = signingConfigs.findByName("release")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
packaging {
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
/**
* Register the `.so` and the generated bindings as GENERATED sources.
*
* NOT `sourceSets { ... srcDir(task) }`: AGP 9 rejects a Provider there outright,
* because it cannot tell whether the directory holds generated (read-only) or
* hand-written (read-write) files — a distinction the IDE needs. The Variant API
* is the supported route and, unlike a bare path, `addGeneratedSourceDirectory`
* carries the task dependency, so Kotlin cannot compile before the bindings
* exist and the APK cannot package a stale `.so`.
*/
androidComponents {
onVariants { variant ->
variant.sources.kotlin?.addGeneratedSourceDirectory(generateBindings, UniffiBindgen::outputDir)
variant.sources.jniLibs?.addGeneratedSourceDirectory(cargoNdk, CargoNdkBuild::jniLibsDir)
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.work.runtime)
// Required by the uniffi bindings — see the catalog note on the @aar
// classifier; the plain jar builds fine and fails at runtime.
implementation(variantOf(libs.jna) { artifactType("aar") })
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons.core)
implementation(libs.compose.ui.tooling.preview)
debugImplementation(libs.compose.ui.tooling)
testImplementation(libs.junit)
}
+7
View File
@@ -0,0 +1,7 @@
# JNA reaches the native library reflectively, so R8 must not rename or strip
# either it or the uniffi bindings that ride on it. Without these a minified
# build fails at runtime with UnsatisfiedLinkError and only in release, which
# is the worst possible time to learn it.
-keep class com.sun.jna.** { *; }
-keepclassmembers class * extends com.sun.jna.** { public *; }
-keep class com.fabledsword.thoughtsync.core.** { *; }
+142
View File
@@ -0,0 +1,142 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!--
INTERNET is requested but nothing uses it until the user links a server.
The app is local-first: the store, capture and the whole board work with
this permission never exercised.
-->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Only to answer "is this connection metered?" before the app downloads its own
update in the background. Normal permission, no prompt, no location. -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!--
Four more permissions are NOT declared here and still reach the merged
manifest, contributed by WorkManager for the automatic sync:
RECEIVE_BOOT_COMPLETED reschedules the periodic sync after a restart,
instead of it silently stopping until the app is
next opened by hand
ACCESS_NETWORK_STATE evaluates the "needs a network" constraint, so a
run is not attempted with no route to the server
WAKE_LOCK holds the device awake for the seconds a sync
takes, so it is not suspended mid-request
FOREGROUND_SERVICE used only for expedited work; nothing here asks
for it, and it arrives with the library
Verified against the built APK's merged manifest, not assumed. Noted here
because all four appear in the app's permission list and nothing else in
this file would explain where they came from.
-->
<!--
usesCleartextTraffic, deliberately.
Android blocks plain HTTP by default from API 28, and the core explicitly
supports a self-hosted server on a LAN — `http://192.168.1.10:8000` is a
case it has a test for. Leaving the platform default would make this app
unusable for exactly the people it is built for, with a transport error
they could do nothing about.
Scoped by the fact that the app talks to ONE host: the server the user
typed in. There is no ad SDK, no analytics, nothing else making requests.
A network-security-config would be tighter in principle, but it matches on
domains and IP literals rather than CIDR ranges, so it cannot express
"any address on my own network" — the case that actually matters here.
The trade is not made silently: the sync screen shows an unmissable
warning when the probed address is http://, BEFORE any credential field
appears. See SyncScreen.kt.
-->
<!--
Reminders.
POST_NOTIFICATIONS is a runtime permission from API 33. It is asked for in
context — the first time the app opens holding a reminder that could fire,
never at launch on an empty board, where there would be nothing to explain
why it is being asked.
SCHEDULE_EXACT_ALARM rather than USE_EXACT_ALARM. USE_EXACT_ALARM is granted
at install with no prompt, and is reserved for apps whose whole purpose is an
alarm clock or calendar; a note app claiming it would be claiming something
untrue. SCHEDULE_EXACT_ALARM is the one the person can grant or refuse, and
refusing costs precision, not the feature — see Reminders.scheduleNext.
RECEIVE_BOOT_COMPLETED already arrives via WorkManager (below), but is
declared here too because ReminderReceiver now depends on it directly. A
permission this file relies on should be visible in this file.
-->
<!--
Updating this app from the server it syncs with (M12 step 7).
REQUEST_INSTALL_PACKAGES lets the app hand an APK to the system installer at
all. It is NOT what makes an install look suspicious to on-device heuristics
— Mihon declares it too — the legacy ACTION_VIEW install intent was, and this
app uses a PackageInstaller session instead. See AppUpdate.kt and Scribe note
2437. The person must additionally grant "install unknown apps" in system
settings; the update card asks before downloading anything.
UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+) is what removes the install
confirmation on the UPDATE path, and only there — Android will not let an app
silently put a NEW package on a device, which is correct. It also only applies
when the new build is signed with the same key as the installed one, which is
why signing had to land before any of this could work.
-->
<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.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:name=".ThoughtSyncApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ThoughtSync"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.ThoughtSync">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!--
Not exported: every intent that reaches it is one this app created, with
an explicit component. Exporting would let any app on the device mark
someone's reminders as done.
The two system broadcasts are the exception and need the filter, because
the system is the sender. Both exist for the same reason — pending alarms
do not survive either a reboot or an app update, so without this a phone
that restarts overnight would quietly stop reminding anyone of anything.
-->
<!--
Where the system reports what happened to an install we committed. Not
exported: the only sender is the PendingIntent this app handed to
PackageInstaller. Without it a failed install would be indistinguishable
from someone declining the dialog (Scribe #2438).
-->
<receiver
android:name=".UpdateReceiver"
android:exported="false" />
<receiver
android:name=".ReminderReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -0,0 +1,177 @@
package com.fabledsword.thoughtsync
import android.content.Context
import android.content.Intent
import android.content.IntentSender
import android.content.pm.PackageInstaller
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.util.Log
import java.io.File
/**
* Replacing this app with a newer build of itself.
*
* ## A PackageInstaller session, not an install intent
*
* The obvious route — `ACTION_VIEW` on the APK with
* `application/vnd.android.package-archive` — is the one on-device install
* heuristics are tuned against, and it is what produces the "bypassing Android
* security" warning the operator saw on Minstrel (Scribe note 2437). It also never
* tells the OS that this app is the legitimate updater of its own package, and it
* returns nothing: a failed install is indistinguishable from a person dismissing
* the dialog.
*
* A session says who is doing what. On Android 12+ it can also declare that no user
* action is required, which — paired with `UPDATE_PACKAGES_WITHOUT_USER_ACTION` —
* removes the confirmation dialog entirely on the UPDATE path. Not on a first
* install: the OS will not let an app quietly put a NEW package on a device, which
* is right.
*
* Two things from that same research that are NOT done here, deliberately:
* `setRequestUpdateOwnership` was chased and turned out to be a red herring, and
* `REQUEST_INSTALL_PACKAGES` is not the differentiator either — Mihon declares it
* too. The mechanism was the whole difference.
*
* ## The outcome comes back
*
* `commit` takes an `IntentSender`; the system reports the result to
* [UpdateReceiver], which is why a failure can be shown rather than guessed at.
*/
object AppUpdate {
private const val TAG = "ThoughtSyncUpdate"
/** This build's versionCode — what the server's is compared against. */
fun installedVersionCode(context: Context): Long =
runCatching {
context.packageManager.getPackageInfo(context.packageName, 0).longVersionCode
}.getOrDefault(0L)
/**
* Whether this app may install packages at all.
*
* A separate grant from anything in the manifest, and one only the person can
* give. Checked before offering an update rather than after downloading 55 MiB.
*/
fun canInstall(context: Context): Boolean = context.packageManager.canRequestPackageInstalls()
/** The settings page where that grant lives, scoped to this app. */
fun installPermissionSettings(context: Context): Intent =
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)
.setData(Uri.fromParts("package", context.packageName, null))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
/**
* Whether this is wifi somebody is not paying by the megabyte for.
*
* The app fetches its own update in the background, and fifty-odd megabytes over
* mobile data is a bill nobody agreed to. Anywhere else it simply waits — the
* update is found, nothing is downloaded, and nothing is said until it can be.
*
* BOTH conditions, deliberately. Wifi alone would still download over a tethered
* hotspot, which is mobile data wearing a different hat and the exact bill this
* avoids. Unmetered alone would download over an unmetered cellular plan, which
* is not what "on wifi" means to the person who asked for it.
*
* Every uncertain answer is `false`: the cautious one costs nothing.
*/
fun onWifi(context: Context): Boolean {
val caps =
context
.getSystemService(ConnectivityManager::class.java)
?.let { manager -> manager.activeNetwork?.let(manager::getNetworkCapabilities) }
return caps != null &&
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
}
/** Where a download goes: app-private, so no storage permission is involved. */
fun downloadTarget(context: Context): File = File(context.cacheDir, "update.apk")
/**
* Hand the APK to the system installer.
*
* Streamed into the session rather than passed as a path or a content URI —
* the session takes bytes, which is also why no FileProvider is needed here.
*
* Returns the failure to show, or null when the install was handed over
* successfully. "Handed over" is the honest word: the real outcome arrives
* later at [UpdateReceiver], because a commit that the system accepts can still
* fail afterwards.
*/
fun install(
context: Context,
apk: File,
): String? {
val installer = context.packageManager.packageInstaller
var sessionId = -1
return try {
val params =
PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Only honoured on an UPDATE of an app signed with the same key —
// exactly our case, and the reason the signing work had to land
// first. Android ignores it for anything else rather than failing,
// so there is no need to guard on which it is.
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
}
sessionId = installer.createSession(params)
installer.openSession(sessionId).use { session ->
writeApk(session, apk)
session.commit(statusSender(context))
}
null
} catch (e: Exception) {
// Broad on purpose: createSession throws IOException, openWrite throws,
// and the framework raises SecurityException for a revoked grant. All of
// them mean one thing to the person — it did not install — and none of
// them should take the app down.
Log.w(TAG, "could not start the install session", e)
if (sessionId != -1) runCatching { installer.abandonSession(sessionId) }
e.message ?: "The update could not be installed."
}
}
/**
* Stream the APK into the session.
*
* Its own function only because the two nested `use` blocks read badly inline —
* and detekt agreed, which is fair: a stream inside a session inside a try is
* three things to hold at once.
*/
private fun writeApk(
session: PackageInstaller.Session,
apk: File,
) {
session.openWrite(WRITE_NAME, 0, apk.length()).use { out ->
apk.inputStream().use { it.copyTo(out) }
// Before close: the session must have the bytes on disk, not sitting in
// a buffer, or commit can be handed a short file.
session.fsync(out)
}
}
private fun statusSender(context: Context): IntentSender {
val intent =
Intent(context, UpdateReceiver::class.java).setAction(UpdateReceiver.ACTION_INSTALLED)
// MUTABLE, and this is the one place it is correct: the system fills the
// result extras in before delivering it. An immutable one would arrive with
// no status at all.
val flags =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
android.app.PendingIntent.FLAG_UPDATE_CURRENT or
android.app.PendingIntent.FLAG_MUTABLE
} else {
android.app.PendingIntent.FLAG_UPDATE_CURRENT
}
return android.app.PendingIntent
.getBroadcast(context, 0, intent, flags)
.intentSender
}
private const val WRITE_NAME = "thoughtsync-update"
}
@@ -0,0 +1,414 @@
package com.fabledsword.thoughtsync
import android.Manifest
import android.content.Intent
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import com.fabledsword.thoughtsync.core.ThoughtSync
import com.fabledsword.thoughtsync.ui.BoardScreen
import com.fabledsword.thoughtsync.ui.BoardSync
import com.fabledsword.thoughtsync.ui.BoardUpdate
import com.fabledsword.thoughtsync.ui.BoardViewModel
import com.fabledsword.thoughtsync.ui.ForegroundTransitions
import com.fabledsword.thoughtsync.ui.NoteEditorScreen
import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen
import com.fabledsword.thoughtsync.ui.SyncScreen
import com.fabledsword.thoughtsync.ui.SyncState
import com.fabledsword.thoughtsync.ui.SyncViewModel
import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme
import com.fabledsword.thoughtsync.ui.UpdateViewModel
import com.fabledsword.thoughtsync.ui.olderThan
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : ComponentActivity() {
/**
* The note a reminder notification asked for, waiting to be opened.
*
* Held on the Activity rather than passed to `setContent` once, because a tap
* on a notification while the app is already running arrives at [onNewIntent],
* not [onCreate] — the composition is long since built by then and the only
* way in is a piece of state it is already reading.
*/
private val requestedNote = mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val app = application as ThoughtSyncApplication
requestedNote.value = takeRequestedNote(intent)
setContent {
ThoughtSyncTheme {
val core = app.core
if (core == null) {
// The store never opened. There is no board to show and no
// action that would help, so say what happened plainly rather
// than render an empty board that looks like data loss.
StoreUnavailableScreen(reason = app.openFailure)
} else {
App(core, requestedNote)
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
requestedNote.value = takeRequestedNote(intent)
}
/**
* Read the note a notification asked for, and CONSUME it.
*
* The removal is the point. The Activity keeps the intent it was launched
* with, so without this, rotating the phone would replay it — reopening a note
* the person had tapped through and then closed, over and over, with no way to
* tell where it kept coming from.
*/
private fun takeRequestedNote(intent: Intent?): String? {
val id = intent?.getStringExtra(Reminders.EXTRA_NOTE_ID) ?: return null
intent.removeExtra(Reminders.EXTRA_NOTE_ID)
return id
}
}
/** Which screen is up. Exactly one at a time. */
private enum class Screen { BOARD, EDITOR, SYNC }
/**
* The whole app, once the store is open.
*
* One screen composed at a time, never stacked: the editor and the sync screen
* both cover the display completely, so keeping the board's two-column grid
* measuring and recomposing underneath one would be pure waste.
*
* Still no navigation library. Three destinations, each entered from exactly one
* place and left by back — a nav graph would be ceremony around an enum, and the
* state that actually matters (which note is open, whether this device is linked)
* already lives in view models.
*/
@Composable
private fun App(
core: ThoughtSync,
requestedNote: MutableState<String?>,
) {
val context = LocalContext.current
val board: BoardViewModel =
viewModel(
factory =
BoardViewModel.factory(core) {
// Any store write can have moved the next reminder. Called on
// the IO dispatcher by the view model, which is where it has to
// be — this reads every note carrying a reminder.
Reminders.refresh(context, core)
},
)
// Consumed, not just read: without clearing it, every later recomposition
// would reopen the same note and make the editor impossible to leave.
LaunchedEffect(requestedNote.value) {
requestedNote.value?.let {
board.openNoteById(it)
requestedNote.value = null
}
}
ReminderAlarms(core)
// A pull can rewrite every note the board is holding, so a sync that changed
// anything tells it to reload. Wired here, at the one place that owns both.
val sync: SyncViewModel =
viewModel(factory = SyncViewModel.factory(core, onStoreChanged = board::refresh))
// Screen visibility is view STATE, not view-model state: it is about what is on
// the display, and nothing in the store cares. Saveable so a rotation does not
// close it.
//
// The capture sheet used to keep its own flag here too. It is gone: the + button
// opens the editor on an unsaved draft, so writing a note and editing one are the
// same surface with the same toolbar.
var showingSync by rememberSaveable { mutableStateOf(false) }
val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context))
val settings = remember(context) { SyncSettings(context) }
var automatic by remember { mutableStateOf(settings.automatic) }
AutomaticSync(state = sync.state, enabled = automatic, onSync = sync::syncQuietly)
AutomaticUpdate(linked = sync.state.linked, onCheck = update::checkInBackground)
val editing = board.state.editing
val screen =
when {
showingSync -> Screen.SYNC
editing != null -> Screen.EDITOR
else -> Screen.BOARD
}
when (screen) {
Screen.SYNC ->
SyncScreen(
state = sync.state,
onClose = { showingSync = false },
onProbe = sync::probe,
onClearProbe = sync::clearProbe,
onLink = sync::link,
onSyncNow = sync::syncNow,
onUnlink = sync::unlink,
onDismissRevokeNotice = sync::dismissRevokeNotice,
automatic = automatic,
onAutomaticChange = {
automatic = it
settings.automatic = it
},
update = update.state,
onCheckUpdate = update::check,
onInstallUpdate = update::downloadAndInstall,
onDismissUpdateError = update::dismissError,
onInstallOutcome = update::consumeInstallOutcome,
)
Screen.EDITOR ->
NoteEditorScreen(
// Non-null by construction: `screen` is EDITOR only when it is.
note = requireNotNull(editing) { "the editor screen needs a note" },
sessionKey = board.state.editingSession,
labels = board.state.labels,
saving = board.state.saving,
error = board.state.error,
// The one seam between the editor and the store. Exhaustive at the
// other end, so a new action cannot be added without being handled.
onAction = { board.onEditorAction(editing, it) },
)
Screen.BOARD -> {
BoardScreen(
state = board.state,
onOpen = board::open,
onOpenNote = board::openNote,
sync =
BoardSync(
summary = syncSummary(sync),
error = sync.state.syncError,
refreshing = sync.state.syncing,
// Only a linked device has anywhere to pull FROM. The
// board never learns this itself — one owner for the fact.
canRefresh = sync.state.linked,
onRefresh = sync::syncNow,
onDismissError = sync::dismissSyncError,
),
onOpenSync = { showingSync = true },
onSearch = board::search,
onCompose = board::compose,
onToggleItem = board::toggleItem,
// The SAME seam the editor uses. `onEditorAction` is already the
// exhaustive dispatcher for every action a note has, and it takes
// the note to act on rather than reading the open one — so the board
// can hand it a card without a second dispatcher existing to drift.
onNoteAction = board::onEditorAction,
// Null unless there is genuinely something to say — the board is
// handed a decision, not a state to interpret.
update =
update.state.available
?.takeIf { update.state.nagging }
?.let {
BoardUpdate(
version = it.version,
busy = update.state.busy,
onInstall = update::downloadAndInstall,
onDismiss = update::dismissNag,
)
},
onDismissError = board::dismissError,
)
}
}
// The sync screen has no back handler of its own, so one lives here. The
// editor keeps its own, because it has to save the open note before leaving.
BackHandler(enabled = showingSync) { showingSync = false }
}
/**
* Keeping the alarm current, and asking to be allowed to ring it.
*
* The refresh runs on every return to the foreground rather than once at launch:
* a reminder can have been set on the desktop and pulled in while this app was
* backgrounded, and the alarm is derived from the store, not from what the UI last
* saw. It is cheap and idempotent by construction — see [Reminders.refresh].
*
* The permission is asked for on the first launch where a reminder actually
* exists. Android gives an app essentially one chance at this dialog, so spending
* it at first launch on an empty board — before the person has any idea what
* notifications this app would send — is spending it on nothing.
*/
@Composable
private fun ReminderAlarms(core: ThoughtSync) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
// Off the main thread: this reads every note that has a reminder, and a phone
// holding a few hundred would drop frames doing it during a resume.
val refresh = { scope.launch(Dispatchers.IO) { Reminders.refresh(context, core) } }
val prompt =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
// Whatever the answer, re-derive: if it was yes, the reminders that
// could not be shown a moment ago can be shown now.
refresh()
}
ForegroundTransitions(onForeground = { refresh() }, onBackground = {})
LaunchedEffect(Unit) {
// TIRAMISU is where POST_NOTIFICATIONS became a runtime permission. Below
// it, notifications are granted at install and there is nothing to ask.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@LaunchedEffect
val due = withContext(Dispatchers.IO) { Reminders.promptToNotifyDue(context, core) }
if (due) {
Reminders.markPromptShown(context)
prompt.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
/**
* Looking for an app update without being asked.
*
* Until this existed, `check()` had exactly one caller: a button on the sync screen.
* So a new build was found only by someone who went looking for one, and the operator
* had to remember to go looking — which is the same as not being told.
*
* On coming forward rather than on a timer: it is the moment the person is present,
* and the view model rate-limits so flicking between two apps is not a re-check.
* Unlinked devices are skipped entirely — updates come from a linked server, and
* there is nothing to ask.
*/
@Composable
private fun AutomaticUpdate(
linked: Boolean,
onCheck: () -> Unit,
) {
var wanted by remember { mutableStateOf(false) }
ForegroundTransitions(onForeground = { wanted = true }, onBackground = {})
LaunchedEffect(wanted, linked) {
if (!wanted || !linked) return@LaunchedEffect
// Consumed here, so this fires once per trip to the foreground however many
// times the effect restarts. There is no suspension point before the call, so
// the block completes before the recomposition that would cancel it.
wanted = false
onCheck()
}
}
/**
* Syncing without being asked.
*
* Three moments, and they are not the same job:
*
* - **Coming to the front.** Someone opening the app expects what they are
* looking at to be true. Rate-limited by [STALE_MINUTES] so flicking between
* two apps is not a request for fresh notes.
* - **Going away with unsent work.** Handed to WorkManager rather than run
* inline, because the process is about to stop being a priority and a sync
* started here would be killed halfway.
* - **Every fifteen minutes.** The background heartbeat, so a phone in a pocket
* is roughly current before it is picked up.
*
* Being unlinked or having the switch off makes all three no-ops, and cancels the
* scheduled work rather than merely skipping it.
*/
@Composable
private fun AutomaticSync(
state: SyncState,
enabled: Boolean,
onSync: () -> Unit,
) {
val context = LocalContext.current
// DECLARED as a function of two facts rather than toggled from the places
// that change them. There are four routes to "should not be syncing on its
// own" — never linked, just unlinked, switch off, switch off then unlink —
// and a call at each is four chances to leave a phone quietly syncing after
// it was told to stop.
LaunchedEffect(state.linked, enabled) {
if (state.linked && enabled) SyncSchedule.enable(context) else SyncSchedule.disable(context)
}
var wanted by remember { mutableStateOf(false) }
ForegroundTransitions(
onForeground = { wanted = true },
onBackground = {
// Unsent work follows the person out of the app. Without this, a note
// written on a phone that then goes into a pocket for the night does
// not reach the desktop until the app is opened again by hand.
if (enabled && state.linked && state.pending) SyncSchedule.pushSoon(context)
},
)
// Keyed on `loading` so the decision waits for the stored link to be READ. At
// first composition `linked` is still false because nothing has looked in the
// database yet, and acting on that would skip the sync on every cold start.
LaunchedEffect(wanted, state.loading) {
if (!wanted || state.loading) return@LaunchedEffect
// Consumed here, so this fires exactly once per trip to the foreground
// however many times the effect restarts. Writing a key from inside the
// effect does restart it — but there is no suspension point between here
// and the call below, so the block runs to completion before the
// recomposition that would cancel it can be scheduled.
wanted = false
val worthIt = state.pending || olderThan(state.status?.lastSyncAt, STALE_MINUTES)
if (enabled && state.linked && worthIt) onSync()
}
}
/**
* How stale the last sync has to be before opening the app triggers another.
*
* Not zero. Stepping out to copy a link and stepping back is not a request for
* fresh notes, and syncing on every app switch spends someone's mobile data to
* tell them what they are already looking at. Five minutes is short enough that
* coming back to the phone after doing something else gets current data, and
* long enough that flicking between two apps does not.
*
* Unsent local changes bypass this entirely — those go out at the first chance.
*/
private const val STALE_MINUTES = 5L
/**
* One line of sync state for the drawer, or null when there is nothing to say.
*
* Deliberately silent while the status is still loading and when the device is
* simply unlinked-and-idle — an "Off" badge on a local-first app would frame its
* normal resting state as something switched off.
*/
@Composable
private fun syncSummary(sync: SyncViewModel): String? {
val state = sync.state
return when {
state.loading -> null
!state.linked -> null
state.pending -> stringResource(R.string.sync_badge_unsent)
else -> stringResource(R.string.sync_badge_on)
}
}
@@ -0,0 +1,121 @@
package com.fabledsword.thoughtsync
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.fabledsword.thoughtsync.core.Note
/**
* What a due reminder looks like in the shade.
*
* Split from [Reminders] because the two answer different questions and change for
* different reasons: that one decides WHEN something should be said, this one
* decides how it is said and what can be done about it without opening the app.
*/
internal object ReminderNotification {
fun ensureChannel(context: Context) {
val channel =
NotificationChannel(
CHANNEL,
context.getString(R.string.reminder_channel),
// HIGH so a reminder can interrupt. Someone who asked to be
// reminded at a time has already said this may interrupt them;
// DEFAULT would leave it silent in the shade until next unlock.
NotificationManager.IMPORTANCE_HIGH,
).apply { description = context.getString(R.string.reminder_channel_description) }
context
.getSystemService(NotificationManager::class.java)
?.createNotificationChannel(channel)
}
/** Post one reminder. Returns whether it actually reached the shade. */
fun show(
context: Context,
note: Note,
): Boolean {
val manager = NotificationManagerCompat.from(context)
// Not marked as announced when this is false, so a reminder is not silently
// burned by being "delivered" to a device that cannot show it — turning
// notifications on later still surfaces it.
if (!manager.areNotificationsEnabled()) return false
val body = note.body.trim().takeIf { it.isNotEmpty() && it != note.displayTitle }
val builder =
NotificationCompat
.Builder(context, CHANNEL)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(note.displayTitle)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setContentIntent(openIntent(context, note))
.addAction(
0,
context.getString(R.string.reminder_done),
action(context, note, ReminderReceiver.ACTION_DONE),
).addAction(
0,
context.getString(R.string.reminder_snooze_hour),
action(context, note, ReminderReceiver.ACTION_SNOOZE),
)
if (body != null) {
builder.setContentText(body).setStyle(NotificationCompat.BigTextStyle().bigText(body))
}
return runCatching {
manager.notify(note.id.hashCode(), builder.build())
true
}.getOrElse {
// POST_NOTIFICATIONS can be revoked between the check and the post.
Log.w(TAG, "could not post reminder", it)
false
}
}
/** Take a reminder off the shade, once it has been acted on. */
fun dismiss(
context: Context,
noteId: String,
) = NotificationManagerCompat.from(context).cancel(noteId.hashCode())
private fun openIntent(
context: Context,
note: Note,
): PendingIntent =
PendingIntent.getActivity(
context,
note.id.hashCode(),
Intent(context, MainActivity::class.java)
.setAction(Intent.ACTION_VIEW)
.putExtra(Reminders.EXTRA_NOTE_ID, note.id)
// Reuse the running task rather than stacking a second copy of the
// app on top of itself; MainActivity picks the id up in onNewIntent.
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private fun action(
context: Context,
note: Note,
what: String,
): PendingIntent =
PendingIntent.getBroadcast(
context,
// Distinct per note AND per action, or the two would share one
// PendingIntent and Snooze would quietly perform Done.
(note.id + what).hashCode(),
Intent(context, ReminderReceiver::class.java)
.setAction(what)
.putExtra(Reminders.EXTRA_NOTE_ID, note.id),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private const val CHANNEL = "reminders"
private const val TAG = "ThoughtSyncReminders"
}
@@ -0,0 +1,72 @@
package com.fabledsword.thoughtsync
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* Everything that happens to a reminder while the app is not on screen.
*
* Four arrivals, one ending: whatever came in, the reminder picture is recomputed
* and the next alarm is set. That is deliberate — it means no path here has to
* remember to reschedule, and the one that fires an alarm cannot leave the device
* with no alarm pending.
*
* - **[ACTION_DUE]** — the alarm went off. Announce what is due.
* - **[ACTION_DONE] / [ACTION_SNOOZE]** — a notification button. Write it to the
* store, drop the notification.
* - **`BOOT_COMPLETED`** — alarms do not survive a restart, so every reminder on
* the device would silently stop existing without this.
* - **`MY_PACKAGE_REPLACED`** — an app update cancels them the same way. This
* device installs by APK from its own server, so updates are routine.
*
* ## Threading
*
* `onReceive` runs on the main thread and the store is blocking SQLite, so the
* work goes to [Dispatchers.IO] under [goAsync]. Without `goAsync` the process
* becomes killable the moment `onReceive` returns, which for a reminder firing at
* 3am is precisely when nothing is holding it up.
*/
class ReminderReceiver : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent,
) {
val core = (context.applicationContext as? ThoughtSyncApplication)?.core ?: return
val action = intent.action
val noteId = intent.getStringExtra(Reminders.EXTRA_NOTE_ID)
val app = context.applicationContext
val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
when (action) {
ACTION_DONE -> noteId?.let { Reminders.complete(app, core, it) }
ACTION_SNOOZE -> noteId?.let { Reminders.snooze(app, core, it) }
// The alarm and the two system broadcasts all want the same
// thing, which is simply: look at the store and act on it.
else -> Unit
}
Reminders.refresh(app, core)
} catch (e: Exception) {
// Broad on purpose. Nobody is present, so an escaping exception is
// a crash report for something the person never initiated — and
// every path in here has already logged its own failure.
Log.w(TAG, "reminder broadcast failed: $action", e)
} finally {
pending.finish()
}
}
}
companion object {
const val ACTION_DUE = "com.fabledsword.thoughtsync.REMINDER_DUE"
const val ACTION_DONE = "com.fabledsword.thoughtsync.REMINDER_DONE"
const val ACTION_SNOOZE = "com.fabledsword.thoughtsync.REMINDER_SNOOZE"
private const val TAG = "ThoughtSyncReminders"
}
}
@@ -0,0 +1,245 @@
package com.fabledsword.thoughtsync
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import com.fabledsword.thoughtsync.core.Note
import com.fabledsword.thoughtsync.core.ThoughtSync
import java.time.OffsetDateTime
/**
* Getting a reminder in front of someone at the time they asked for.
*
* ## One alarm, not one per reminder
*
* Only the EARLIEST future reminder is ever scheduled. When it fires, everything
* now due is announced and the next one is scheduled. A hundred reminders cost one
* alarm, and there is no bookkeeping to get wrong when a note is edited on another
* device and arrives by sync — [refresh] recomputes the whole picture from the
* store every time.
*
* ## AlarmManager, not WorkManager
*
* The background sync runs on WorkManager and is right to: nobody minds whether it
* happens at 3:05 or 3:19. A reminder minded very much. WorkManager's periodic
* floor is fifteen minutes and it batches work into maintenance windows, so
* "remind me at 09:00" would routinely arrive at 09:14 — which is not a reminder,
* it is a rebuke.
*
* Exactness is asked for and not depended on: on Android 12+ it is a permission
* the person can refuse, and refusing drops this to an inexact alarm rather than
* to nothing. A reminder a few minutes late still beats no reminder, and pestering
* someone into a settings screen before the feature works at all is the coercion
* this product does not do.
*/
object Reminders {
const val EXTRA_NOTE_ID = "note_id"
/**
* How long after its time a missed reminder is still worth announcing.
*
* The web uses fifteen minutes, because a tab that is open has been checking
* every forty-five seconds and anything older than that was almost certainly
* already seen. A phone can be switched off all night, so the equivalent
* question here — "could this plausibly not have been seen yet?" — has a much
* longer answer. Beyond a day it stops being a reminder and starts being
* archaeology; the note is still on the board, still marked overdue in red.
*/
private const val MISSED_WINDOW_MS = 24L * 60 * 60 * 1000
private const val SNOOZE_MINUTES = 60L
private const val TAG = "ThoughtSyncReminders"
/**
* Announce what is due, then schedule the next one.
*
* Safe to call as often as anything might have changed — after an edit, after
* a sync, at launch, at boot. It reads the whole reminder set each time and
* derives everything from it, so there is no incremental state to drift.
*/
fun refresh(
context: Context,
core: ThoughtSync,
) {
ReminderNotification.ensureChannel(context)
val notes =
runCatching { core.reminderNotes() }
.onFailure { Log.w(TAG, "could not read reminders", it) }
.getOrElse { return }
val now = System.currentTimeMillis()
val announced = Announced(context)
// Intersecting with what still exists prunes the record in the same step:
// a reminder that was completed, snoozed to a new time or deleted drops out
// on its own, so this set cannot grow without bound.
val live = notes.mapNotNull { key(it) }.toSet()
val seen = announced.keys().intersect(live).toMutableSet()
val due = notes.filter { at(it)?.let { ms -> ms <= now } == true }
if (!announced.primed) {
// First run on this device. Adopt everything already overdue SILENTLY:
// the storm case is linking a server and pulling months of history, and
// a hundred notifications the moment someone signs in is a good way to
// have them turn the feature off before it has ever been useful.
due.forEach { note -> key(note)?.let { seen += it } }
} else {
due.forEach { note ->
val k = key(note) ?: return@forEach
val overdueBy = now - (at(note) ?: return@forEach)
if (k !in seen && overdueBy <= MISSED_WINDOW_MS && ReminderNotification.show(context, note)) {
seen += k
}
}
}
announced.write(seen)
scheduleNext(context, notes, now)
}
/**
* Whether it is worth putting Android's notification prompt in front of someone.
*
* True only when there is a reminder that could actually fire and we have not
* asked before. Asking at launch on an empty board would be a dialog with no
* visible cause, which is how people learn to dismiss dialogs unread; asking
* again after a refusal is nagging, and the Reminders view carries a standing
* notice for anyone who changes their mind.
*/
fun promptToNotifyDue(
context: Context,
core: ThoughtSync,
): Boolean =
!Announced(context).askedToNotify &&
!NotificationManagerCompat.from(context).areNotificationsEnabled() &&
runCatching { core.reminderNotes().isNotEmpty() }.getOrDefault(false)
/** Remember that Android's prompt has been shown, whatever the answer was. */
fun markPromptShown(context: Context) = Announced(context).markAsked()
/** Clear the reminder, as the notification's Done action. */
fun complete(
context: Context,
core: ThoughtSync,
noteId: String,
) {
runCatching { core.completeReminder(noteId) }
.onFailure { Log.w(TAG, "could not complete reminder", it) }
ReminderNotification.dismiss(context, noteId)
}
/** Push the reminder an hour out, as the notification's Snooze action. */
fun snooze(
context: Context,
core: ThoughtSync,
noteId: String,
) {
runCatching { core.snoozeReminder(noteId, SNOOZE_MINUTES) }
.onFailure { Log.w(TAG, "could not snooze reminder", it) }
ReminderNotification.dismiss(context, noteId)
}
// ─────────────────────────────── scheduling ───────────────────────────────
private fun scheduleNext(
context: Context,
notes: List<Note>,
now: Long,
) {
val alarms = context.getSystemService(AlarmManager::class.java) ?: return
val fire =
PendingIntent.getBroadcast(
context,
0,
Intent(context, ReminderReceiver::class.java).setAction(ReminderReceiver.ACTION_DUE),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val next = notes.mapNotNull { at(it) }.filter { it > now }.minOrNull()
if (next == null) {
alarms.cancel(fire)
return
}
// RTC_WAKEUP: reminders are wall-clock times, and the point is to wake a
// sleeping phone. ELAPSED_REALTIME would drift against the clock the person
// actually set the reminder against.
runCatching {
if (canBeExact(alarms)) {
alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, next, fire)
} else {
alarms.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, next, fire)
}
}.onFailure {
// setExact can still throw if the permission was revoked between the
// check and the call. Falling back beats losing the reminder entirely.
Log.w(TAG, "exact alarm refused, falling back to inexact", it)
runCatching { alarms.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, next, fire) }
}
}
/**
* Whether this device will let us fire at the exact minute.
*
* Below Android 12 there was no permission and exact alarms always worked.
* From 12 it is grantable and from 13 it is denied by default, so this is a
* question with a real answer rather than a formality.
*/
fun canBeExact(alarms: AlarmManager): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms()
// ────────────────────────────── bookkeeping ──────────────────────────────
/** Epoch millis of a note's reminder, or null if it has none we can read. */
private fun at(note: Note): Long? =
note.remindAt?.let {
runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull()
}
/**
* Identity of one OCCURRENCE, not of the note.
*
* The time is part of it so that snoozing — which rewrites `remind_at` — is a
* new thing to announce rather than one already dealt with. Same key the web
* store uses, for the same reason.
*/
private fun key(note: Note): String? = note.remindAt?.let { "${note.id}@$it" }
}
/** Which reminder occurrences have already been put in front of someone. */
private class Announced(
context: Context,
) {
private val prefs =
context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
/** False only before the very first [Reminders.refresh] on this install. */
val primed: Boolean get() = prefs.getBoolean(KEY_PRIMED, false)
// Copied: the set from getStringSet must not be mutated, and the docs are
// explicit that doing so corrupts what is stored.
fun keys(): Set<String> = prefs.getStringSet(KEY_SEEN, emptySet())?.toSet().orEmpty()
fun write(keys: Set<String>) {
prefs
.edit()
.putStringSet(KEY_SEEN, keys)
.putBoolean(KEY_PRIMED, true)
.apply()
}
/** Survives a restart, so the prompt is a one-off rather than once per launch. */
val askedToNotify: Boolean get() = prefs.getBoolean(KEY_ASKED, false)
fun markAsked() = prefs.edit().putBoolean(KEY_ASKED, true).apply()
private companion object {
const val FILE = "thoughtsync-reminders"
const val KEY_SEEN = "announced"
const val KEY_PRIMED = "primed"
const val KEY_ASKED = "asked_to_notify"
}
}
@@ -0,0 +1,94 @@
package com.fabledsword.thoughtsync
import android.content.Context
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
/**
* When the system should sync on its own.
*
* All of the policy lives here rather than being spread across the call sites,
* because the interesting question is not "how do I enqueue work" but "how often
* is often enough" — and that answer should be readable in one place.
*
* Two jobs, deliberately different:
*
* - **[enable]** is the heartbeat. Fifteen minutes is not a preference, it is
* WorkManager's floor for periodic work; asking for less silently gets you
* fifteen anyway. It keeps a phone that is sitting in a pocket roughly current
* so that opening the app is not a wait.
* - **[pushSoon]** is for the moment a person walks away from a note they just
* wrote. Waiting up to fifteen minutes to hand that to the server is the
* difference between "my notes are everywhere" and "my notes are on whichever
* device I used last", which is the whole point of the product.
*
* Both require a network. Without that constraint every run on a phone with no
* signal would wake the process, open SQLite, fail a connection and burn the
* retry budget for nothing.
*/
object SyncSchedule {
/** Every 15 minutes while linked. */
fun enable(context: Context) {
val request =
PeriodicWorkRequestBuilder<SyncWorker>(PERIOD_MINUTES, TimeUnit.MINUTES)
.setConstraints(networkRequired())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS)
.build()
// UPDATE, not KEEP: this is called on every launch, and KEEP would ignore
// a changed interval forever on any device that had ever enqueued the old
// one. UPDATE applies the change WITHOUT resetting the next run, so
// opening the app repeatedly cannot push the sync further away each time.
WorkManager
.getInstance(context)
.enqueueUniquePeriodicWork(PERIODIC, ExistingPeriodicWorkPolicy.UPDATE, request)
}
/** Stop syncing on our own: unlinked, or the person turned it off. */
fun disable(context: Context) {
WorkManager.getInstance(context).apply {
cancelUniqueWork(PERIODIC)
cancelUniqueWork(PUSH)
}
}
/**
* Get whatever is unsent off this device, as soon as there is a network.
*
* Enqueued when the app goes to the background holding unsent changes, so a
* note survives being written on a phone that is then put away for the night.
*/
fun pushSoon(context: Context) {
val request =
OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(networkRequired())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS)
.build()
// REPLACE rather than KEEP: if an earlier attempt is sitting in a long
// backoff, the person has just given us a reason to try again sooner.
WorkManager
.getInstance(context)
.enqueueUniqueWork(PUSH, ExistingWorkPolicy.REPLACE, request)
}
private fun networkRequired(): Constraints =
Constraints
.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
private const val PERIODIC = "thoughtsync-periodic-sync"
private const val PUSH = "thoughtsync-push-pending"
/** WorkManager's own minimum for periodic work. Asking for less gets this. */
private const val PERIOD_MINUTES = 15L
private const val BACKOFF_SECONDS = 30L
}
@@ -0,0 +1,44 @@
package com.fabledsword.thoughtsync
import android.content.Context
/**
* Whether this device syncs on its own, and nothing else.
*
* Device-local on purpose. Every other piece of sync state — the server, the
* token, the cursor — lives in the core's SQLite file because it describes the
* PAIRING and has to survive a reinstall to the same account. This describes
* how one phone behaves, and a person who turns it off on their handset is not
* asking their laptop to stop.
*
* `SharedPreferences` rather than the store because the background worker reads
* it on a process the system started, where reaching for the core would mean
* depending on the store having opened successfully to answer a question that
* has nothing to do with the store.
*/
class SyncSettings(
context: Context,
) {
// applicationContext: this outlives any Activity, and holding one here would
// leak the whole window when the phone rotates.
private val prefs =
context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
/**
* Defaults to ON.
*
* Linking a server IS the consent — a person who paired this device and then
* had to find a second switch before anything moved would reasonably call
* that broken. Turning it off leaves manual sync working exactly as before.
*/
var automatic: Boolean
get() = prefs.getBoolean(KEY_AUTOMATIC, true)
set(value) {
prefs.edit().putBoolean(KEY_AUTOMATIC, value).apply()
}
private companion object {
const val FILE = "thoughtsync-sync"
const val KEY_AUTOMATIC = "automatic"
}
}
@@ -0,0 +1,72 @@
package com.fabledsword.thoughtsync
import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
/**
* One sync cycle, run by the system rather than by a person.
*
* WorkManager may start the process to run this, which means [ThoughtSyncApplication.onCreate]
* has already opened the store by the time [doWork] is called — the same handle
* the UI uses, so there is never a second SQLite connection racing the first.
*
* The automatic-sync switch is checked here as well as at scheduling time. That
* does NOT avoid opening the store — `onCreate` has already done it by the time
* any Worker runs — it avoids the network call and the writes.
*
* ## Why the outcome is thrown away
*
* A run that nobody asked for must not become a notification, a banner, or
* anything else that interrupts. If it pulled changes, the board reloads next
* time it is looked at; if it pushed them, they are gone from the outbox. The
* one thing the person can act on — "there are unsent notes" — is already told
* by the drawer badge, from `has_pending`, which does not care how the attempt
* was made.
*/
class SyncWorker(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val core = (applicationContext as? ThoughtSyncApplication)?.core
// Both of these are "nothing to do", not "something went wrong", so both
// report success and let the run retire quietly:
// * automatic sync was switched off after this was enqueued — the
// schedule is cancelled then, but a run already handed to the system
// can still land;
// * the store never opened, which no retry fixes this launch and which
// the UI is already reporting to whoever is looking.
if (!SyncSettings(applicationContext).automatic || core == null) return Result.success()
return try {
// Unlinked since this was enqueued. Not a failure — retrying would
// burn the backoff schedule on a device that has no server.
if (core.syncStatus().linked) {
val outcome = core.syncNow()
Log.i(TAG, "background sync at ${outcome.status.lastSyncAt}")
// A pull can have brought in a reminder set on another device, or
// moved one this phone already knew about. The alarm is derived
// from the store, so it has to be re-derived whenever the store
// changed underneath it — otherwise a reminder made at a desk
// never rings on the phone until the app is next opened.
Reminders.refresh(applicationContext, core)
}
Result.success()
} catch (e: Exception) {
// Deliberately broad, and deliberately `retry` rather than `failure`:
// almost everything that goes wrong here is a flat tyre — no route to
// the server, a laptop asleep, a token being rotated. Retry hands it
// to WorkManager's exponential backoff; `failure` would drop the run
// for good and strand the notes until someone opens the app by hand.
Log.w(TAG, "background sync failed, will retry", e)
Result.retry()
}
}
private companion object {
const val TAG = "ThoughtSyncWorker"
}
}
@@ -0,0 +1,49 @@
package com.fabledsword.thoughtsync
import android.app.Application
import android.util.Log
import com.fabledsword.thoughtsync.core.ThoughtSync
/**
* Opens the shared Rust core once, for the process lifetime.
*
* The store is a single SQLite file behind a mutex, so one handle is both
* sufficient and correct — a second would be two connections racing for the same
* lock. This mirrors how the desktop manages it as Tauri app state.
*
* [filesDir] is app-private storage: readable by this app and nothing else,
* removed on uninstall, and never on external media. The core does not guess at
* platform paths; Android is the only thing that knows where this is.
*/
class ThoughtSyncApplication : Application() {
/**
* Null only if the store could not be opened — a corrupt or unwritable
* database. The UI reports that honestly rather than crashing on first
* touch, because a user whose notes won't open needs a message, not a
* stack trace.
*/
var core: ThoughtSync? = null
private set
var openFailure: String? = null
private set
override fun onCreate() {
super.onCreate()
try {
val handle = ThoughtSync(filesDir.absolutePath)
core = handle
Log.i(TAG, "local store ready — ${handle.summary()}")
} catch (e: Exception) {
// Deliberately broad: whatever went wrong, the app still has to
// start and say so. Narrowing this would mean an unanticipated
// failure mode takes the process down at launch instead.
openFailure = e.message ?: e.toString()
Log.e(TAG, "could not open the local store", e)
}
}
private companion object {
const val TAG = "ThoughtSync"
}
}
@@ -0,0 +1,37 @@
package com.fabledsword.thoughtsync
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/**
* The last thing the system said about an install, waiting to be shown.
*
* A process-wide holder because the two ends cannot reach each other any other
* way: [UpdateReceiver] is constructed by the system, and the view model that
* wants the answer is owned by the composition. The alternative — a bound service
* or a broadcast the UI also listens for — is more machinery for one nullable
* string.
*
* Safe as snapshot state: `onReceive` runs on the main thread, which is where
* Compose expects its state to be written.
*/
object UpdateOutcome {
/** `error == null` means it went through, or the person declined. */
data class Result(
val error: String?,
)
/** Null until the system has said something about an install we committed. */
var latest: Result? by mutableStateOf(null)
private set
fun report(error: String?) {
latest = Result(error)
}
/** Called once the UI has shown it, so a later install starts from silence. */
fun clear() {
latest = null
}
}
@@ -0,0 +1,77 @@
package com.fabledsword.thoughtsync
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.util.Log
/**
* What the system says about an install we committed.
*
* Without this the app would `commit` and learn nothing — a failed install would
* look exactly like a person deciding not to go ahead, and the update card would
* sit there claiming an update is available with no explanation of why nothing
* happened. That was the specific complaint recorded against Minstrel's first
* attempt (Scribe #2438).
*
* 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
* the card they are looking at.
*/
class UpdateReceiver : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent,
) {
if (intent.action != ACTION_INSTALLED) return
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, Int.MIN_VALUE)
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
when (status) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
// Android wants a confirmation. This is the ORDINARY path below API
// 31, and the path on 31+ whenever the OS declines to skip the
// dialog — which it may, and is entitled to.
val confirm =
@Suppress("DEPRECATION")
intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
if (confirm == null) {
UpdateOutcome.report("Android asked for confirmation but sent no way to give it.")
return
}
// NEW_TASK because a receiver has no activity of its own to start
// from. The app is in the foreground, so this surfaces immediately.
confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { context.startActivity(confirm) }
.onFailure {
Log.w(TAG, "could not show the install confirmation", it)
UpdateOutcome.report("Android's install confirmation could not be shown.")
}
}
PackageInstaller.STATUS_SUCCESS -> {
// Rarely seen: a successful self-update replaces this process, so
// the app is usually gone before it can act on this.
Log.i(TAG, "update installed")
UpdateOutcome.report(null)
}
PackageInstaller.STATUS_FAILURE_ABORTED ->
// Someone declined. Not an error, and saying "install failed" for a
// deliberate choice is how an app sounds broken when it is not.
UpdateOutcome.report(null)
else -> {
Log.w(TAG, "install failed: status=$status message=$message")
UpdateOutcome.report(message ?: "The update did not install.")
}
}
}
companion object {
const val ACTION_INSTALLED = "com.fabledsword.thoughtsync.UPDATE_INSTALLED"
private const val TAG = "ThoughtSyncUpdate"
}
}
@@ -0,0 +1,264 @@
package com.fabledsword.thoughtsync.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
/**
* The note's body, as fields and checkboxes rather than as markup.
*
* The point of the whole shape: a box you can tick while looking at the note, rather
* than `- [ ] ` to read and edit around. What the note IS never changed.
*/
@Composable
fun BlockBody(
blocks: List<EditorBlock>,
readOnly: Boolean,
focus: Long?,
onChange: (List<EditorBlock>) -> Unit,
onFocus: (Long?) -> Unit,
modifier: Modifier = Modifier,
) {
// Focus is addressed by block ID, never by position — the id is the only thing
// about a block that survives one being inserted above it. Hoisted to the caller
// rather than kept here, because the TOOLBAR also asks for a focus when its button
// appends an item, and two owners of one cursor is one too many.
val requesters = remember { mutableMapOf<Long, FocusRequester>() }
LaunchedEffect(focus) {
val id = focus ?: return@LaunchedEffect
// Honoured after the composition that created the field: a FocusRequester not
// yet attached to anything throws when asked.
requesters[id]?.requestFocus()
onFocus(null)
}
fun replace(
index: Int,
block: EditorBlock,
) = onChange(blocks.toMutableList().also { it[index] = block })
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) {
blocks.forEachIndexed { index, block ->
val requester = requesters.getOrPut(block.id) { FocusRequester() }
if (block.isTask) {
TaskBlock(
block = block,
readOnly = readOnly,
requester = requester,
onChange = { replace(index, it) },
onEnter = {
val next = blocks.nextId()
onChange(afterEnter(blocks, index, next))
// The new item if there was one; otherwise the block that just
// became prose, which keeps the caret where the person left it.
onFocus(if (blocks[index].value.text.isBlank()) block.id else next)
},
onDelete = {
val remaining = blocks.withoutIndex(index)
onChange(remaining)
// The row above — or, for the FIRST row, whichever one takes
// its place. `index - 1` alone is -1 there, which left the
// keyboard up with nothing focused.
onFocus(remaining.getOrNull((index - 1).coerceAtLeast(0))?.id)
},
)
} else {
ProseBlock(
block = block,
readOnly = readOnly,
requester = requester,
onChange = { replace(index, it) },
onBlur = {
// Compared by IDENTITY, not equality: `promotingTasks` hands
// back the same list when there was nothing to promote, and a
// blur that changed nothing must not touch the state at all.
val promoted = blocks.promotingTasks(index)
if (promoted !== blocks) onChange(promoted)
},
)
}
}
}
}
/**
* A run of prose: one ordinary multi-line field, exactly as the editor always had.
*
* 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.
*
* `onFocusChanged` also fires with `isFocused = false` on the first composition, before
* the field has ever held focus. Deliberately not guarded: [splitBlocks] ran when the
* editor opened, so a prose block nobody has typed in cannot contain a task line, and
* the promotion is a no-op that the caller's identity check drops on the floor.
*/
@Composable
private fun ProseBlock(
block: EditorBlock,
readOnly: Boolean,
requester: FocusRequester,
onChange: (EditorBlock) -> Unit,
onBlur: () -> Unit,
) {
BlockField(
value = block.value,
onValueChange = { onChange(block.copy(value = it)) },
modifier =
Modifier
.focusRequester(requester)
.onFocusChanged { if (!it.isFocused) onBlur() },
enabled = !readOnly,
hint = R.string.editor_body_hint,
)
}
/**
* One checklist item: a real box, and the item's text beside it.
*
* Single-line with [ImeAction.Next], which is what turns the keyboard's return key
* into "next item" — the reason a list can be typed straight through rather than a
* marker at a time.
*/
@Composable
private fun TaskBlock(
block: EditorBlock,
readOnly: Boolean,
requester: FocusRequester,
onChange: (EditorBlock) -> Unit,
onEnter: () -> Unit,
onDelete: () -> Unit,
) {
// Material sizes every interactive component to a 48dp touch target, and on a
// checklist that IS the row height — which is why six items filled a phone screen
// even after the field's own padding came off.
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides ROW_TOUCH) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = block.checked == true,
onCheckedChange = { onChange(block.copy(checked = it)) },
enabled = !readOnly,
)
BlockField(
value = block.value,
onValueChange = { onChange(block.copy(value = it)) },
modifier = Modifier.weight(1f).focusRequester(requester),
enabled = !readOnly,
singleLine = true,
textStyle =
MaterialTheme.typography.bodyLarge.copy(
// Struck through when done, matching the card and the web.
textDecoration =
if (block.checked == true) TextDecoration.LineThrough else null,
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { onEnter() }),
)
if (!readOnly) {
IconButton(onClick = onDelete) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.editor_remove_item),
)
}
}
}
}
}
/**
* The touch target for a checklist row's controls.
*
* Material's floor is 48dp and this is deliberately under it. That floor is sized for
* a control somebody has to find; a checklist box sits in a predictable column with an
* identical box directly above and below, and the cost of a near miss is ticking the
* neighbouring item — visible, and undone by tapping again. Trading twelve of those
* dp for a list that fits on a screen is what was asked for, twice.
*/
private val ROW_TOUCH = 36.dp
/**
* The field a block is typed into.
*
* `BasicTextField`, not the Material one [PlainTextField] wraps, and the reason is
* density. Material's TextField puts 16dp above and below its text — padding that
* makes a FORM field comfortable to hit, and that on a checklist IS the row height. It
* made six items twice as tall as the six items, which is what the operator saw.
*
* Nothing is lost by dropping down a layer. `PlainTextField` exists to strip a
* container and an indicator; `BasicTextField` never had either, so there is no box
* here to drift back into existence. What it does not supply and this must:
*
* - the text COLOUR. It defaults to `Color.Unspecified`, which draws BLACK — the same
* default that made the editor's toolbar invisible in dark mode. Set, not inherited.
* - the 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.
*
* `enabled = false` deliberately does not grey the text out: a trashed note renders
* read-only through this and its words are meant to be READ.
*/
@Composable
private fun BlockField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
singleLine: Boolean = false,
@StringRes hint: Int? = null,
textStyle: TextStyle = MaterialTheme.typography.bodyLarge,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
val style = textStyle.copy(color = MaterialTheme.colorScheme.onSurface)
Box(modifier = modifier) {
if (hint != null && value.text.isEmpty()) {
Text(
text = stringResource(hint),
style = style,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
enabled = enabled,
singleLine = singleLine,
textStyle = style,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
)
}
}
@@ -0,0 +1,560 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.pullToRefresh
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Label
import com.fabledsword.thoughtsync.core.Note
import kotlinx.coroutines.launch
@Composable
fun BoardScreen(
state: BoardState,
onOpen: (Destination) -> Unit,
onOpenNote: (Note) -> Unit,
sync: BoardSync,
onOpenSync: () -> Unit,
onSearch: (String) -> Unit,
onCompose: () -> Unit,
onToggleItem: (Note, Int, Boolean) -> Unit,
onNoteAction: (Note, EditorAction) -> Unit,
update: BoardUpdate?,
onDismissError: () -> Unit,
) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val snackbars = remember { SnackbarHostState() }
// Held HERE rather than on the card. A card lives in a lazy grid and is disposed
// the moment it scrolls out of view, which would take its dialog down with it —
// and the board can scroll under an open dialog.
var confirmingDelete by remember { mutableStateOf<Note?>(null) }
// Resolved in composition, not inside the coroutine: `stringResource` is a
// composable read and cannot be called from a suspend block.
val trashedMessage = stringResource(R.string.board_trashed)
val undoLabel = stringResource(R.string.board_undo)
// Trash gets an UNDO rather than a confirmation, and the two are not
// interchangeable. A long press is a gesture you can make by accident — resting a
// thumb while reading is enough — so the mistake worth designing for is the one
// nobody meant to make, and a dialog only helps someone who is paying attention
// in the moment they were not. Trash is already recoverable; the snackbar just
// says so where it happened, instead of leaving you to find the Trash view and
// work out which note went missing.
//
// Delete forever keeps its dialog. That one does not undo.
val onCardAction: (Note, EditorAction) -> Unit = { note, action ->
onNoteAction(note, action)
if (action == EditorAction.Trash) {
scope.launch {
val outcome =
snackbars.showSnackbar(
message = trashedMessage,
actionLabel = undoLabel,
duration = SnackbarDuration.Short,
)
// `note` is the pre-trash copy and deliberately so: Restore only needs
// its id, and the id is the one thing trashing does not change.
if (outcome == SnackbarResult.ActionPerformed) {
onNoteAction(note, EditorAction.Restore)
}
}
}
}
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
NavigationDrawer(
current = state.destination,
labels = state.labels,
syncSummary = sync.summary,
onOpen = {
onOpen(it)
scope.launch { drawerState.close() }
},
onOpenSync = {
onOpenSync()
scope.launch { drawerState.close() }
},
)
},
) {
Scaffold(
// The IME, added to what the Scaffold already insets for. `enableEdgeToEdge`
// makes the manifest's `adjustResize` a no-op on API 30+, so nothing resizes
// for the keyboard unless the app asks — and `ScaffoldDefaults.contentWindowInsets`
// is systemBars, which the IME is not part of. The Scaffold positions the FAB
// AND the snackbar host from this value, so without it both sit behind the
// keyboard whenever the search field has focus. That is not theoretical: the
// undo on a trashed search hit is exactly the control you cannot reach.
//
// `union` rather than `add` — the two are the same edge, not two stacked ones.
// Adding them would inset by the navigation bar a second time underneath a
// keyboard that already covers it.
//
// One owner for the edge, as with the search bar's missing statusBarsPadding:
// set here, the content Column gets it through `padding` and must not repeat it.
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
snackbarHost = { SnackbarHost(snackbars) },
floatingActionButton = {
// The + is the ONLY way in, by design: one obvious target rather
// than a capture bar and a button competing for the same job.
FloatingActionButton(
onClick = onCompose,
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
) {
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.compose_open))
}
},
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
SearchBar(
query = state.query,
onQueryChange = onSearch,
onMenu = { scope.launch { drawerState.open() } },
)
state.error?.let { message ->
ErrorBanner(message = message, onDismiss = onDismissError)
}
// Stacked rather than one-or-the-other. A store failure and a sync
// failure are different facts about different halves of the app;
// hiding either behind the other would report the wrong problem.
sync.error?.let { message ->
ErrorBanner(message = message, onDismiss = sync.onDismissError)
}
// Below the failures and above the notes: an update is worth saying,
// and never worth saying before a note failed to save.
update?.let {
UpdateBanner(
version = it.version,
busy = it.busy,
onInstall = it.onInstall,
onDismiss = it.onDismiss,
)
}
// Only where someone is already thinking about reminders. On the
// main board it would nag people who have never set one.
if (state.destination == Destination.Reminders) ReminderNotice()
val pull = rememberPullToRefreshState()
Box(
modifier =
Modifier
.fillMaxSize()
.pullToRefresh(
isRefreshing = sync.refreshing,
state = pull,
// INERT on a device with no server, rather than
// spinning and finding nothing: there is no remote
// to fetch from, and a gesture that always comes
// back empty teaches people it is broken.
enabled = sync.canRefresh && !state.loading,
onRefresh = sync.onRefresh,
),
) {
when {
state.loading -> LoadingBoard()
state.notes.isEmpty() -> EmptyBoard(state)
else ->
NoteBoard(
notes = state.notes,
onOpenNote = onOpenNote,
onToggleItem = onToggleItem,
onNoteAction = onCardAction,
onConfirmDelete = { confirmingDelete = it },
)
}
// `PullToRefreshBox` would be less code, but it takes no
// `enabled`, so the modifier and the indicator are wired by
// hand to keep the gate above.
PullToRefreshDefaults.Indicator(
state = pull,
isRefreshing = sync.refreshing,
modifier = Modifier.align(Alignment.TopCenter),
)
}
}
}
confirmingDelete?.let { note ->
ConfirmDeleteDialog(
onConfirm = {
confirmingDelete = null
onNoteAction(note, EditorAction.DeleteForever)
},
onDismiss = { confirmingDelete = null },
)
}
}
}
/**
* Everything the board knows about sync, which is deliberately not much.
*
* A holder rather than six loose parameters, for the reason `EditorAction`
* exists: [summary] and [error] are both `String?` and both about sync, so as
* positional arguments they could be swapped with nothing to catch it. Named
* fields make that unsayable.
*
* Passed in rather than read from [BoardState]. Sync has its own view model, and
* giving the board a second copy of "is this device linked" would be two sources
* of truth for one fact.
*/
data class BoardSync(
/** One line for the drawer badge, or null when there is nothing worth saying. */
val summary: String?,
/** The last sync failure, still unacknowledged. */
val error: String?,
/** A sync is in flight — drives the indicator, whoever started it. */
val refreshing: Boolean,
/** Whether there is a server to refresh FROM. False means the gesture is off. */
val canRefresh: Boolean,
val onRefresh: () -> Unit,
val onDismissError: () -> Unit,
)
/**
* The waiting app update, or null when there is nothing to say.
*
* A holder rather than five loose parameters, for the same reason [BoardSync] is one:
* `version` and a pair of booleans as positional arguments could be swapped with
* nothing to catch it.
*
* Null covers every reason there is nothing to show — unlinked, up to date, found but
* not yet downloaded, dismissed for this sitting — so the board never has to know
* which.
*/
data class BoardUpdate(
val version: String,
/** An install is in flight — the banner stays and reports it. */
val busy: Boolean,
val onInstall: () -> Unit,
val onDismiss: () -> Unit,
)
/**
* A search field IS the top bar, following the phone convention rather than the
* desktop's title-plus-sidebar.
*
* On a phone, finding a note you already wrote is the most common thing after
* writing one, and burying it behind an icon costs a tap every time. The drawer
* lives inside it on the left, which is where every Android user reaches for
* navigation.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
onMenu: () -> Unit,
) {
Surface(
// No `statusBarsPadding()` here. The Scaffold this sits in already applies
// the system-bar insets to its content padding, so adding them again put
// the whole status bar's height of empty space above the search field —
// roughly a centimetre of nothing at the top of the first screen anyone
// sees. Insets get consumed once, by whichever component owns the edge.
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = GUTTER, vertical = 8.dp),
shape = RoundedCornerShape(SEARCH_RADIUS),
color = MaterialTheme.colorScheme.surfaceVariant,
tonalElevation = 0.dp,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onMenu) {
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.nav_open))
}
PlainTextField(
value = query,
onValueChange = onQueryChange,
modifier = Modifier.weight(1f),
hint = R.string.search_hint,
singleLine = true,
// The search key is decorative here: results already land as you
// type, so pressing it should dismiss the keyboard and change
// nothing, which is what an empty handler does.
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = {}),
)
if (query.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }) {
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.search_clear))
}
} else {
Icon(
Icons.Filled.Search,
contentDescription = null,
modifier = Modifier.padding(end = 12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun NavigationDrawer(
current: Destination,
labels: List<Label>,
syncSummary: String?,
onOpen: (Destination) -> Unit,
onOpenSync: () -> Unit,
) {
ModalDrawerSheet {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(start = 28.dp, top = 24.dp, bottom = 16.dp),
)
listOf(Destination.Notes, Destination.Reminders).forEach { destination ->
DrawerRow(destination, current, onOpen)
}
if (labels.isNotEmpty()) {
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
Text(
text = stringResource(R.string.nav_labels),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, bottom = 4.dp),
)
labels.forEach { label ->
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
}
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
listOf(Destination.Archive, Destination.Trash).forEach { destination ->
DrawerRow(destination, current, onOpen)
}
// Sync sits below the divider with the destinations rather than behind
// a settings gear: it is not a preference, it is where you go to find
// out whether this phone and your desktop are actually in step. The
// subtitle answers that without opening it.
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
NavigationDrawerItem(
label = { Text(stringResource(R.string.sync_title)) },
badge = syncSummary?.let { { Text(it, style = MaterialTheme.typography.labelSmall) } },
selected = false,
onClick = onOpenSync,
modifier = Modifier.padding(horizontal = 12.dp),
)
}
}
}
@Composable
private fun DrawerRow(
destination: Destination,
current: Destination,
onOpen: (Destination) -> Unit,
) {
NavigationDrawerItem(
label = { Text(destination.title) },
selected = destination == current,
onClick = { onOpen(destination) },
modifier = Modifier.padding(horizontal = 12.dp),
)
}
/**
* The board: a two-column masonry, matching the web and desktop.
*
* Staggered rather than a uniform grid because notes are wildly different heights
* — a one-line thought beside a twelve-item checklist — and forcing them to a
* common height either clips the long ones or strands whitespace under the short
* ones. This is the Compose equivalent of the CSS multi-column `NoteGrid.vue` uses.
*/
@Composable
private fun NoteBoard(
notes: List<Note>,
onOpenNote: (Note) -> Unit,
onToggleItem: (Note, Int, Boolean) -> Unit,
onNoteAction: (Note, EditorAction) -> Unit,
onConfirmDelete: (Note) -> Unit,
) {
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(BOARD_COLUMNS),
modifier = Modifier.fillMaxSize(),
// Bottom padding clears the FAB, so the last note is never trapped under it.
contentPadding = PaddingValues(start = GUTTER, end = GUTTER, top = 4.dp, bottom = 88.dp),
verticalItemSpacing = 8.dp,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// Keyed by id so Compose reuses cards across a refresh rather than
// rebuilding them — and so a newly captured note slides in instead of
// making every card below it flicker.
items(items = notes, key = { it.id }) { note ->
NoteCard(
note = note,
onOpen = { onOpenNote(note) },
onToggleItem = { index, checked -> onToggleItem(note, index, checked) },
onAction = { onNoteAction(note, it) },
onConfirmDelete = { onConfirmDelete(note) },
)
}
}
}
/**
* The empty state, which has to say something DIFFERENT per destination.
*
* "Nothing here yet" is encouraging on an empty board and wrong in Trash, where it
* should read as reassurance, and misleading after a search, where the notes exist
* but did not match.
*/
@Composable
private fun EmptyBoard(state: BoardState) {
val (title, body) =
when {
state.searching ->
stringResource(R.string.empty_search_title) to
stringResource(R.string.empty_search_body, state.query)
state.destination == Destination.Trash ->
stringResource(R.string.empty_trash_title) to stringResource(R.string.empty_trash_body)
state.destination == Destination.Archive ->
stringResource(R.string.empty_archive_title) to stringResource(R.string.empty_archive_body)
state.destination == Destination.Reminders ->
stringResource(R.string.empty_reminders_title) to stringResource(R.string.empty_reminders_body)
else ->
stringResource(R.string.board_empty_title) to stringResource(R.string.board_empty_body)
}
// A LazyColumn holding one centred item, NOT a plain Column. Pull-to-refresh
// works through nested scroll, and a layout that never scrolls never dispatches
// any — so on a Column the gesture would be dead on exactly the screen where it
// matters most: linked, board empty, notes still on the server.
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
item {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun LoadingBoard() {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator(modifier = Modifier.size(32.dp))
}
}
/**
* Shown when the store could not be opened at all.
*
* No retry: whatever stopped SQLite opening will stop it again this launch. Saying
* so plainly beats a button that does nothing.
*/
@Composable
fun StoreUnavailableScreen(reason: String?) {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(R.string.store_unavailable_title),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.height(8.dp))
Text(
text = reason ?: stringResource(R.string.store_unavailable_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
private const val BOARD_COLUMNS = 2
// Not private: the reminder notice is board content and has to line up with the
// search bar and the cards, so it shares the board's gutter rather than guessing.
internal val GUTTER = 12.dp
private val SEARCH_RADIUS = 28.dp
@@ -0,0 +1,556 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.fabledsword.thoughtsync.core.Label
import com.fabledsword.thoughtsync.core.Note
import com.fabledsword.thoughtsync.core.NoteDraft
import com.fabledsword.thoughtsync.core.NoteEdit
import com.fabledsword.thoughtsync.core.NoteQuery
import com.fabledsword.thoughtsync.core.ThoughtSync
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Which pile of notes the board is showing. Mirrors the desktop sidebar.
*
* A sealed type rather than a string so the `when` that loads them is exhaustive —
* adding a destination becomes a compile error at the loader instead of a silently
* empty board.
*/
sealed interface Destination {
val title: String
data object Notes : Destination {
override val title = "Notes"
}
data object Reminders : Destination {
override val title = "Reminders"
}
data object Archive : Destination {
override val title = "Archive"
}
data object Trash : Destination {
override val title = "Trash"
}
data class WithLabel(
val id: String,
override val title: String,
) : Destination
}
/** Everything the board renders from, in one immutable snapshot. */
data class BoardState(
val destination: Destination = Destination.Notes,
val notes: List<Note> = emptyList(),
val labels: List<Label> = emptyList(),
val query: String = "",
val loading: Boolean = true,
val saving: Boolean = false,
val error: String? = null,
/**
* The note the editor is open on, or null for the board.
*
* The NOTE and not its id, so the editor always renders from the same object
* the store last returned. Every mutation hands back the reloaded note, so
* ticking a box or picking a colour updates this in place and the editor never
* has to re-query to see its own change.
*/
val editing: Note? = null,
/**
* Bumped each time the editor is opened on a DIFFERENT note, and deliberately
* not when the note it is already on changes.
*
* The editor keys its text field on this rather than on `editing.id`, because a
* draft's id changes the instant it is first saved — and re-keying on that would
* reset the field to whatever the store just returned, discarding anything typed
* during the write. That is a data-loss bug rather than a flicker.
*/
val editingSession: Long = 0,
) {
/** Search overrides the destination while there is a query to run. */
val searching: Boolean get() = query.isNotBlank()
}
/**
* Drives the board off the shared Rust core.
*
* Every store call is a BLOCKING FFI call — synchronous SQLite behind a mutex — so
* they run on [Dispatchers.IO]. Doing otherwise would block the main thread on
* disk, which is the jank a native client exists to avoid.
*
* ONE view model for both screens, over detekt's objection. The obvious split —
* a second one for the editor — fails on the fact that every editor mutation has
* to reload the board behind it, so the editor's view model would need a
* reference back into this one and the two would share the note list anyway. What
* is left is a dozen small functions around a single coherent state machine,
* which is what the suppression says rather than hides.
*/
@Suppress("TooManyFunctions")
class BoardViewModel(
private val core: ThoughtSync,
/**
* Called after any write that could have moved a reminder.
*
* The alarm is derived from the store, so anything that edits the store can
* invalidate it — setting a time, completing one, trashing the note it is on.
* Wired as a callback rather than reaching for a Context from a view model,
* which is how view models come to leak Activities. Same shape as the sync
* view model's `onStoreChanged`.
*/
private val onRemindersChanged: () -> Unit = {},
) : ViewModel() {
var state by mutableStateOf(BoardState())
private set
/**
* The in-flight search. Held so each keystroke cancels the previous one:
* without it, a fast typist queues one full-text query per character and the
* results arrive out of order, so the board can settle on a stale answer.
*/
private var searchJob: Job? = null
init {
refresh()
loadLabels()
}
fun open(destination: Destination) {
// Clearing the query is deliberate: picking Archive while a search is
// running should show the archive, not search results filtered by a box
// the user has visually moved on from.
searchJob?.cancel()
state = state.copy(destination = destination, query = "")
refresh()
}
fun refresh() {
viewModelScope.launch {
state = state.copy(loading = true)
state =
try {
val notes = withContext(Dispatchers.IO) { load(state.destination) }
state.copy(notes = notes, loading = false, error = null)
} catch (e: Exception) {
// Broad by intent: the board must render something for any
// failure, and the core reports problems as one error type
// carrying a message meant to be shown.
state.copy(loading = false, error = e.message ?: FALLBACK_ERROR)
}
}
}
private fun load(destination: Destination): List<Note> =
when (destination) {
Destination.Notes -> core.listNotes(query(VIEW_NOTES))
Destination.Archive -> core.listNotes(query(VIEW_ARCHIVE))
Destination.Trash -> core.listNotes(query(VIEW_TRASH))
// Not a board view: the core models reminders as its own query, since
// "has a reminder" cuts across archived and active alike.
Destination.Reminders -> core.reminderNotes()
is Destination.WithLabel -> core.listNotes(query(VIEW_NOTES, labelId = destination.id))
}
private fun loadLabels() {
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
.onSuccess { state = state.copy(labels = it) }
// A drawer that cannot list labels is a degraded drawer, not a
// broken board — the notes are still there. Failing quietly here
// beats an error banner over working content.
.onFailure { state = state.copy(labels = emptyList()) }
}
}
fun search(text: String) {
state = state.copy(query = text)
searchJob?.cancel()
if (text.isBlank()) {
refresh()
return
}
searchJob =
viewModelScope.launch {
// Let the typing settle before hitting the store. Short enough to
// feel live, long enough that a whole word is one query.
delay(SEARCH_DEBOUNCE_MS)
state = state.copy(loading = true)
state =
try {
val hits = withContext(Dispatchers.IO) { core.searchNotes(text) }
state.copy(notes = hits, loading = false, error = null)
} catch (e: Exception) {
state.copy(loading = false, error = e.message ?: FALLBACK_ERROR)
}
}
}
// ─────────────────────────────── the editor ──────────────────────────────
/**
* Open a note by id, for a notification tap.
*
* Loads it fresh rather than searching the board's list: the board may be
* showing Trash, a label, or search results, and a reminder can fire for a note
* that is in none of them.
*/
fun openNoteById(id: String) {
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { core.getNote(id) } }
.onSuccess { state = state.copy(editing = it, editingSession = state.editingSession + 1) }
}
}
fun openNote(note: Note) {
state = state.copy(editing = note, editingSession = state.editingSession + 1)
}
/**
* Open the editor on a note that does not exist yet.
*
* The + button used to raise a separate capture sheet, which meant a note being
* WRITTEN could not be given a colour, a reminder or a checklist — those live on
* the editor's toolbar, and the sheet had none. Writing and editing are now the
* same surface.
*
* The 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
* in. It becomes a row on its first save; see [onDraftAction].
*/
fun compose() {
draftDismissed = false
state = state.copy(editing = blankDraft(), editingSession = state.editingSession + 1)
}
/**
* Set when a draft's editor closes, so a create still in flight does not reopen
* it. The editor flushes its text and then closes, and the flush is a coroutine —
* without this the note would be created, the screen would close, and the create
* would finish and put the screen back.
*/
private var draftDismissed = false
/**
* The editor's actions, for a note that has no row yet.
*
* Everything a toolbar button does needs an id to act on, so the first action
* that needs one creates the note and replays itself against the real thing.
*/
private fun onDraftAction(
draft: Note,
action: EditorAction,
) {
when (action) {
// Nothing exists, so leaving leaves nothing behind — which is what makes
// tapping + and changing your mind free. Text typed before this point has
// already gone to createFromDraft via the editor's autosave or its flush.
EditorAction.Close, EditorAction.Trash -> {
draftDismissed = true
state = state.copy(editing = null)
}
EditorAction.DismissError -> dismissError()
is EditorAction.SaveText -> createFromDraft(action.body)
// Colour, reminder, pin, labels: attributes OF a note, so there has to be
// a note. With autosave at a second, "typed something" is true by the time
// anyone reaches the toolbar; before that there is nothing to attribute.
else -> createFromDraft(draft.body) { created -> onEditorAction(created, action) }
}
}
/**
* Turn a draft into a row, and keep the editor on it.
*
* Adopting the created note is what lets a session of autosaves stay one note:
* the second save sees a real id and updates rather than creating again.
*/
private fun createFromDraft(
content: String,
allowEmpty: Boolean = false,
then: (Note) -> Unit = {},
) {
val cleanContent = content.trim()
// A blank draft is not a note. Ignored rather than rejected: tapping + and
// walking away is a slip, not a mistake worth interrupting someone over.
if (cleanContent.isEmpty() && !allowEmpty) return
viewModelScope.launch {
state = state.copy(saving = true)
state =
try {
val created = withContext(Dispatchers.IO) { core.createNote(draft(cleanContent)) }
// Prepend rather than reload: the new note belongs at the top of
// the board, and a full re-query would cost a round trip to tell
// us what we already know. Skipped when the board is not showing
// plain notes — a note created while looking at Trash does not
// belong in that list.
val notes =
if (state.destination == Destination.Notes && !state.searching) {
listOf(created) + state.notes
} else {
state.notes
}
withContext(Dispatchers.IO) { onRemindersChanged() }
// editingSession is NOT bumped: this is the same sitting, and the
// editor's field must not be re-keyed underneath the typing.
state.copy(
notes = notes,
editing = if (draftDismissed) state.editing else created,
saving = false,
error = null,
)
} catch (e: Exception) {
state.copy(saving = false, error = e.message ?: FALLBACK_ERROR)
}
if (!draftDismissed) state.editing?.let(then)
}
}
/**
* Apply one editor action to the note the editor is open on.
*
* The `when` is exhaustive by construction, so adding a variant to
* [EditorAction] breaks THIS function until it is handled — which is the whole
* reason the editor speaks in actions rather than through a bundle of
* callbacks. The note is passed in rather than read from `state.editing` so a
* mutation that lands between a tap and its dispatch cannot redirect the
* action at a different note.
*
* Both suppressions have ONE cause: [EditorAction] has twenty variants, so a
* total function over it is twenty branches and sixty-odd lines no matter how
* it is written. Splitting it into sub-dispatchers is the only way to shorten
* it, and each of those would need an `else` — which throws away precisely the
* exhaustiveness this shape exists for. Suppressed rather than worked around,
* because the rules are measuring the action type's size, not this function's.
*/
@Suppress("CyclomaticComplexMethod", "LongMethod")
fun onEditorAction(
note: Note,
action: EditorAction,
) {
if (note.id == DRAFT_ID) {
onDraftAction(note, action)
return
}
val id = note.id
when (action) {
EditorAction.Close -> state = state.copy(editing = null)
EditorAction.DismissError -> dismissError()
// Sent on an idle debounce while typing, and again on close. Writing
// this often is affordable because a body write no longer snapshots a
// revision — the core keeps one per editing session, not one per save.
is EditorAction.SaveText ->
mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) }
// Pinning re-sorts the board rather than emptying it, and on a phone
// you often pin while still reading — so unlike the three below, it
// deliberately leaves the editor open.
is EditorAction.SetPinned -> edit(id, NoteEdit.Pinned(action.pinned))
// Archiving, trashing and restoring all take the note out of the list
// you were looking at, so the editor closes behind them: staying open
// on a note that has visibly left the board reads as a bug.
is EditorAction.SetArchived ->
mutate(closeEditor = true) {
it.updateNote(id, listOf(NoteEdit.Archived(action.archived)))
}
EditorAction.Trash -> mutate(closeEditor = true) { it.trashNote(id) }
EditorAction.Restore -> mutate(closeEditor = true) { it.restoreNote(id) }
EditorAction.DeleteForever ->
mutate(closeEditor = true) {
it.deleteNoteForever(id)
// Nothing to hand back — the row is gone. The board reload
// inside `mutate` is what makes it disappear.
null
}
is EditorAction.SetLabels -> mutate { it.setNoteLabels(id, action.labelIds) }
is EditorAction.CreateLabel ->
action.name.trim().takeIf { it.isNotEmpty() }?.let { name ->
mutate {
val label = it.createLabel(name)
val manual = note.labels.filterNot { l -> l.viaTag }.map { l -> l.id }
it.setNoteLabels(id, (manual + label.id).distinct())
}
// The drawer lists labels with their note counts, and both
// just changed.
loadLabels()
}
is EditorAction.SetReminder -> edit(id, NoteEdit.RemindAt(action.at))
EditorAction.ClearReminder -> edit(id, NoteEdit.ClearRemindAt)
EditorAction.CompleteReminder -> mutate { it.completeReminder(id) }
is EditorAction.SnoozeReminder -> mutate { it.snoozeReminder(id, action.minutes) }
is EditorAction.SetRecurrence ->
edit(
id,
action.rule?.let { NoteEdit.Recurrence(it) } ?: NoteEdit.ClearRecurrence,
)
}
}
/** The common case: one field-level edit to one note. */
private fun edit(
id: String,
change: NoteEdit,
) = mutate { it.updateNote(id, listOf(change)) }
/**
* The one path every store mutation takes.
*
* Each core mutation returns the reloaded note, which refreshes
* [BoardState.editing] so an OPEN editor shows its own change without a
* re-query — and does nothing at all when the editor is closed, because that
* field doubles as "which screen is up". The BOARD list is then reloaded rather than patched in place:
* pinning re-sorts it, archiving removes the note from it, and adding a label
* can move it in or out of a label view — a splice would have to reimplement
* the core's ordering and membership rules in Kotlin to get any of that right.
* The reload is a local SQLite query, so it costs less than the code that would
* avoid it.
*
* Quiet, deliberately: no spinner, because the board is already on screen with
* correct-until-a-moment-ago content, and flashing it empty would be a worse
* lie than showing it one frame stale.
*
* Search results are left alone — they are the answer to a query, not a live
* view, and re-running the board query underneath them would replace the hits
* with the whole board.
*/
private fun mutate(
closeEditor: Boolean = false,
block: (ThoughtSync) -> Note?,
) {
viewModelScope.launch {
state = state.copy(saving = true)
state =
try {
val updated = withContext(Dispatchers.IO) { block(core) }
val notes =
if (state.searching) {
state.notes
} else {
withContext(Dispatchers.IO) { load(state.destination) }
}
// On IO, not here: re-deriving the alarm reads every note
// that carries a reminder, and this line runs on the main
// thread — the coroutine is back from its withContext by now.
withContext(Dispatchers.IO) { onRemindersChanged() }
state.copy(
notes = notes,
// Only REFRESHES an open editor; it must never open one.
// `editing != null` IS "the editor is on screen", so writing
// the reloaded note in unconditionally meant any mutation
// started from the BOARD threw the editor open on top of it —
// which is exactly what ticking a checkbox on a card did.
editing = if (closeEditor) null else state.editing?.let { updated ?: it },
saving = false,
error = null,
)
} catch (e: Exception) {
// Broad by intent, as elsewhere: the core reports every failure
// as one error type carrying a message meant to be shown, and a
// half-applied edit must still leave a usable screen.
state.copy(saving = false, error = e.message ?: FALLBACK_ERROR)
}
}
}
/**
* Tick or untick one item from the BOARD, without opening the note.
*
* The common gesture on a checklist, and the reason it goes through the store
* rather than the pure text helpers the editor uses: nothing here is holding a
* half-typed body, so the reloaded note is simply the truth.
*
* `index` is the item's ordinal, which is what its id is now (M304).
*/
fun toggleItem(
note: Note,
index: Int,
checked: Boolean,
) = mutate { it.setItemChecked(note.id, index.toString(), checked) }
fun dismissError() {
state = state.copy(error = null)
}
companion object {
private const val FALLBACK_ERROR = "Something went wrong."
private const val SEARCH_DEBOUNCE_MS = 180L
// The core's board vocabulary. "archived", not "archive" — it matches on
// the former and silently falls through to the default board otherwise.
private const val VIEW_NOTES = "notes"
private const val VIEW_ARCHIVE = "archived"
private const val VIEW_TRASH = "trash"
fun factory(
core: ThoughtSync,
onRemindersChanged: () -> Unit,
): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
BoardViewModel(core, onRemindersChanged) as T
}
}
}
// ── pure builders ───────────────────────────────────────────────────────────
//
// Neither of these reads or writes view-model state; they only shape a core input
// from arguments. Kept at file scope so the class above holds only things that
// actually depend on it — which is also what keeps its function count meaningful.
private fun query(
view: String,
labelId: String? = null,
) = NoteQuery(view = view, labelId = labelId, sort = null, facets = null)
private fun draft(content: String): NoteDraft =
// The core names the note from the body's first line, so a captured thought is
// findable without anyone being asked to name it. A checklist is added afterwards,
// in the editor — it is something a note HAS, not a different thing to capture.
NoteDraft(body = content, items = null)
/**
* The id a note has before it has been saved.
*
* A real id is a uuid, so the empty string cannot collide with one. Using a sentinel
* rather than making the editor's note nullable keeps "not saved yet" out of a screen
* that reads eight fields off the note and should not have to null-check any of them.
*/
internal const val DRAFT_ID = ""
private fun blankDraft(): Note =
Note(
id = DRAFT_ID,
displayTitle = "",
body = "",
position = 0,
pinned = false,
archived = false,
trashed = false,
deletedAt = null,
remindAt = null,
recurrence = null,
labels = emptyList(),
items = emptyList(),
attachments = emptyList(),
previews = emptyList(),
createdAt = null,
updatedAt = null,
)
@@ -0,0 +1,102 @@
package com.fabledsword.thoughtsync.ui
// The colour a LABEL wears when nobody picked one for it.
//
// Every `#tag` is born colourless, so without this a board of tags is a board of
// identical grey chips. Hashing the tag's NAME is deterministic, identical on every
// surface, costs no column and no migration, and a tag keeps its colour for life.
//
// THIS WAS THE CARD'S COLOUR TOO, ONCE. It is not any more (M315): a note's fill is
// one neutral and only its tags carry hue. The hash survived that removal because the
// job it still does — give a name a stable colour — was never the job that failed.
// What failed was asking a colour that means "which tag" to also mean nothing at all
// on an untagged note, at which point the board had two vocabularies and neither read.
//
// 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
// the phone and another in the browser. Same discipline as the checklist grammar's
// three implementations, and the same reason: a value that disagrees across surfaces
// is a bug you cannot unsee and cannot explain.
//
// NO COMPOSE IN THIS FILE, deliberately. It is the half of the pair that CAN be
// pinned by a host-JVM test, and staying free of `androidx.compose` is what keeps
// `DerivedTintTest` runnable in the Unit tests step rather than on an emulator. The
// web side has no test runner at all, so this test is the only mechanical guard the
// mirror gets — see the fixture comment in colors.ts.
/**
* The colours a derived hue can land on: `NOTE_TINTS`' keys minus `default`, which is
* the ABSENCE of a colour — a tag that derived it would be indistinguishable from one
* nobody has tagged. `gray` stays: as a chip it reads as a deliberate choice.
*
* Order is load-bearing and matches `DERIVED_TINT_KEYS` in colors.ts. Reordering this
* list silently recolours every tag on one surface only.
*/
val DERIVED_TINT_KEYS: List<String> =
listOf("red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray")
private const val FNV_OFFSET_BASIS = -0x7ee3623b // 0x811c9dc5 as a signed Int
private const val FNV_PRIME = 0x01000193
private const val BYTE_MASK = 0xFF
private const val UNSIGNED_MASK = 0xFFFFFFFFL
/**
* FNV-1a over the id's bytes, 32-bit.
*
* Chosen because both languages compute it identically in ten lines with no library.
* Explicitly NOT `String.hashCode()`: Kotlin's is specified but JS has no equivalent,
* and reimplementing Java's from memory in TypeScript is exactly how a mirror drifts.
*
* `and BYTE_MASK` is a no-op for the ASCII of a UUID, and is kept because it states
* the intent — this hashes BYTES, so the TypeScript side reading `charCodeAt(i) &
* 0xff` is the same function rather than a coincidence.
*
* Overflow is the point: Kotlin's `Int` wraps on multiply, which is what the web's
* `Math.imul` exists to reproduce.
*/
fun tintHash(id: String): Int {
var hash = FNV_OFFSET_BASIS
for (ch in id) {
hash = hash xor (ch.code and BYTE_MASK)
hash *= FNV_PRIME
}
return hash
}
/** The colour a name maps to, stable for as long as the name is. Called with a
* label's lowercased name; `id` is the parameter's history, not its meaning. */
fun derivedTint(id: String): String {
// Through Long to read the hash as unsigned. A signed remainder would be negative
// for half of all ids and index out of the list.
val index = (tintHash(id).toLong() and UNSIGNED_MASK) % DERIVED_TINT_KEYS.size
return DERIVED_TINT_KEYS[index.toInt()]
}
/**
* The colour key for a LABEL — its chip, and its `#tag` where it sits in the prose.
*
* Derived from the tag's NAME when nobody has picked one. Every `#tag` ever typed is
* `default`: the server mints one as `Label(owner_id=…, name=name)` with no colour,
* so without deriving, a board of tags would be a board of identical grey chips.
*
* DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing #2965's plan. That plan wanted
* a hashed colour written at each of the four places a label can be born — and named
* the risk itself: `find_or_create_label` is "easy to miss, and it is the common one",
* since most tags are born from typing `#grocery`, not from a management screen.
* Deriving has no mint points to miss and no backfill for the tags already out there.
* The cost is that renaming a tag recolours it, which is fair: the name IS the tag.
*
* Lowercased because tags dedupe case-insensitively — `#Todo` renamed to `#todo` is
* the same tag and should not change colour. Kotlin's `lowercase()` and the web's
* `toLowerCase()` are both locale-independent, so the mirror holds.
*/
fun resolvedLabelColor(
name: String,
color: String,
known: Set<String>,
): String =
when {
color.isNotEmpty() && color != "default" && color in known -> color
name.isEmpty() -> "default"
else -> derivedTint(name.lowercase())
}
@@ -0,0 +1,96 @@
package com.fabledsword.thoughtsync.ui
/**
* Everything the editor can ask for, as one type.
*
* The alternative was a bundle of twenty callbacks, and it was a bad one: twenty
* same-shaped `(String, String) -> Unit` parameters is a place for two of them to
* get swapped, with nothing to catch it. One `(EditorAction) -> Unit` costs a
* `when` at the far end and gets EXHAUSTIVENESS in exchange — adding a variant
* here breaks the dispatcher until it is handled, which is precisely the guarantee
* the callback bundle could not offer.
*
* 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
* create the possibility of the two disagreeing.
*/
sealed interface EditorAction {
/** Leave the editor. Text is saved separately, via [SaveText], before this. */
data object Close : EditorAction
/** Clear the error banner. Shared state — the board shows the same one. */
data object DismissError : EditorAction
data class SaveText(
val body: String,
) : EditorAction
data class SetPinned(
val pinned: Boolean,
) : EditorAction
data class SetArchived(
val archived: Boolean,
) : EditorAction
data object Trash : EditorAction
data object Restore : EditorAction
data object DeleteForever : EditorAction
// No checklist actions at all any more (M304). An item is a `- [ ] ` line of the
// body, so adding, renaming, ticking or deleting one is editing text — which the
// editor already does, through SaveText, with the same autosave and the same
// revision window as any other edit. Routing them through the store would have
// meant the store handing back a note whose body disagreed with the field the
// person was typing in.
/**
* The note's MANUAL labels, replacing whatever was there.
*
* `#tag` labels must never appear in this list. They are owned by the body
* text and the core re-derives them on every body edit — see
* `set_note_labels` in the FFI crate.
*/
data class SetLabels(
val labelIds: List<String>,
) : EditorAction
/**
* Create a label and attach it to this note in one gesture.
*
* Typing a new label in the picker and then having to tick it as well would
* be two steps for one intention. The core finds-or-creates, so typing the
* name of a label that already exists simply attaches that one.
*/
data class CreateLabel(
val name: String,
) : EditorAction
/** `at` is an RFC3339 instant — see `Time.kt` for why the UI writes it. */
data class SetReminder(
val at: String,
) : EditorAction
data object ClearReminder : EditorAction
/**
* Mark the reminder dealt with.
*
* Distinct from [ClearReminder] even though the core does the same thing to
* the column today: this is where recurrence advancement lands when it is
* built, so a recurring reminder finished through the generic clear would
* silently stop recurring.
*/
data object CompleteReminder : EditorAction
data class SnoozeReminder(
val minutes: Long,
) : EditorAction
/** null is "does not repeat". */
data class SetRecurrence(
val rule: String?,
) : EditorAction
}
@@ -0,0 +1,189 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.runtime.saveable.Saver
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.fabledsword.thoughtsync.core.checklistItems
import com.fabledsword.thoughtsync.core.checklistRender
/**
* 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
* input shape, and nothing below the editor can tell it exists. [joinBlocks] puts the
* string back together on every edit.
*
* A run of prose lines is ONE block rather than one per line. Typing a paragraph has
* to feel like typing a paragraph, and a separate field under every sentence would
* break the caret in the middle of writing. Only a checklist item earns a block of its
* own, because only a checklist item needs a widget.
*
* The block owns its [TextFieldValue], not just its text, so a caret survives an edit
* to some other block. And [id] is stable across edits: Compose keys fields by
* position unless told otherwise, so inserting an item above one would otherwise move
* everyone's caret up a row. Content cannot serve as that key — two empty items are
* identical and neither is the other.
*/
data class EditorBlock(
val id: Long,
val value: TextFieldValue,
/** null for prose; ticked-or-not for a checklist item. */
val checked: Boolean?,
) {
val isTask: Boolean get() = checked != null
}
/**
* Split a body into blocks, numbering them from [firstId].
*
* Which lines are items comes from the core, not from a pattern here — the grammar is
* written three times already and Kotlin is not going to be the fourth.
*/
fun splitBlocks(
body: String,
firstId: Long = 0,
): List<EditorBlock> {
val itemAt = checklistItems(body).associateBy { it.line.toInt() }
val out = mutableListOf<EditorBlock>()
val prose = mutableListOf<String>()
var id = firstId
fun flushProse() {
if (prose.isNotEmpty()) {
out += EditorBlock(id++, TextFieldValue(prose.joinToString("\n")), null)
prose.clear()
}
}
body.split("\n").forEachIndexed { n, line ->
val item = itemAt[n]
if (item == null) {
prose += line
} else {
flushProse()
out += EditorBlock(id++, TextFieldValue(item.text), item.checked)
}
}
flushProse()
// Never empty: an empty note still needs one field to type into.
return out.ifEmpty { listOf(EditorBlock(id, TextFieldValue(""), null)) }
}
/**
* The body those blocks stand for — byte-identical to what [splitBlocks] was given,
* for a body already in canonical form. A non-canonical one (`- [X]`, an odd bullet)
* comes back canonical, which is the same rule every other rewriter in `derive`
* follows.
*/
fun joinBlocks(blocks: List<EditorBlock>): String =
blocks.joinToString("\n") { block ->
val checked = block.checked
if (checked == null) block.value.text else checklistRender(block.value.text, checked)
}
/**
* Rotation carries the TEXT and re-derives the shape.
*
* Blocks are not parcelable and their ids are meaningless across a process death, so
* the body string is the honest thing to save — it is the real state, and everything
* else about a block is derived from it.
*/
val blocksSaver: Saver<List<EditorBlock>, String> =
Saver(save = { joinBlocks(it) }, restore = { splitBlocks(it) })
/**
* What the return key does on a checklist item.
*
* `internal` rather than private because BlockBody.kt calls it. These three helpers
* are the block MODEL and the composables are the block UI — one file was doing both,
* which detekt noticed by counting functions before anybody noticed by reading.
*
* On one with words in it, a new empty item below. On an EMPTY one, the item becomes
* prose — which is how a list ENDS, and the same rule the plain text field used
* before this: without it a list is impossible to get out of.
*
* 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.
*/
internal fun afterEnter(
blocks: List<EditorBlock>,
index: Int,
newId: Long,
): List<EditorBlock> {
val block = blocks[index]
val out = blocks.toMutableList()
if (block.value.text.isBlank()) {
out[index] = block.copy(value = TextFieldValue(""), checked = null)
} else {
out.add(index + 1, EditorBlock(newId, TextFieldValue(""), false))
}
return out
}
/** Drop a block, leaving at least one field to type into. */
internal fun List<EditorBlock>.withoutIndex(index: Int): List<EditorBlock> {
val out = toMutableList().also { it.removeAt(index) }
return out.ifEmpty { listOf(EditorBlock(nextId(), TextFieldValue(""), null)) }
}
/** An id nothing else is using. Monotonic within a session, which is all it has to be. */
internal fun List<EditorBlock>.nextId(): Long = (maxOfOrNull { it.id } ?: -1L) + 1L
/**
* One more empty checklist item at the end, and the id to put the caret in.
*
* What the toolbar's checklist button does. It appends rather than inserting at the
* caret because a block editor has no single caret to insert at — the field that had
* focus may not even be the one being looked at by the time this runs.
*/
fun List<EditorBlock>.plusTask(): Pair<List<EditorBlock>, Long> {
val id = nextId()
return (this + EditorBlock(id, TextFieldValue(""), false)) to id
}
/**
* Re-read ONE prose block for `- [ ] ` lines somebody typed by hand.
*
* [splitBlocks] runs once, when the editor opens. After that the blocks are the state
* and nothing reads the body again — every edit travels the other way, through
* [joinBlocks]. So a marker typed by hand stayed literal text on screen until the note
* was closed and reopened, even though it was already a real item in storage and the
* card was already drawing a checkbox for it. The editor was the only place that
* disagreed.
*
* **On blur, and only the block being left.** There is no good moment to convert while
* someone is typing: re-splitting on a keystroke moves the caret out of the word being
* written, and converting the instant `- [ ]` is complete does it before the item has
* any text. Blur is the one moment the person has demonstrably finished with the block,
* so a re-split costs no caret and cannot catch a half-typed line.
*
* Returns THIS LIST, not an equal copy, when there was nothing to promote — the caller
* leans on that to leave the state alone, and a blur that changed nothing must not
* re-key every field below it.
*
* Non-canonical markers (`- [X]`, an odd bullet) come back canonical, exactly as they
* would have on reopen. That is the only case where this changes the body rather than
* only the way it is drawn.
*/
internal fun List<EditorBlock>.promotingTasks(index: Int): List<EditorBlock> {
val block = getOrNull(index)
if (block == null || block.isTask) return this
val split = splitBlocks(block.value.text, nextId())
// A single prose block back means there was nothing to promote. `splitBlocks` never
// returns an empty list, so `first()` is safe.
val changed = split.size > 1 || split.first().isTask
return if (changed) take(index) + split + drop(index + 1) else this
}
/**
* Put the caret at the end of the last block, for an editor that has just opened.
*
* Opening an existing note means continuing it, and a caret at offset zero would put
* the cursor before the first character of the wrong field.
*/
fun List<EditorBlock>.focusedAtEnd(): List<EditorBlock> {
if (isEmpty()) return this
val last = last()
return dropLast(1) + last.copy(value = last.value.copy(selection = TextRange(last.value.text.length)))
}
@@ -0,0 +1,398 @@
package com.fabledsword.thoughtsync.ui
import android.text.format.DateUtils
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Note
/**
* The editor's action bar, along the top of the surface.
*
* It sits exactly where the capture sheet's drag handle used to. The handle cost
* this strip of screen and did nothing that a back gesture does not already do, so
* the strip carries the actions instead.
*
* Top rather than bottom, now that this one surface is used for WRITING as well as
* editing: the keyboard owns the bottom of the display for most of a note's life,
* so a bar down there spends its time riding on the IME. That is the right place
* for a send button and the wrong one for a colour picker, which is reached for
* between thoughts rather than at the end of them. The cost is honest — the top of
* a phone is further from a thumb than the bottom — and it buys a bar that does not
* move while you type.
*
* The three affordances with a permanent slot are the ones reached for while still
* writing — colour, reminder, note-or-list. Everything structural (pin, labels,
* archive, delete) is one tap further into the overflow, where it is spelled out
* in WORDS.
*
* That split is a deliberate trade against icon-guessing. `material-icons-core`
* carries no pin, archive or label glyph, and the two ways out were pulling in the
* ~1,000-vector extended set for four icons, or pressing unrelated ones into
* service — a star meaning "pin" is a star meaning "favourite" to everyone who has
* used another app. Text says exactly what it does and reads correctly aloud.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditorTopBar(
note: Note,
readOnly: Boolean,
onClose: () -> Unit,
onStartChecklist: () -> Unit,
onPicker: (Picker) -> Unit,
onConfirmDelete: () -> Unit,
onAction: (EditorAction) -> Unit,
) {
val dark = isSystemInDarkTheme()
TopAppBar(
title = {},
navigationIcon = {
// The only way out, and the only thing that needed a "save" button
// before writes became continuous. Leaving IS saving now, which is what
// the line in the bottom corner is there to say out loud.
IconButton(onClick = onClose) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.editor_back),
)
}
},
actions = {
if (!readOnly) {
IconButton(onClick = { onPicker(Picker.REMINDER) }) {
Icon(
Icons.Filled.Notifications,
contentDescription = stringResource(R.string.editor_reminder),
)
}
// Inserts `- [ ] ` at the caret. Always available, and never hidden:
// a checklist is text now (M304), so there is no section to be
// already-showing and no reason a second list cannot start further
// down the same note.
IconButton(onClick = onStartChecklist) {
Icon(
Icons.AutoMirrored.Filled.List,
contentDescription = stringResource(R.string.editor_add_checklist),
)
}
}
OverflowMenu(
note = note,
readOnly = readOnly,
onPicker = onPicker,
onConfirmDelete = onConfirmDelete,
onAction = onAction,
)
},
// EXPLICIT, and not optional — the same lesson the old bottom bar learned.
// Material derives a bar's content colour from its container via
// contentColorFor(), which maps a colour-SCHEME ROLE to its `on-` pair and
// returns Unspecified for anything else. The card surface is a plain constant
// and not a role, so the icons drew with no colour filter: black vectors on a
// near-black bar, a toolbar that rendered the whole time and was invisible in
// dark mode. STILL TRUE with one neutral surface — it is the same kind of
// value, so this stays exactly as it is.
//
// 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
// not have the contrast to spare.
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = noteCardSurface(dark),
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
),
)
}
/**
* The footer: when the note was last written, and the way out.
*
* **Where the note stands.** There is no save button, and there should not be — a
* note is saved continuously, so a button offering to do what already happened is a
* lie with a tap attached. But that left nothing on screen saying the work is safe,
* and "closing this keeps it" is not a thing anyone should have to be told twice. So
* the state says it, as a fact rather than an instruction: Not saved yet → Saving… →
* Edited just now is the whole lifecycle, and someone who watches it once never has
* to wonder again.
*
* **The way out.** Down here because of where hands are. Moving the toolbar to the
* top took the back arrow with it, which left the only exit from a full-screen
* editor in the top-left corner — the furthest point on the display from a
* right-handed thumb, and reached over the whole note to get to. The operator hit
* that on the first device pass and was right to. So the exit lives in the bottom
* corner, which with the keyboard up sits directly above it.
*
* The top-left arrow stays as well. Two affordances for one action is usually
* clutter, but this is the case that earns it: the arrow is what habit, the system
* back gesture and TalkBack all expect of a full-screen surface, and removing it
* would strand the reflex to strike a duplicate that costs one icon slot.
*
* A checkmark, at the operator's ask. I had shipped the word "Done" here on the
* argument that a tick in a NOTES app reads as a checklist item; overruled, and the
* filled treatment is what settles it — a tonal button in the note's own colour is
* plainly a control, where a bare glyph beside a checklist would not be. It carries
* "Done" as its content description, so the reasoning survives where it actually
* mattered: read aloud.
*
* [DateUtils] rather than a hand-rolled formatter: it is localised, it already
* knows the difference between minutes, hours and yesterday, and getting plurals
* right in every language is not this app's problem to solve twice.
*/
@Composable
fun EditorFooter(
updatedAt: String?,
saving: Boolean,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier =
modifier
.fillMaxWidth()
// Rides above the keyboard, like the bar that used to be here. The
// content Column deliberately does not also inset for the IME:
// Scaffold measures this row at its lifted height and passes the
// inset down.
.imePadding()
.navigationBarsPadding()
.padding(horizontal = 12.dp, vertical = 4.dp),
// The gap is what keeps the timestamp from reading as the button's label.
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = savedLabel(updatedAt, saving),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
FilledTonalIconButton(
onClick = onClose,
// The BRAND, matching the board's compose FAB — the app's one existing
// statement of "this is the affirmative action here", now reused rather
// than a second one invented.
//
// This wore the note's own tint until M315, on the argument that it would
// otherwise be the one element on a tinted card ignoring the tint. There is
// no tint to ignore any more, and the alternative — Material's default
// secondaryContainer — is a baseline M3 colour this theme never sets, so
// taking the default would put an off-brand lilac in the corner of the
// editor.
colors =
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
),
) {
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.editor_done))
}
}
}
/** The three things the footer can be saying, in the order it says them. */
@Composable
private fun savedLabel(
updatedAt: String?,
saving: Boolean,
): String {
// No timestamp means no row yet — a draft opened by + and not typed into.
val at = updatedAt?.let { epochMillis(it) }
val now = System.currentTimeMillis()
return when {
saving -> stringResource(R.string.editor_saving)
at == null -> stringResource(R.string.editor_unsaved)
// DateUtils rounds anything under its minimum resolution to "0 minutes
// ago" — which is both odd-looking and precisely the moment this line is
// on screen for, since it is the moment right after a save lands.
now - at < DateUtils.MINUTE_IN_MILLIS ->
stringResource(R.string.editor_edited, stringResource(R.string.editor_just_now))
else ->
stringResource(
R.string.editor_edited,
DateUtils.getRelativeTimeSpanString(at, now, DateUtils.MINUTE_IN_MILLIS).toString(),
)
}
}
@Composable
private fun OverflowMenu(
note: Note,
readOnly: Boolean,
onPicker: (Picker) -> Unit,
onConfirmDelete: () -> Unit,
onAction: (EditorAction) -> Unit,
) {
var open by remember { mutableStateOf(false) }
val close = { open = false }
Box {
IconButton(onClick = { open = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.editor_more))
}
DropdownMenu(expanded = open, onDismissRequest = close) {
if (readOnly) {
MenuItem(R.string.editor_restore, close) { onAction(EditorAction.Restore) }
MenuItem(R.string.editor_delete_forever, close, onConfirmDelete)
} else {
MenuItem(
if (note.pinned) R.string.editor_unpin else R.string.editor_pin,
close,
) { onAction(EditorAction.SetPinned(!note.pinned)) }
MenuItem(R.string.editor_labels, close) { onPicker(Picker.LABELS) }
MenuItem(
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
close,
) { onAction(EditorAction.SetArchived(!note.archived)) }
MenuItem(R.string.editor_trash, close) { onAction(EditorAction.Trash) }
}
}
}
}
/**
* The note's labels, each removable.
*
* `#tag` labels get no remove button: they are owned by the body text and the core
* re-derives them on the next edit, so a cross that undid itself a second later
* would look broken. The way to remove one is to delete the tag from the text,
* which is what the trailing note says.
*/
@Composable
fun EditorLabelRow(
note: Note,
readOnly: Boolean,
onAction: (EditorAction) -> Unit,
) {
val dark = isSystemInDarkTheme()
Column(modifier = Modifier.padding(top = 12.dp)) {
note.labels.forEach { label ->
val tint = labelTintFor(label.name, label.color)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 2.dp),
) {
Text(
// `#` on every chip, matching the card. This row still shows the
// tags the BODY owns as well — it is the control surface, and the
// "from tag" hint beside one is what says why it has no cross.
text = "#${label.name}",
style = MaterialTheme.typography.labelLarge,
color = tint.tagInk(dark),
modifier =
Modifier
.clip(CircleShape)
.background(tint.chipBackground(dark))
.border(1.dp, tint.chipBorder(dark), CircleShape)
.padding(horizontal = 10.dp, vertical = 4.dp),
)
if (label.viaTag) {
Text(
text = stringResource(R.string.label_from_tag),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
} else if (!readOnly) {
IconButton(onClick = {
// Only the MANUAL labels are sent: the core replaces
// exactly those, and including a tag label here would ask
// it to own something the body text already owns.
val kept =
note.labels
.filterNot { it.viaTag || it.id == label.id }
.map { it.id }
onAction(EditorAction.SetLabels(kept))
}) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.editor_remove_label),
)
}
}
}
}
}
}
/**
* The set reminder, with the one-tap actions beside it.
*
* Done / 1h / 1d are the same three the web editor offers, for the same reason:
* when a reminder surfaces, the answer is almost always "handled" or "not yet",
* and making either of those cost a trip through the date picker is how a reminder
* ends up ignored instead of dealt with.
*/
@Composable
fun EditorReminderRow(
at: String,
recurrence: String?,
readOnly: Boolean,
onAction: (EditorAction) -> Unit,
) {
Column(modifier = Modifier.padding(top = 12.dp)) {
Text(
text = reminderLabel(at, recurrence),
style = MaterialTheme.typography.labelLarge,
color =
if (isPast(at)) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
if (!readOnly) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
TextButton(onClick = { onAction(EditorAction.CompleteReminder) }) {
Text(stringResource(R.string.reminder_done))
}
TextButton(onClick = { onAction(EditorAction.SnoozeReminder(SNOOZE_HOUR)) }) {
Text(stringResource(R.string.reminder_snooze_hour))
}
TextButton(onClick = { onAction(EditorAction.SnoozeReminder(SNOOZE_DAY)) }) {
Text(stringResource(R.string.reminder_snooze_day))
}
}
}
}
}
private const val SNOOZE_HOUR = 60L
private const val SNOOZE_DAY = 1440L
@@ -0,0 +1,393 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Label
import com.fabledsword.thoughtsync.core.Note
import java.time.DayOfWeek
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.temporal.TemporalAdjusters
// The two things you pick rather than type: a set of labels, and a time.
//
// It was three. The colour sheet went with `note.color` in M315 — a card is one neutral
// surface now and colour lives on the tag, so the swatch grid was a control with nothing
// behind it.
//
// All bottom sheets rather than dialogs. A dialog takes the middle of the screen
// and asks to be dismissed; a sheet rises from the bottom, under the thumb, with
// the note still visible above it — which matters when the choice you are making
// is about the thing you are looking at.
/**
* Every label, ticked where it is on the note.
*
* `#tag` labels appear ticked and disabled — they are true of the note, and they
* are owned by its text, so showing them unticked would be a lie and letting them
* be unticked would be a control that undoes itself.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LabelSheet(
note: Note,
labels: List<Label>,
onAction: (EditorAction) -> Unit,
onDismiss: () -> Unit,
) {
var typed by remember { mutableStateOf("") }
val manual =
note.labels
.filterNot { it.viaTag }
.map { it.id }
.toSet()
val viaTag =
note.labels
.filter { it.viaTag }
.map { it.id }
.toSet()
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.imePadding()
.navigationBarsPadding(),
) {
SheetTitle(R.string.label_picker_title)
PlainTextField(
value = typed,
onValueChange = { typed = it },
hint = R.string.label_new_hint,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(onDone = {
onAction(EditorAction.CreateLabel(typed))
typed = ""
}),
)
// Capped rather than unbounded: a sheet that grows past the screen
// makes its own scroll fight the sheet's drag gesture.
LazyColumn(modifier = Modifier.heightIn(max = LABEL_LIST_MAX_HEIGHT)) {
items(items = labels, key = { it.id }) { label ->
val fromTag = label.id in viaTag
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = fromTag || label.id in manual,
enabled = !fromTag,
onCheckedChange = { on ->
val next = if (on) manual + label.id else manual - label.id
onAction(EditorAction.SetLabels(next.toList()))
},
)
Text(
text = label.name,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 4.dp),
)
if (fromTag) {
Text(
text = stringResource(R.string.label_from_tag),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
}
}
}
}
if (labels.isEmpty()) {
Text(
text = stringResource(R.string.label_none_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 16.dp),
)
}
}
}
}
/**
* When to be reminded.
*
* Presets first, and a full picker behind them. On a phone almost every reminder
* is "this evening", "tomorrow morning" or "next week" — the web's raw
* `datetime-local` field is the right control for a desktop and three taps too
* many for the common case here. The exact picker is still there, one tap down,
* because "Thursday at 3" is a real thing to want.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReminderSheet(
note: Note,
onAction: (EditorAction) -> Unit,
onDismiss: () -> Unit,
) {
var exact by remember { mutableStateOf(false) }
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.navigationBarsPadding(),
) {
SheetTitle(R.string.reminder_title)
reminderPresets().forEach { (labelRes, at) ->
Text(
text = "${stringResource(labelRes)} · ${formatInstant(rfc3339(at))}",
style = MaterialTheme.typography.bodyLarge,
modifier =
Modifier
.fillMaxWidth()
.clickable {
onAction(EditorAction.SetReminder(rfc3339(at)))
onDismiss()
}.padding(vertical = 12.dp),
)
}
Text(
text = stringResource(R.string.reminder_pick),
style = MaterialTheme.typography.bodyLarge,
modifier =
Modifier
.fillMaxWidth()
.clickable { exact = true }
.padding(vertical = 12.dp),
)
// Repeat only appears once there IS a reminder — a recurrence rule on
// a note with no time to recur from is a setting that does nothing.
if (note.remindAt != null) {
RecurrenceChips(
current = note.recurrence,
onPick = { onAction(EditorAction.SetRecurrence(it)) },
)
Text(
text = stringResource(R.string.reminder_clear),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error,
modifier =
Modifier
.fillMaxWidth()
.clickable {
onAction(EditorAction.ClearReminder)
onDismiss()
}.padding(vertical = 12.dp),
)
}
}
}
if (exact) {
ExactReminderPicker(
initial = note.remindAt?.let { localTime(it) } ?: defaultPickerTime(),
onPick = {
onAction(EditorAction.SetReminder(rfc3339(it)))
exact = false
onDismiss()
},
onDismiss = { exact = false },
)
}
}
/**
* Date then time, as two dialogs.
*
* Material 3 ships a date picker and a time picker but nothing that does both, and
* a phone screen has no room for them side by side. Sequential also matches how
* the choice is actually made — you know the day before you know the hour.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ExactReminderPicker(
initial: LocalDateTime,
onPick: (LocalDateTime) -> Unit,
onDismiss: () -> Unit,
) {
var date by remember { mutableStateOf<LocalDate?>(null) }
if (date == null) {
val state =
rememberDatePickerState(
initialSelectedDateMillis =
initial
.toLocalDate()
.atStartOfDay(ZoneId.of("UTC"))
.toInstant()
.toEpochMilli(),
)
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
// Nothing selected means nothing to confirm — the picker opens
// on a date, so this only guards a user who cleared it.
enabled = state.selectedDateMillis != null,
onClick = {
// The picker reports UTC midnight of the CALENDAR day that
// was tapped, so it has to be read back in UTC. Reading it
// in the device's zone shifts the date by one west of
// Greenwich — the classic off-by-a-day in this control.
date =
state.selectedDateMillis?.let {
Instant.ofEpochMilli(it).atZone(ZoneId.of("UTC")).toLocalDate()
}
},
) { Text(stringResource(R.string.picker_next)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.editor_cancel)) }
},
) {
DatePicker(state = state)
}
} else {
val state =
rememberTimePickerState(
initialHour = initial.hour,
initialMinute = initial.minute,
)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.picker_time_title)) },
text = { TimePicker(state = state) },
confirmButton = {
TextButton(onClick = {
onPick(
LocalDateTime.of(
requireNotNull(date) { "the time step is only reachable with a date" },
LocalTime.of(state.hour, state.minute),
),
)
}) { Text(stringResource(R.string.picker_set)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.editor_cancel)) }
},
)
}
}
@Composable
private fun RecurrenceChips(
current: String?,
onPick: (String?) -> Unit,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(vertical = 8.dp),
) {
RECURRENCE_RULES.forEach { (rule, labelRes) ->
FilterChip(
selected = current.orEmpty() == rule.orEmpty(),
onClick = { onPick(rule) },
label = { Text(stringResource(labelRes)) },
)
}
}
}
@Composable
private fun SheetTitle(labelRes: Int) {
Text(
text = stringResource(labelRes),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp),
)
}
/**
* The presets, computed against the device clock at the moment the sheet opens.
*
* "Later today" disappears once the evening has passed rather than silently
* meaning tomorrow — an offer that quietly does something else is worse than one
* that isn't there.
*/
private fun reminderPresets(): List<Pair<Int, LocalDateTime>> {
val now = LocalDateTime.now()
val presets = mutableListOf<Pair<Int, LocalDateTime>>()
val evening = now.toLocalDate().atTime(EVENING_HOUR, 0)
if (evening.isAfter(now)) {
presets += R.string.reminder_later_today to evening
}
presets += R.string.reminder_tomorrow to now.toLocalDate().plusDays(1).atTime(MORNING_HOUR, 0)
presets +=
R.string.reminder_next_week to
now
.toLocalDate()
.with(TemporalAdjusters.next(DayOfWeek.MONDAY))
.atTime(MORNING_HOUR, 0)
return presets
}
/** Where the exact picker opens when the note has no reminder yet. */
private fun defaultPickerTime(): LocalDateTime =
LocalDateTime
.now()
.toLocalDate()
.plusDays(1)
.atTime(MORNING_HOUR, 0)
/** The core's recurrence vocabulary; null is "does not repeat". */
private val RECURRENCE_RULES: List<Pair<String?, Int>> =
listOf(
null to R.string.recurrence_none,
"daily" to R.string.recurrence_daily,
"weekly" to R.string.recurrence_weekly,
"monthly" to R.string.recurrence_monthly,
"yearly" to R.string.recurrence_yearly,
)
private const val EVENING_HOUR = 18
private const val MORNING_HOUR = 8
private val LABEL_LIST_MAX_HEIGHT = 320.dp
@@ -0,0 +1,54 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
// Shared by the board and the editor.
//
// A failed save is most likely to happen WHILE the editor is open — that is where
// the writes are — so a banner only the board could render meant the one screen
// that needed it was the one screen without it.
@Composable
fun ErrorBanner(
message: String,
onDismiss: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = noteTint("red")
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp)
.clip(RoundedCornerShape(BANNER_RADIUS))
.background(tint.background(dark))
.border(1.dp, tint.border(dark), RoundedCornerShape(BANNER_RADIUS))
.padding(start = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDismiss) { Text(stringResource(R.string.error_dismiss)) }
}
}
private val BANNER_RADIUS = 12.dp
@@ -0,0 +1,36 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
/**
* Run [flush] when the app goes to the background.
*
* `ON_STOP` rather than `ON_PAUSE`: pause also fires when a dialog opens over the
* activity, which would save mid-sentence for no reason. The lambda goes through
* `rememberUpdatedState` so the observer — registered once — always calls the
* CURRENT one; captured directly it would hold the first composition's empty text
* forever and save that over a full note.
*
* Shared by the editor and the capture sheet. Both are places where text exists
* only in a composable until something writes it down, and the process can be
* killed while backgrounded without either of them being told again.
*/
@Composable
fun FlushOnStop(flush: () -> Unit) {
val current by rememberUpdatedState(flush)
val owner = LocalLifecycleOwner.current
DisposableEffect(owner) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) current()
}
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
}
@@ -0,0 +1,52 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
/**
* Calls back when the app comes to the front and when it leaves.
*
* `ON_START`/`ON_STOP` and not `ON_RESUME`/`ON_PAUSE`, which is the same choice
* the editor's save-on-leave makes for the same reason: resume and pause fire for
* anything that merely covers the window — a permission dialog, the notification
* shade — and a sync per shade-pull is not automatic sync, it is a stutter.
*
* A single-Activity app, so the Activity's lifecycle is the app's. If a second
* Activity is ever added this needs `ProcessLifecycleOwner` instead, or rotating
* between them will read as leaving and returning.
*
* Two callers, wanting opposite halves of it: automatic sync uses the return to
* decide whether to fetch, and the reminder notice uses it to re-read a
* permission the person may have just changed in the system settings.
*
* Both callbacks go through [rememberUpdatedState]: the observer is registered
* once, and without it the lambda would keep reading the first composition's
* state forever — deciding whether to push unsent notes from a snapshot taken
* before any note existed.
*/
@Composable
fun ForegroundTransitions(
onForeground: () -> Unit,
onBackground: () -> Unit,
) {
val forward by rememberUpdatedState(onForeground)
val away by rememberUpdatedState(onBackground)
val owner = LocalLifecycleOwner.current
DisposableEffect(owner) {
val observer =
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> forward()
Lifecycle.Event.ON_STOP -> away()
else -> Unit
}
}
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
}
@@ -0,0 +1,509 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.BodyItem
import com.fabledsword.thoughtsync.core.Note
import com.fabledsword.thoughtsync.core.NoteLabel
import com.fabledsword.thoughtsync.core.bodyTags
import com.fabledsword.thoughtsync.core.checklistItems
@Composable
fun NoteCard(
note: Note,
onOpen: () -> Unit,
onToggleItem: (Int, Boolean) -> Unit,
onAction: (EditorAction) -> Unit,
onConfirmDelete: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val haptics = LocalHapticFeedback.current
var menuOpen by remember { mutableStateOf(false) }
// NAMED rather than written inline in the chain below, and not for taste: ktlint's
// chain-method-continuation wants the next `.` glued to the closing paren of a
// multiline element — `).background(…)` — which is worse to read than a modifier
// with a name. Every other multiline element in this codebase happens to be last
// in its chain, so this is the first place the rule bites.
val opening =
Modifier.combinedClickable(
onClickLabel = stringResource(R.string.board_open_note),
onLongClickLabel = stringResource(R.string.board_note_actions),
onLongClick = {
// Fired HERE rather than when the menu appears. A long press is
// confirmed by the system before the popup has laid out, and the whole
// point of the buzz is to say "that registered" at the moment your
// finger has been still long enough — a menu that arrives with no tick
// under it reads as a phone that missed the gesture and then changed
// its mind.
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
menuOpen = true
},
onClick = onOpen,
)
// The Box exists only to anchor the menu. A DropdownMenu is a popup and takes no
// space, so the card's size is still the Column's.
Box {
Column(
modifier =
Modifier
.fillMaxWidth()
// Depth, not the boundary — the edge below is that. 1dp: enough to
// separate a white card from a #fafafa board, and the web's own
// `shadow-sm` is the value it is matching.
.shadow(CARD_ELEVATION, RoundedCornerShape(CARD_RADIUS))
// Clipped BEFORE the click modifier, so the ripple is bounded by the
// card's rounded corners instead of a rectangle overhanging them —
// and BEFORE padding, so the padded edge is still a tap target.
.clip(RoundedCornerShape(CARD_RADIUS))
.then(opening)
// ONE surface and ONE edge on every card, both neutral, neither
// asking the note anything. See noteCardSurface and CARD_EDGE_DARK.
.background(noteCardSurface(dark))
.border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS))
.padding(12.dp),
) {
// TAGS FIRST. They used to sit under everything else, which on a tall note put
// the one thing that says what a note IS below the fold of a glance. A board is
// scanned, not read, and the answer to "which of these is about the thing I am
// looking for" should be the first thing the eye lands on rather than the last.
//
// Above the body rather than beside it, because the body's first line is the
// note's NAME (M13 steps 3 and 4) and a chip floated next to it would compete
// with the thing that identifies the note. A row of its own costs one line and
// only on notes that have tags at all.
//
// ONLY the labels whose text is not still in the note. `via_tag` means exactly
// "backed by body text" since M311, so a chip for one printed the same tag
// twice — once where it was typed, once up here — and the card was carrying
// furniture for information it was already showing. A tag left in prose is
// tinted in place instead; see [tintTags]. What reaches this row is what the
// body cannot say: a tag lifted off its own line, and a label added by hand.
val chips = note.labels.filterNot { it.viaTag }
if (chips.isNotEmpty()) {
LabelChips(labels = chips)
Spacer(Modifier.height(8.dp))
}
// Body then checklist, in order — a note can carry both (M13 step 2). The
// first line of the body IS the note's name, at the same weight as the rest of
// it (M13 steps 3 and 4).
if (note.body.isNotBlank()) {
NoteBody(note = note, onToggleItem = onToggleItem)
}
// A note with nothing in it still has to occupy the board legibly — otherwise
// it reads as a rendering bug.
if (note.body.isBlank()) {
Text(
text = stringResource(R.string.board_empty_note),
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
note.remindAt?.let { at ->
Spacer(Modifier.height(8.dp))
ReminderChip(instant = at, recurrence = note.recurrence)
}
}
NoteMenu(
note = note,
expanded = menuOpen,
onDismiss = { menuOpen = false },
onAction = onAction,
onConfirmDelete = onConfirmDelete,
)
}
}
/**
* What you can do to a note without opening it.
*
* The board used to have none of this, and the operator's read of that was not "the
* actions are in the editor" — it was *"there are no long hold context menus in the
* app I have no way to delete notes."* Trash was three interactions deep (open, ⋮,
* Move to trash), and on a phone that is far enough from the gesture people reach
* for that it may as well not exist.
*
* **The same items as the editor's overflow, in the same words, from the same string
* resources.** A note has one vocabulary of things that can be done to it, and two
* surfaces that named them differently would be describing two different apps. It
* dispatches [EditorAction] for the same reason — `BoardViewModel.onEditorAction` is
* already the exhaustive dispatcher for every one of them, so the board reuses the
* seam rather than growing a parallel one that could drift.
*
* **Gated on the NOTE, not on the destination.** `note.trashed` is what the editor
* gates its own read-only mode on, and it is the only reading that survives the views
* that mix piles: Reminders cuts across archived and active alike, and a search hits
* whatever matches. A menu that offered "Move to trash" on a note already in the
* trash would be offering to do something twice.
*
* **Colour is absent**, though #2946 suggested it. It was left out because `note.color`
* was already scheduled for removal; M315 removed it. There is no colour to set on a
* note any more — a card is one neutral surface and the only coloured thing on a board
* is a tag — so the row this menu never grew is a row that could not exist.
*
* Labels are absent too, for a duller reason: the picker they open is editor state,
* and hoisting it to the board is a bigger change than the friction actually reported.
*/
@Composable
private fun NoteMenu(
note: Note,
expanded: Boolean,
onDismiss: () -> Unit,
onAction: (EditorAction) -> Unit,
onConfirmDelete: () -> Unit,
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
if (note.trashed) {
MenuItem(R.string.editor_restore, onDismiss) { onAction(EditorAction.Restore) }
MenuItem(R.string.editor_delete_forever, onDismiss, onConfirmDelete)
} else {
MenuItem(
if (note.pinned) R.string.editor_unpin else R.string.editor_pin,
onDismiss,
) { onAction(EditorAction.SetPinned(!note.pinned)) }
MenuItem(
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
onDismiss,
) { onAction(EditorAction.SetArchived(!note.archived)) }
MenuItem(R.string.editor_trash, onDismiss) { onAction(EditorAction.Trash) }
}
}
}
/**
* The note's body, with its checklist drawn where it actually sits.
*
* Rendered line by line rather than as one block of text, because an item is a line
* of the body now (M304) and a card that showed the prose and then the list would put
* every list in the wrong place — and, since the body already contains those lines,
* would show each one twice.
*
* Which lines are items is asked of the core rather than matched here. The grammar is
* already written three times; a fourth in Compose would be a fourth place for a
* checklist to change shape when it syncs.
*/
@Composable
private fun NoteBody(
note: Note,
onToggleItem: (Int, Boolean) -> Unit,
) {
val lines = remember(note.body) { note.body.split("\n") }
// Read from the BODY rather than from note.items, which is the same list by a
// longer route — and one that can lag the text by a save.
val itemAtLine =
remember(note.body) {
checklistItems(note.body)
.mapIndexed { index, item -> item.line.toInt() to (index to item) }
.toMap()
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
lines.take(MAX_PREVIEW_LINES).forEachIndexed { n, line ->
val found = itemAtLine[n]
when {
found != null ->
ChecklistRow(note, found.second) { onToggleItem(found.first, !found.second.checked) }
// Kept as a gap rather than dropped: it is the paragraph break
// somebody typed, and the card reads as a wall without it.
line.isBlank() -> Spacer(Modifier.height(4.dp))
else ->
Text(
text = tintTags(line, note),
style = MaterialTheme.typography.bodyMedium,
maxLines = MAX_WRAPPED_LINES,
overflow = TextOverflow.Ellipsis,
)
}
}
if (lines.size > MAX_PREVIEW_LINES) {
Text(
text = "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/**
* One checklist row on a card, with a box you can actually tick.
*
* A glyph rather than a Material Checkbox: it sits on a line of text and has to share
* that line's metrics, and a real Checkbox brings 48dp of touch target that would
* space a list out like a form. The tap target is the glyph's own padding, which is
* why it carries `clickable` rather than the row — clicking the TEXT should open the
* note, the way clicking anywhere else on the card does.
*/
@Composable
private fun ChecklistRow(
note: Note,
item: BodyItem,
onToggle: () -> Unit,
) {
Row(verticalAlignment = Alignment.Top) {
Text(
text = if (item.checked) "" else "",
style = MaterialTheme.typography.bodyMedium,
modifier =
Modifier
.clickable(onClick = onToggle)
.padding(end = 6.dp),
)
Text(
text = tintTags(item.text, note),
style = MaterialTheme.typography.bodyMedium,
textDecoration = if (item.checked) TextDecoration.LineThrough else null,
color =
if (item.checked) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = MAX_WRAPPED_LINES,
overflow = TextOverflow.Ellipsis,
)
}
}
/**
* One string of a note's own words, with every `#tag` in it drawn in that tag's colour.
*
* This is what replaced the chip for a tag still living in the prose. The card used to
* print such a tag twice — once where it was typed and once in the row above — and the
* duplicate was the loud copy, which made a tagged note read as "tag, then some text
* that happens to start with the same word". Colouring it in place says the same thing
* with no furniture, and says it more honestly: the token you can see IS the text you
* would delete to remove the tag.
*
* 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,
* TypeScript); a fourth in Compose would be a fourth thing to disagree — and this one
* would fail silently, as the wrong characters tinted rather than an error anywhere.
* The core's offsets are UTF-16 code units for this call site specifically, which is
* the only unit `addStyle` can take.
*
* Called per rendered STRING rather than once per body so a checklist item's text can
* be handled with no arithmetic: an item is a line minus a `- [ ] ` prefix of a length
* nothing carries, and shifting spans by a guessed prefix is the kind of off-by-one
* that shows up only on the one note that had a tag in a list.
*/
@Composable
private fun tintTags(
text: String,
note: Note,
): AnnotatedString {
val dark = isSystemInDarkTheme()
return remember(text, note.labels, dark) {
val spans = bodyTags(text)
if (spans.isEmpty()) {
AnnotatedString(text)
} else {
// A tag the note does not carry as a label yet — just typed, not yet
// derived — still gets a colour: `labelTint` falls back to deriving one
// from the name, which is what the chip would have shown anyway.
val picked = note.labels.associate { it.name.lowercase() to it.color }
buildAnnotatedString {
append(text)
spans.forEach { tag ->
val tint = labelTint(tag.name, picked[tag.name.lowercase()].orEmpty())
addStyle(
SpanStyle(color = tint.tagInk(dark), fontWeight = FontWeight.Medium),
tag.start.toInt(),
tag.end.toInt(),
)
}
}
}
}
}
@Composable
private fun LabelChips(labels: List<NoteLabel>) {
val dark = isSystemInDarkTheme()
// A plain row that clips rather than wraps: a card with eight labels should
// not grow taller than its content. The editor shows the full set.
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
labels.take(MAX_LABEL_CHIPS).forEach { label ->
val tint = labelTintFor(label.name, label.color)
Text(
// The `#` is carried on every chip, because everything that reaches
// this row is a tag — a tag lifted off its own line, or one attached
// through the picker — and the hash is how you would type either. It
// also keeps a lifted chip reading as the `#todo` somebody wrote.
text = "#${label.name}",
style = MaterialTheme.typography.labelSmall,
color = tint.tagInk(dark),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.clip(RoundedCornerShape(CHIP_RADIUS))
.background(tint.chipBackground(dark))
.border(1.dp, tint.chipBorder(dark), RoundedCornerShape(CHIP_RADIUS))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
}
/**
* The reminder, red once it has passed.
*
* Red for overdue and neutral otherwise, matching the web card exactly — the same
* red-100/red-700 and black/5 pairs, resolved through the shared tint table. It
* used to be blue for every reminder here, which made "you missed this" and
* "coming up on Friday" look identical on a board full of both.
*/
@Composable
private fun ReminderChip(
instant: String,
recurrence: String?,
) {
val dark = isSystemInDarkTheme()
val tint = noteTint(if (isPast(instant)) "red" else "default")
Text(
text = reminderLabel(instant, recurrence),
style = MaterialTheme.typography.labelSmall,
color = tint.chipForeground(dark),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.clip(RoundedCornerShape(CHIP_RADIUS))
.background(tint.chipBackground(dark))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
private const val MAX_PREVIEW_LINES = 8
/** How far one long line of a card may wrap before it is cut. */
private const val MAX_WRAPPED_LINES = 2
private const val MAX_LABEL_CHIPS = 3
private val CARD_RADIUS = 12.dp
private val CARD_ELEVATION = 1.dp
// ---------------------------------------------------------------------------
// WHAT A CARD IS: one surface and one edge, neither of which asks the note anything.
//
// Both are constants HERE rather than columns in NoteTint precisely so the palette
// CANNOT vary them; uniformity is the feature. The editor reads the surface from here
// too, so a note opened is the same object as the note on the board.
/**
* THE CARD SURFACE — one neutral per theme (M315).
*
* This used to be a function of the note: a palette fill for a tagged one, a colour
* generated from the id for the rest. Both are gone. The operator's verdict after four
* passes — "my coloring attempt has failed and nothing looks right… we've tried a lot
* to make the color work and somehow it never seems to land" — and the diagnosis under
* it is that a card's fill was being asked to carry meaning it could not carry. Nine
* keys is too few to identify anything on a board of any size, and a generated fill
* identifies nothing by construction, so a coloured board taught the eye to read hue
* as significant and then handed it noise. Colour lives on the TAG now, where the
* thing it names is right beside it.
*
* The values are `neutral-900` on dark and white on light — exactly what the palette's
* `default` always was, and exactly what the web card and both editors already use, so
* this is a collapse onto a surface every surface already had rather than a new colour
* anybody has to like. Mirrored as `NOTE_CARD_SURFACE` in `frontend/src/notes/colors.ts`
* (`bg-white dark:bg-neutral-900`).
*
* NOT a colour-scheme role: `surface` is the BOARD in this theme (neutral-50 / -950),
* and Material's `surfaceContainer` roles are unset here so they would resolve to
* baseline M3 greys rather than to the web's neutrals. Two hexes matching the web beats
* a role that nearly does.
*
* Measured, against the operator's "not the same color as their background but close
* to it" — the card fill is deliberately the WEAKEST number on the card:
*
* card vs board light #FFFFFF on #FAFAFA 1.04
* dark #171717 on #0A0A0A 1.10
* edge vs card light #B8B8B8 on #FFFFFF 1.98
* dark #404040 on #171717 1.73
* body vs card light #171717 on #FFFFFF 17.93 (needs 4.5)
* dark #FAFAFA on #171717 17.17
* muted vs card light #404040 on #FFFFFF 10.37
* dark #E5E5E5 on #171717 14.23
*
* A card is not separated from the board by its fill and never was — the edge and the
* shadow do that, which is why 1.04 is enough and why it has to stay near 1. A fill
* that separated on its own would be a panel, and a board of panels is the wall this
* whole line of work started from.
*/
fun noteCardSurface(dark: Boolean): Color = if (dark) CARD_SURFACE_DARK else CARD_SURFACE_LIGHT
private val CARD_SURFACE_LIGHT = Color(0xFFFFFFFF)
private val CARD_SURFACE_DARK = Color(0xFF171717)
// THE CARD'S EDGE — one grey, every card, both themes. Since M315 it is the only thing
// that differs from the board by more than a hair, which makes it structure rather than
// decoration: it is what a card IS.
//
// It was already neutral before the fill was. The version that came from the palette
// was a `{hue}-900` border and failed twice over: the line measured 1.56-2.09 against
// its own fill while the fill managed only 1.03-1.05 against the board, so it was the
// loudest thing on the card — and it carried the same information the fill did, so a
// field of cards read as a grid of outlines however different the colours inside were.
// A neutral line carries no information at all, which is exactly what lets it be
// structure instead of content. The fill is that same argument one size up.
//
// MEASURED AGAINST ONE FILL NOW, and deliberately left where it was. #B8B8B8 on white
// is 1.98 and #404040 on #171717 is 1.73 — both inside the ranges these values already
// shipped at across twenty fills (light 1.57-1.98, dark 1.58-1.73), but at the top of
// them rather than the ~1.6-1.7 the pair was originally matched on. Softening the light
// edge to re-match would weaken the only boundary a white card on a #FAFAFA board has,
// and the complaint that started M315 was about fill, never about edge weight. If an
// operator pass disagrees it is one constant, in two files.
//
// NOT a translucent black/white edge, which is the tidier way to write this and was
// measured and rejected: a border composites over what is under it, so `White` at 20%
// came out #56396D on a purple card and #A3C9C1 on a teal one. With one fill that
// argument no longer bites — but an opaque grey is what NoteCard.vue must also write,
// and two surfaces stating the same hex is how they stay the same card.
private val CARD_EDGE_LIGHT = Color(0xFFB8B8B8)
private val CARD_EDGE_DARK = Color(0xFF404040)
private val CHIP_RADIUS = 6.dp
@@ -0,0 +1,314 @@
package com.fabledsword.thoughtsync.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.core.Label
import com.fabledsword.thoughtsync.core.Note
import kotlinx.coroutines.delay
/**
* The one writing surface: a new note and an existing one are the same screen.
*
* Shaped like the capture sheet it replaced — a rounded card that begins below the
* status bar — so opening a note still reads as something rising over the board
* rather than a place you navigated to. It is full height rather than a real
* `ModalBottomSheet`, and that is the whole trade: a sheet spends a writing session
* negotiating with the IME for the bottom half of the display, and the swipe-down it
* buys is a gesture back already does. The shape is what was worth keeping.
*
* The card's surface paints the WHOLE sheet rather than a panel inside it, so opening
* a note reads as the same object growing to fill the display — the more literally
* true since M315, where the board and the editor became the same one neutral.
*
* 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
* bottom corner says the same thing as a fact instead, beside the Done that
* leaves.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NoteEditorScreen(
note: Note,
sessionKey: Long,
labels: List<Label>,
saving: Boolean,
error: String?,
onAction: (EditorAction) -> Unit,
) {
val dark = isSystemInDarkTheme()
// Keyed by the SESSION, not by note.id: the editor is reused across notes, so it
// needs a key — but a draft's id changes the moment it is first saved, and
// re-keying on that would reset this state to whatever the store just returned,
// throwing away every character typed during the write.
//
// BLOCKS rather than one string, because a checklist item is drawn as a real
// checkbox now and a widget cannot live inside a text field. The note is still one
// 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
// mid-capture. The saver carries the TEXT and re-derives the shape, since a block's
// id means nothing across a process death.
var blocks by
rememberSaveable(sessionKey, stateSaver = blocksSaver) {
mutableStateOf(splitBlocks(note.body).focusedAtEnd())
}
// Which field the caret is wanted in, or null. Held HERE rather than inside
// BlockBody because the toolbar's checklist button also asks for one.
var focus by remember(sessionKey) { mutableStateOf<Long?>(null) }
val bodyText = remember(blocks) { joinBlocks(blocks) }
var picker by remember(sessionKey) { mutableStateOf(Picker.NONE) }
var confirmingDelete by remember(sessionKey) { mutableStateOf(false) }
// A note in the trash is a record, not a document: editing one would silently
// resurrect work that was meant to be thrown away. It renders read-only, with
// Restore and Delete forever as the only things to do with it.
val readOnly = note.trashed
// Persist the text, if it changed. The baseline check is what makes "open a
// note, read it, back out" write nothing at all — without it every glance
// would bump `updated_at`, mark the note dirty for sync, and snapshot a
// revision identical to the one before it.
val flush = {
if (!readOnly && bodyText != note.body) {
onAction(EditorAction.SaveText(bodyText))
}
}
val leave = {
flush()
onAction(EditorAction.Close)
}
// Opening an existing note means continuing it. Without this the note arrives
// unfocused, and carrying on costs a tap into the last field.
//
// Not for a trashed note: it renders read-only, and a keyboard over a record you
// cannot edit is noise.
LaunchedEffect(sessionKey) {
if (!readOnly) focus = blocks.lastOrNull()?.id
}
// Idle-debounced autosave. LaunchedEffect cancels and restarts on every
// keystroke, so the delay only ever elapses once typing stops.
//
// Saving this often is affordable because a body write no longer costs a
// revision: history snapshots once per editing session rather than once per
// save. Before that, writing was expensive enough that this editor hoarded
// text until it closed — and an app kill mid-session lost the lot.
//
// 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
// needing the note to be saved by hand first.
LaunchedEffect(bodyText, sessionKey) {
if (readOnly || bodyText == note.body) return@LaunchedEffect
delay(AUTOSAVE_IDLE_MS)
onAction(EditorAction.SaveText(bodyText))
}
BackHandler(onBack = leave)
// Leaving the APP is not closing the editor, so the text has to be saved
// without the screen being torn down. Losing a paragraph to an incoming call
// is exactly the failure that makes someone stop trusting a notes app.
FlushOnStop(flush)
// The sheet shape, kept. `windowInsetsPadding` both insets the card below the
// status bar AND consumes that inset, so the bar inside adds no second gap of
// its own — the strip above the rounded corner is what makes this read as a card
// over the board rather than a screen that replaced it.
Box(
modifier =
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars),
) {
Surface(
modifier = Modifier.fillMaxSize(),
shape = RoundedCornerShape(topStart = SHEET_CORNER, topEnd = SHEET_CORNER),
color = noteCardSurface(dark),
// Both content colours are spelled out for the reason the toolbar had to
// be: Surface and Scaffold each default theirs to contentColorFor(their
// container), which returns Unspecified for anything that is not a
// colour-SCHEME ROLE. The card surface is not one, so the default publishes
// Unspecified as LocalContentColor and everything inside that does not
// set its own colour draws black — which is how the last toolbar became
// invisible in dark mode.
contentColor = MaterialTheme.colorScheme.onSurface,
) {
Scaffold(
containerColor = noteCardSurface(dark),
contentColor = MaterialTheme.colorScheme.onSurface,
topBar = {
EditorTopBar(
note = note,
readOnly = readOnly,
onClose = leave,
onStartChecklist = {
val (next, id) = blocks.plusTask()
blocks = next
focus = id
},
onPicker = { picker = it },
onConfirmDelete = { confirmingDelete = true },
onAction = onAction,
)
},
// Where the action bar used to be, carrying the two things that
// belong within reach of a thumb: whether the note is safe, and the
// way out. See [EditorFooter] for why the exit is down here and not
// only in the top-left corner.
bottomBar = {
EditorFooter(
updatedAt = note.updatedAt,
saving = saving,
onClose = leave,
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
// No imePadding here: EditorFooter carries it, so
// Scaffold measures that row at its keyboard-lifted
// height and the inset already reaches this Column
// through `padding`. Adding it again would inset for the
// keyboard twice.
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
) {
// A failed save has to be visible HERE. The board renders the
// same banner, but a write that fails while the editor is open
// would otherwise report itself only after the user had already
// left.
error?.let { message ->
ErrorBanner(
message = message,
onDismiss = { onAction(EditorAction.DismissError) },
)
}
// A note is its body; its NAME is that body's first line, so there
// is nothing separate to type into and nothing rendered bolder than
// the line beneath it (M13 steps 3 and 4). What 2992 changed is only
// how the body is DRAWN — checklist items as boxes rather than as
// the markup for boxes.
BlockBody(
blocks = blocks,
readOnly = readOnly,
focus = focus,
onChange = { blocks = it },
onFocus = { focus = it },
)
// No checklist section. The items ARE lines of the field above
// (M304) — rendering them again down here is what would put every
// list on screen twice.
if (note.labels.isNotEmpty()) {
EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction)
}
note.remindAt?.let { at ->
EditorReminderRow(
at = at,
recurrence = note.recurrence,
readOnly = readOnly,
onAction = onAction,
)
}
}
}
}
}
EditorOverlays(
note = note,
labels = labels,
picker = picker,
onPicker = { picker = it },
onAction = onAction,
)
if (confirmingDelete) {
ConfirmDeleteDialog(
onConfirm = {
confirmingDelete = false
onAction(EditorAction.DeleteForever)
},
onDismiss = { confirmingDelete = false },
)
}
}
/** Which overlay is open. One at a time, so they cannot stack on a phone screen. */
enum class Picker { NONE, LABELS, REMINDER }
/** The pickers, hoisted out so the screen above reads as a layout rather than a switch. */
@Composable
private fun EditorOverlays(
note: Note,
labels: List<Label>,
picker: Picker,
onPicker: (Picker) -> Unit,
onAction: (EditorAction) -> Unit,
) {
val dismiss = { onPicker(Picker.NONE) }
when (picker) {
Picker.NONE -> Unit
Picker.LABELS ->
LabelSheet(
note = note,
labels = labels,
onAction = onAction,
onDismiss = dismiss,
)
Picker.REMINDER ->
ReminderSheet(
note = note,
onAction = onAction,
onDismiss = dismiss,
)
}
}
/**
* How long typing has to stop before the note is written.
*
* Long enough that a normal sentence is one write, short enough that nothing
* meaningful is at risk if the app dies. The flush on close and [FlushOnStop] still
* cover the window between the last keystroke and this elapsing.
*/
private const val AUTOSAVE_IDLE_MS = 1_000L
/**
* 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.
*/
private val SHEET_CORNER = 28.dp
@@ -0,0 +1,313 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color
/**
* The colour palette, matching `frontend/src/notes/colors.ts` VALUE FOR VALUE.
*
* A colour is stored by the core as a key ("red", "teal", …) and every surface
* resolves it to its own tints. The web app resolves through Tailwind classes; this
* table is those same Tailwind colours as literals, so a tag that is amber on the
* desktop is the same amber on the phone rather than a near-miss. Generated from
* tailwindcss 3.4's palette rather than transcribed by eye.
*
* Dark tints keep the web's ALPHA instead of a precomputed blend — Compose composites
* a translucent colour over what's beneath exactly as CSS does, so a panel sits on the
* background the same way in both.
*
* This table is the palette of MEANINGFUL colours: a tag's. A NOTE no longer has one
* at all (M315) — the card is one neutral per theme, held in NoteCard.kt beside the
* edge, where the palette cannot reach either of them. What is left here is the chip,
* the inline `#tag`, and the panels and banners that borrow a hue to say what they
* are.
*
* `yellow` maps to Tailwind's *amber*, matching colors.ts; plain yellow is too
* acid against the neutral surfaces.
*/
data class NoteTint(
val label: String,
val lightBackground: Color,
val lightBorder: Color,
val darkBackground: Color,
val darkBorder: Color,
val lightChipBackground: Color,
val darkChipBackground: Color,
/**
* The REMINDER pill's ink, and nothing else's — see [chipForeground].
*
* Only two of these ten are ever read (`red` when a reminder has passed, `default`
* otherwise). They stay a per-hue column because they are transcribed from the
* web's literals rather than derived from anything here.
*/
val lightChipForeground: Color,
val darkChipForeground: Color,
/**
* THE INK A TAG IS DRAWN IN — inline in the prose AND as a chip's text — see
* [tagInk].
*
* These were two columns until M315, and the split was real while it lasted: a chip
* brought its own `-100` fill and could afford `-700`, while inline text sat on
* whatever the card was, which included a gray-tagged card at `neutral-200` where
* `-700` measured 3.98 (green), 4.11 (orange) and 4.34 (teal), all under the 4.5
* body text needs. One step deeper cleared every fill at once.
*
* The twenty card fills that split was solving for are gone, so both jobs take this
* one value. The direction is deliberate: since M311 a tag whose text is in the body
* is drawn where it was typed and NOT repeated as a chip, so the inline token is the
* common case and collapsing onto ITS column leaves what is seen most exactly as it
* was. The chip is strictly better for the move — on its own fill it goes from
* 4.52-8.23 to 6.37-12.01 in light. Dark needed no decision: the two columns already
* held the same value for all ten hues.
*/
val lightTagInk: Color,
val darkTagInk: Color,
) {
/**
* The pale fill of a PANEL, a banner, an update card or a picker swatch — the
* places that borrow a hue to say what they are. Not a note's: since M315 a card
* has one neutral surface and does not come through this table at all.
*/
fun background(dark: Boolean): Color = if (dark) darkBackground else lightBackground
fun border(dark: Boolean): Color = if (dark) darkBorder else lightBorder
fun chipBackground(dark: Boolean): Color = if (dark) darkChipBackground else lightChipBackground
/**
* The REMINDER pill's ink. NOT a tag's — a tag takes [tagInk] wherever it is drawn.
*
* Kept apart from [tagInk] because the reminder pill is not a tag: it borrows the
* chip's shape and its `red-100`/`black-5` fills, and its `red-700`/`neutral-600`
* text is transcribed from NoteCard.vue's literal classes. The two happened to be
* one value; making the tag ink one step deeper (M315) is where they parted, and
* moving the reminder with it would have silently broken that mirror instead.
*/
fun chipForeground(dark: Boolean): Color = if (dark) darkChipForeground else lightChipForeground
/**
* The colour a `#tag` is drawn in — in the note's own words, or as a chip.
*
* A tag whose text is in the body is no longer repeated as a chip (the card was
* printing every tag twice — once where it was typed, once at the top). It is
* tinted in place instead, which is both less furniture and a more honest card:
* the thing you see IS the thing you would delete to remove the tag.
*/
fun tagInk(dark: Boolean): Color = if (dark) darkTagInk else lightTagInk
/**
* A hairline edge for a chip, in its own ink at low alpha.
*
* THE EDGE IS THE PILL. Against the one card surface a chip's fill measures
* 1.02-1.26 in light and 1.02-1.73 in dark — very nearly nothing, and dark red at
* 1.02 is literally invisible. Without this the tag name would read as loose text.
* The fill only tints a shape the edge is drawing.
*
* An edge rather than a heavier fill, because a fill loud enough to hold its own
* shape would be the loudest thing on a board whose whole point is now that the tag
* is the one coloured thing on it.
*/
fun chipBorder(dark: Boolean): Color = tagInk(dark).copy(alpha = CHIP_EDGE_ALPHA)
}
// How strongly a chip's edge is drawn, as a fraction of its own ink.
//
// SOLVED FOR, NOT GUESSED — and re-solved once the answer became solvable. 0.60 was
// picked against the worst case of the time: a chip on a card of its OWN colour, back
// when a note took its first tag's fill. It gave 2.32:1 there, missed the 3:1 of WCAG
// 1.4.11, and the comment here reasoned its way out of that on the grounds that a
// chip's information is its text.
//
// M315 removed that worst case. The edge is now the ink at alpha over a KNOWN fill, so
// the smallest alpha clearing 3:1 for all ten hues is arithmetic rather than judgment:
// 0.60 gives 2.75-3.82 in light and misses for six of the ten, 0.65 gives 3.03-4.36 and
// misses for none. Dark runs 4.52-5.76. The old comment named 0.80 as the fallback if
// the judgment were ever overruled; it is not needed, and it draws a hard outline where
// a hairline does the job.
//
// Mirrored on the web as the `/65` in LABEL_CHIP_SHELL's ring.
private const val CHIP_EDGE_ALPHA = 0.65f
/** Keyed by the core's colour vocabulary. Order matches the web's picker. */
val NOTE_TINTS: Map<String, NoteTint> =
mapOf(
"default" to
NoteTint(
label = "Default",
lightBackground = Color(0xFFFFFFFF),
lightBorder = Color(0xFFE5E5E5),
darkBackground = Color(0xFF171717),
darkBorder = Color(0xFF404040),
lightChipBackground = Color(0x0D000000),
lightChipForeground = Color(0xFF525252),
darkChipBackground = Color(0x1AFFFFFF),
darkChipForeground = Color(0xFFD4D4D4),
lightTagInk = Color(0xFF404040),
darkTagInk = Color(0xFFD4D4D4),
),
"red" to
NoteTint(
label = "Red",
lightBackground = Color(0xFFFEF2F2),
lightBorder = Color(0xFFFECACA),
darkBackground = Color(0x66450A0A),
darkBorder = Color(0xFF7F1D1D),
lightChipBackground = Color(0xFFFEE2E2),
lightChipForeground = Color(0xFFB91C1C),
darkChipBackground = Color(0x80450A0A),
darkChipForeground = Color(0xFFFCA5A5),
lightTagInk = Color(0xFF991B1B),
darkTagInk = Color(0xFFFCA5A5),
),
"orange" to
NoteTint(
label = "Orange",
lightBackground = Color(0xFFFFF7ED),
lightBorder = Color(0xFFFED7AA),
darkBackground = Color(0x66431407),
darkBorder = Color(0xFF7C2D12),
lightChipBackground = Color(0xFFFFEDD5),
lightChipForeground = Color(0xFFC2410C),
darkChipBackground = Color(0x80431407),
darkChipForeground = Color(0xFFFDBA74),
lightTagInk = Color(0xFF9A3412),
darkTagInk = Color(0xFFFDBA74),
),
"yellow" to
NoteTint(
label = "Yellow",
lightBackground = Color(0xFFFFFBEB),
lightBorder = Color(0xFFFDE68A),
darkBackground = Color(0x66451A03),
darkBorder = Color(0xFF78350F),
lightChipBackground = Color(0xFFFEF3C7),
lightChipForeground = Color(0xFF92400E),
darkChipBackground = Color(0x80451A03),
darkChipForeground = Color(0xFFFCD34D),
lightTagInk = Color(0xFF92400E),
darkTagInk = Color(0xFFFCD34D),
),
"green" to
NoteTint(
label = "Green",
lightBackground = Color(0xFFF0FDF4),
lightBorder = Color(0xFFBBF7D0),
darkBackground = Color(0x66052E16),
darkBorder = Color(0xFF14532D),
lightChipBackground = Color(0xFFDCFCE7),
lightChipForeground = Color(0xFF15803D),
darkChipBackground = Color(0x80052E16),
darkChipForeground = Color(0xFF86EFAC),
lightTagInk = Color(0xFF166534),
darkTagInk = Color(0xFF86EFAC),
),
"teal" to
NoteTint(
label = "Teal",
lightBackground = Color(0xFFF0FDFA),
lightBorder = Color(0xFF99F6E4),
darkBackground = Color(0x66042F2E),
darkBorder = Color(0xFF134E4A),
lightChipBackground = Color(0xFFCCFBF1),
lightChipForeground = Color(0xFF0F766E),
darkChipBackground = Color(0x80042F2E),
darkChipForeground = Color(0xFF5EEAD4),
lightTagInk = Color(0xFF115E59),
darkTagInk = Color(0xFF5EEAD4),
),
"blue" to
NoteTint(
label = "Blue",
lightBackground = Color(0xFFEFF6FF),
lightBorder = Color(0xFFBFDBFE),
darkBackground = Color(0x66172554),
darkBorder = Color(0xFF1E3A8A),
lightChipBackground = Color(0xFFDBEAFE),
lightChipForeground = Color(0xFF1D4ED8),
darkChipBackground = Color(0x80172554),
darkChipForeground = Color(0xFF93C5FD),
lightTagInk = Color(0xFF1E40AF),
darkTagInk = Color(0xFF93C5FD),
),
"purple" to
NoteTint(
label = "Purple",
lightBackground = Color(0xFFFAF5FF),
lightBorder = Color(0xFFE9D5FF),
darkBackground = Color(0x663B0764),
darkBorder = Color(0xFF581C87),
lightChipBackground = Color(0xFFF3E8FF),
lightChipForeground = Color(0xFF7E22CE),
darkChipBackground = Color(0x803B0764),
darkChipForeground = Color(0xFFD8B4FE),
lightTagInk = Color(0xFF6B21A8),
darkTagInk = Color(0xFFD8B4FE),
),
"pink" to
NoteTint(
label = "Pink",
lightBackground = Color(0xFFFDF2F8),
lightBorder = Color(0xFFFBCFE8),
darkBackground = Color(0x66500724),
darkBorder = Color(0xFF831843),
lightChipBackground = Color(0xFFFCE7F3),
lightChipForeground = Color(0xFFBE185D),
darkChipBackground = Color(0x80500724),
darkChipForeground = Color(0xFFF9A8D4),
lightTagInk = Color(0xFF9D174D),
darkTagInk = Color(0xFFF9A8D4),
),
"gray" to
NoteTint(
label = "Gray",
lightBackground = Color(0xFFF5F5F5),
lightBorder = Color(0xFFD4D4D4),
darkBackground = Color(0xFF262626),
darkBorder = Color(0xFF404040),
lightChipBackground = Color(0xFFE5E5E5),
lightChipForeground = Color(0xFF404040),
darkChipBackground = Color(0xFF404040),
darkChipForeground = Color(0xFFE5E5E5),
lightTagInk = Color(0xFF262626),
darkTagInk = Color(0xFFE5E5E5),
),
)
/**
* Resolve a stored colour key.
*
* An unknown key falls back to `default` rather than throwing: colours are data
* that arrives from a server which may be newer than this client, and a note
* whose tint we don't recognise should still be readable.
*/
@Composable
@ReadOnlyComposable
fun noteTint(key: String): NoteTint = NOTE_TINTS[key] ?: NOTE_TINTS.getValue("default")
/**
* The tint for a LABEL, derived from its name when nobody has picked one.
*
* Every `#tag` is born colourless, so without this a board of tags is a board of
* identical grey chips. See `DerivedTint.kt` for why this derives rather than
* persisting a colour when the tag is minted.
*/
@Composable
@ReadOnlyComposable
fun labelTintFor(
name: String,
color: String,
): NoteTint = labelTint(name, color)
/**
* [labelTintFor] with no composable context, for a caller building its value inside
* `remember` — where a `@Composable` call is not allowed. The card's inline tag
* colours are computed there, once per body rather than once per recomposition.
*
* One implementation, two entry points: the composable one delegates here rather than
* repeating the lookup, so the chip and the inline token cannot resolve differently.
*/
fun labelTint(
name: String,
color: String,
): NoteTint = NOTE_TINTS[resolvedLabelColor(name, color, NOTE_TINTS.keys)] ?: NOTE_TINTS.getValue("default")
@@ -0,0 +1,155 @@
package com.fabledsword.thoughtsync.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
/**
* A bordered block, tinted from the same table the notes use.
*
* Reusing the note palette rather than Material's `errorContainer` keeps the whole
* app one visual language: a warning here is the same yellow a note can be, which
* is also what the web app does with its Tailwind amber.
*/
@Composable
fun Panel(
tone: Tone = Tone.NEUTRAL,
content: @Composable () -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = noteTint(tone.tintKey())
Column(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(PANEL_RADIUS))
.background(tint.background(dark))
.border(1.dp, tint.border(dark), RoundedCornerShape(PANEL_RADIUS))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
content()
}
}
@Composable
fun Notice(
tone: Tone,
title: String,
body: String,
onDismiss: (() -> Unit)? = null,
/**
* A way to FIX what the notice describes, when there is one.
*
* Separate from [onDismiss] because they are opposites: dismissing accepts the
* situation, acting changes it. A notice about a permission has an action and
* no dismiss — acknowledging a reminder that cannot ring does not make it ring.
*/
actionLabel: String? = null,
onAction: (() -> Unit)? = null,
) {
Panel(tone = tone) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(text = body, style = MaterialTheme.typography.bodyMedium)
if (actionLabel != null && onAction != null) {
TextButton(onClick = onAction) { Text(actionLabel) }
}
onDismiss?.let {
TextButton(onClick = it) { Text(stringResource(R.string.error_dismiss)) }
}
}
}
/**
* 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
* editor's overflow and the board's long-press menu — and they offer the same
* actions in the same words. A second copy of this would be a second place for the
* closing order to be got wrong.
*
* Closing FIRST is the whole point: an action that raises a sheet or a dialog would
* otherwise do it underneath a menu still hanging over the screen. Doing it in here
* means no call site can forget.
*/
@Composable
fun MenuItem(
@StringRes labelRes: Int,
onClose: () -> Unit,
onClick: () -> Unit,
) {
DropdownMenuItem(
text = { Text(stringResource(labelRes)) },
onClick = {
onClose()
onClick()
},
)
}
/**
* The one confirmation in the app.
*
* Delete-forever is the only irreversible thing a note can be asked to do —
* archive, trash, even unlinking a server all undo — so it is the only one that
* interrupts. Both surfaces that offer it raise THIS dialog: the editor's overflow
* and the board's long-press menu are two ways to the same act, and two dialogs
* would be two chances to word the consequences differently.
*
* Nothing about a note is passed in. The caller already knows which note it is
* asking about and holds it while this is on screen; taking one here would only let
* the dialog and the action that follows it disagree.
*/
@Composable
fun ConfirmDeleteDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.editor_delete_forever_title)) },
text = { Text(stringResource(R.string.editor_delete_forever_body)) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text(stringResource(R.string.editor_delete_forever_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.editor_cancel)) }
},
)
}
/** The three tones a panel or notice can take, mapped onto the note palette. */
enum class Tone { NEUTRAL, WARN, ERROR }
private fun Tone.tintKey(): String =
when (this) {
Tone.NEUTRAL -> "default"
Tone.WARN -> "yellow"
Tone.ERROR -> "red"
}
private val PANEL_RADIUS = 12.dp
@@ -0,0 +1,88 @@
package com.fabledsword.thoughtsync.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.VisualTransformation
/**
* A text field with no box around it.
*
* The search box, the label picker, the sync-pairing form: fields that sit on a
* surface which already has its own edges and its own colour, where Material's filled
* field would draw a second, differently coloured box inside the first. Stripping the
* container and the indicator at each site independently is how they drift apart, so
* it happens once, here.
*
* The note EDITOR no longer comes through this. It dropped to `BasicTextField`
* (see `EditorBlock.kt`) for density: Material's field puts 16dp above and below its
* text, which is right for a form and is the whole row height on a checklist. Nothing
* about "no box" was lost there — BasicTextField never had one.
*
* The 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
* is meant to be reading.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlainTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
@StringRes hint: Int? = null,
enabled: Boolean = true,
singleLine: Boolean = false,
minLines: Int = 1,
textStyle: TextStyle = LocalTextStyle.current,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
visualTransformation: VisualTransformation = VisualTransformation.None,
) {
TextField(
value = value,
onValueChange = onValueChange,
modifier = modifier.fillMaxWidth(),
enabled = enabled,
placeholder = hint?.let { { Text(stringResource(it)) } },
singleLine = singleLine,
minLines = minLines,
textStyle = textStyle,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
visualTransformation = visualTransformation,
colors = plainFieldColors(),
)
}
/**
* One definition of "no box". Two copies of this is exactly the drift this file
* exists to prevent.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun plainFieldColors(): TextFieldColors =
TextFieldDefaults.colors(
// Full-strength, not Material's 38%-alpha disabled treatment: a trashed
// note is rendered read-only through this field and its text is meant to
// be READ, not visually retired.
disabledTextColor = MaterialTheme.colorScheme.onSurface,
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
)
@@ -0,0 +1,96 @@
package com.fabledsword.thoughtsync.ui
import android.app.AlarmManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationManagerCompat
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.Reminders
/**
* Says so when a reminder would not actually reach anyone.
*
* Both conditions here are ones Android can put the app into at any time and
* never tells it about: notifications switched off in system settings, and exact
* alarms refused. Either one turns reminders into something that silently does
* nothing, and a feature that silently does nothing is worse than one that is
* plainly absent — the person keeps setting reminders and keeps not getting them.
*
* Shown only on the Reminders view, which is where somebody is already thinking
* about this. Putting it on the main board would nag people who have never set a
* reminder at all.
*
* Re-read on every return to the app, because the fix happens in a system screen
* this app cannot observe: without that, someone would grant the permission, come
* back, and still be looking at a warning telling them they had not.
*/
@Composable
fun ReminderNotice() {
val context = LocalContext.current
var canNotify by remember { mutableStateOf(notificationsAllowed(context)) }
var canBeExact by remember { mutableStateOf(exactAllowed(context)) }
ForegroundTransitions(
onForeground = {
canNotify = notificationsAllowed(context)
canBeExact = exactAllowed(context)
},
onBackground = {},
)
if (canNotify && canBeExact) return
Column(modifier = Modifier.padding(horizontal = GUTTER, vertical = 4.dp)) {
if (!canNotify) {
Notice(
tone = Tone.WARN,
title = stringResource(R.string.reminder_notifications_blocked_title),
body = stringResource(R.string.reminder_notifications_blocked_body),
actionLabel = stringResource(R.string.reminder_open_settings),
onAction = { context.startActivity(appNotificationSettings(context)) },
)
}
// Only worth raising once notifications work at all: told both at once, the
// second is noise about the punctuality of something that is not arriving.
if (canNotify && !canBeExact) {
Notice(
tone = Tone.WARN,
title = stringResource(R.string.reminder_inexact_title),
body = stringResource(R.string.reminder_inexact_body),
actionLabel = stringResource(R.string.reminder_allow_exact),
onAction = { context.startActivity(exactAlarmSettings(context)) },
)
}
}
}
private fun notificationsAllowed(context: Context): Boolean =
NotificationManagerCompat.from(context).areNotificationsEnabled()
private fun exactAllowed(context: Context): Boolean {
val alarms = context.getSystemService(AlarmManager::class.java) ?: return true
return Reminders.canBeExact(alarms)
}
private fun appNotificationSettings(context: Context): Intent =
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
private fun exactAlarmSettings(context: Context): Intent =
Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
.setData(Uri.fromParts("package", context.packageName, null))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -0,0 +1,367 @@
package com.fabledsword.thoughtsync.ui
import android.os.Build
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Compatibility
import com.fabledsword.thoughtsync.core.RevokeOutcome
// Becoming linked: the probe-then-sign-in flow, and the notices around it.
//
// Split from SyncScreen.kt because it is a different job. That file renders the
// state of an existing connection; this one is the several-step negotiation that
// creates one, and it is the half that has to be careful — it is where a password
// gets typed.
@Composable
fun UnlinkedPanel(
state: SyncState,
onProbe: (String) -> Unit,
onClearProbe: () -> Unit,
onLink: (String, Credentials) -> Unit,
onDismissRevokeNotice: () -> Unit,
) {
// Saveable for the things it would be annoying to retype after a rotation —
// and deliberately NOT for the password or the token. `rememberSaveable`
// persists into the instance-state bundle, and a secret has no business being
// written there to save someone four seconds of typing.
var url by rememberSaveable { mutableStateOf("") }
var mode by rememberSaveable { mutableStateOf(LinkMode.PASSWORD) }
var email by rememberSaveable { mutableStateOf("") }
var deviceName by rememberSaveable { mutableStateOf(defaultDeviceName()) }
var password by remember { mutableStateOf("") }
var token by remember { mutableStateOf("") }
state.lastRevoke?.let { RevokeNotice(revoke = it, onDismiss = onDismissRevokeNotice) }
Panel {
Text(
text = stringResource(R.string.sync_offline_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = stringResource(R.string.sync_offline_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
AddressSection(
url = url,
onUrlChange = {
url = it
// A probe describes ONE address. The moment it is edited the answer on
// screen is about a server the user is no longer asking about.
if (state.probe != null || state.probeError != null) onClearProbe()
},
busy = state.busy,
onProbe = { onProbe(url) },
)
ProbeSection(state)
if (state.probeUsable) {
SignInFields(
mode = mode,
onMode = { mode = it },
email = email,
onEmail = { email = it },
password = password,
onPassword = { password = it },
token = token,
onToken = { token = it },
deviceName = deviceName,
onDeviceName = { deviceName = it },
)
state.linkError?.let {
Notice(tone = Tone.ERROR, title = stringResource(R.string.sync_link_failed), body = it)
}
if (state.linking || state.syncing) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
val credentials =
when (mode) {
LinkMode.PASSWORD ->
Credentials
.Password(email, password, deviceName)
.takeIf { email.isNotBlank() && password.isNotEmpty() }
LinkMode.TOKEN -> Credentials.Token(token).takeIf { token.isNotBlank() }
}
Button(
onClick = { credentials?.let { onLink(url, it) } },
enabled = credentials != null && !state.busy,
) {
Text(stringResource(R.string.sync_connect))
}
// Where app updates come from, said here rather than left as a gap. This
// device has no update path at all until it is linked, and a Check button
// that always found nothing would be worse than the sentence.
UnlinkedUpdateNote()
}
}
/**
* How this device proves who it is.
*
* Two modes, because two situations: an email and password is what most people
* have, and a pasted device token is for anyone who would rather not type a
* password into an app — or whose account is behind SSO and has no password to
* type. Both are verified before anything is stored, so a slip fails here rather
* than at the next sync.
*/
@Composable
private fun SignInFields(
mode: LinkMode,
onMode: (LinkMode) -> Unit,
email: String,
onEmail: (String) -> Unit,
password: String,
onPassword: (String) -> Unit,
token: String,
onToken: (String) -> Unit,
deviceName: String,
onDeviceName: (String) -> Unit,
) {
Text(
text = stringResource(R.string.sync_signin),
style = MaterialTheme.typography.labelLarge,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = mode == LinkMode.PASSWORD,
onClick = { onMode(LinkMode.PASSWORD) },
label = { Text(stringResource(R.string.sync_mode_password)) },
)
FilterChip(
selected = mode == LinkMode.TOKEN,
onClick = { onMode(LinkMode.TOKEN) },
label = { Text(stringResource(R.string.sync_mode_token)) },
)
}
if (mode == LinkMode.PASSWORD) {
PlainTextField(
value = email,
onValueChange = onEmail,
hint = R.string.sync_email,
singleLine = true,
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next,
),
)
PlainTextField(
value = password,
onValueChange = onPassword,
hint = R.string.sync_password,
singleLine = true,
keyboardOptions =
KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
visualTransformation = PasswordVisualTransformation(),
)
// Only on this path: a device token was already minted against a named
// device in the web app, so `link_with_token` takes no name and offering
// the field there would collect something with nowhere to go.
PlainTextField(
value = deviceName,
onValueChange = onDeviceName,
hint = R.string.sync_device_name,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
)
Text(
text = stringResource(R.string.sync_device_name_help),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
PlainTextField(
value = token,
onValueChange = onToken,
hint = R.string.sync_token,
singleLine = true,
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done,
),
)
Text(
text = stringResource(R.string.sync_token_help),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/** The address field and its Check button — a probe, never a link. */
@Composable
private fun AddressSection(
url: String,
onUrlChange: (String) -> Unit,
busy: Boolean,
onProbe: () -> Unit,
) {
Text(
text = stringResource(R.string.sync_address_label),
style = MaterialTheme.typography.labelLarge,
)
Row(verticalAlignment = Alignment.CenterVertically) {
PlainTextField(
value = url,
onValueChange = onUrlChange,
modifier = Modifier.weight(1f),
hint = R.string.sync_address_hint,
singleLine = true,
keyboardOptions =
KeyboardOptions(
// No autocapitalise, and a URI keyboard so the IME stops
// autocorrecting. A phone "helpfully" capitalising a hostname
// is the difference between connecting and a baffling failure.
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Go,
),
keyboardActions = KeyboardActions(onGo = { onProbe() }),
)
TextButton(onClick = onProbe, enabled = url.isNotBlank() && !busy) {
Text(stringResource(R.string.sync_check))
}
}
Text(
text = stringResource(R.string.sync_address_help),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
/** Everything the probe produced: progress, failure, what answered, and the risk. */
@Composable
private fun ProbeSection(state: SyncState) {
if (state.probing) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
state.probeError?.let {
Notice(tone = Tone.ERROR, title = stringResource(R.string.sync_probe_failed), body = it)
}
state.probe?.let { probe ->
ProbeCard(probe.siteName, probe.version, probe.compatibility)
// Cleartext is permitted app-wide so a self-hosted server on a LAN works at
// all (the core explicitly supports `http://192.168.1.10:8000`). Permitting
// it silently would be the wrong half of that trade — this warning is what
// turns a platform default into an informed choice, and it appears BEFORE
// the credential fields rather than after.
if (probe.baseUrl.startsWith("http://")) {
Notice(
tone = Tone.WARN,
title = stringResource(R.string.sync_insecure_title),
body = stringResource(R.string.sync_insecure_body),
)
}
}
}
/** What answered, shown BEFORE any credential is offered to it. */
@Composable
private fun ProbeCard(
siteName: String?,
version: String?,
compatibility: Compatibility,
) {
val incompatible = compatibility is Compatibility.Incompatible
Panel(tone = if (incompatible) Tone.ERROR else Tone.NEUTRAL) {
Text(
text = siteName ?: stringResource(R.string.sync_server_generic),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
version?.let {
Text(
text = stringResource(R.string.sync_server_version, it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = describeCompatibility(compatibility),
style = MaterialTheme.typography.bodyMedium,
color =
if (incompatible) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
/**
* Shown when unlinking could not retire the token server-side.
*
* Not a transient message. Someone who disconnected in order to retire a phone
* needs to know a live credential is still out there, and needs it to still be
* there when they come back to check.
*/
@Composable
private fun RevokeNotice(
revoke: RevokeOutcome,
onDismiss: () -> Unit,
) {
// Revoked and Skipped are the fine cases and say nothing — a notice for "it
// worked" is noise on a screen someone is leaving.
val body =
when (revoke) {
is RevokeOutcome.Unsupported -> stringResource(R.string.sync_revoke_unsupported)
is RevokeOutcome.Failed -> stringResource(R.string.sync_revoke_failed, revoke.reason)
else -> return
}
Notice(
tone = Tone.WARN,
title = stringResource(R.string.sync_revoke_title),
body = body,
onDismiss = onDismiss,
)
}
/** The phone's own name, so the server's device list reads usefully by default. */
private fun defaultDeviceName(): String =
listOfNotNull(Build.MANUFACTURER?.replaceFirstChar(Char::titlecase), Build.MODEL)
.filter { it.isNotBlank() }
.distinct()
.joinToString(" ")
.ifBlank { "Android phone" }
@@ -0,0 +1,313 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.UpdateOutcome
/**
* Opt-in server pairing.
*
* The whole screen is written around one idea: **being unlinked is not a
* problem.** ThoughtSync is local-first and completely usable having never opened
* this screen, so the unlinked state leads with "Working offline on this device"
* and explains what connecting would ADD, rather than presenting an empty form as
* unfinished setup.
*
* Structurally a port of the desktop's `SyncView.vue` — same probe-then-link
* order, same copy where the copy was already right — because the two surfaces
* pair with the same servers and a difference in wording here would read as a
* difference in behaviour.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SyncScreen(
state: SyncState,
onClose: () -> Unit,
onProbe: (String) -> Unit,
onClearProbe: () -> Unit,
onLink: (String, Credentials) -> Unit,
onSyncNow: () -> Unit,
onUnlink: () -> Unit,
onDismissRevokeNotice: () -> Unit,
automatic: Boolean,
onAutomaticChange: (Boolean) -> Unit,
update: UpdateState,
onCheckUpdate: () -> Unit,
onInstallUpdate: () -> Unit,
onDismissUpdateError: () -> Unit,
onInstallOutcome: (UpdateOutcome.Result) -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.sync_title)) },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.editor_back),
)
}
},
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.imePadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
when {
state.loading -> CircularProgressIndicator(modifier = Modifier.padding(32.dp))
state.linked ->
LinkedPanel(
state = state,
onSyncNow = onSyncNow,
onUnlink = onUnlink,
automatic = automatic,
onAutomaticChange = onAutomaticChange,
update = update,
onCheckUpdate = onCheckUpdate,
onInstallUpdate = onInstallUpdate,
onDismissUpdateError = onDismissUpdateError,
onInstallOutcome = onInstallOutcome,
)
else ->
UnlinkedPanel(
state = state,
onProbe = onProbe,
onClearProbe = onClearProbe,
onLink = onLink,
onDismissRevokeNotice = onDismissRevokeNotice,
)
}
}
}
}
// ───────────────────────────────── linked ─────────────────────────────────
@Composable
private fun LinkedPanel(
state: SyncState,
onSyncNow: () -> Unit,
onUnlink: () -> Unit,
automatic: Boolean,
onAutomaticChange: (Boolean) -> Unit,
update: UpdateState,
onCheckUpdate: () -> Unit,
onInstallUpdate: () -> Unit,
onDismissUpdateError: () -> Unit,
onInstallOutcome: (UpdateOutcome.Result) -> Unit,
) {
var confirmingUnlink by remember { mutableStateOf(false) }
Panel {
Text(
text = stringResource(R.string.sync_connected_to),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = state.status?.serverUrl.orEmpty(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
state.linkedAs?.let {
Text(
text = stringResource(R.string.sync_linked_as, it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text =
stringResource(
R.string.sync_last_synced,
state.status?.lastSyncAt?.let { formatInstant(it) }
?: stringResource(R.string.sync_never),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (state.pending) {
Text(
text = stringResource(R.string.sync_unsent),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
AutomaticRow(automatic = automatic, enabled = !state.busy, onChange = onAutomaticChange)
if (state.syncing) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onSyncNow, enabled = !state.busy) {
Text(stringResource(R.string.sync_now))
}
TextButton(onClick = { confirmingUnlink = true }, enabled = !state.busy) {
Text(stringResource(R.string.sync_disconnect))
}
}
state.lastOutcome?.let { outcome ->
if (state.syncError == null) {
Text(
text = syncSummary(outcome),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Rejections are the server refusing a SPECIFIC change. Surfaced, never
// swallowed, because only a person can resolve them.
if (outcome.push.rejected > 0uL) {
Notice(
tone = Tone.WARN,
title = stringResource(R.string.sync_rejected_title),
body =
pluralStringResource(
R.plurals.sync_rejected_body,
outcome.push.rejected.toInt(),
outcome.push.rejected.toInt(),
outcome.push.errors.joinToString("; "),
),
)
}
}
if (state.degraded.isNotEmpty()) {
Notice(
tone = Tone.WARN,
title = stringResource(R.string.sync_degraded_title),
body = stringResource(R.string.sync_degraded_body, state.degraded.joinToString(", ")),
)
}
state.syncError?.let {
Notice(tone = Tone.ERROR, title = stringResource(R.string.sync_failed_title), body = it)
}
// The app itself comes from this server too, not just the notes.
UpdateCard(
state = update,
onCheck = onCheckUpdate,
onInstall = onInstallUpdate,
onDismissError = onDismissUpdateError,
onOutcome = onInstallOutcome,
)
Text(
text = stringResource(R.string.sync_footer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp),
)
if (confirmingUnlink) {
// Confirmed because it is not obvious what disconnecting does to the notes.
// The copy answers that first — they stay — since the fear it raises is
// "will this delete something?", not "am I sure?".
AlertDialog(
onDismissRequest = { confirmingUnlink = false },
title = { Text(stringResource(R.string.sync_disconnect_title)) },
text = { Text(stringResource(R.string.sync_disconnect_body)) },
confirmButton = {
TextButton(onClick = {
confirmingUnlink = false
onUnlink()
}) { Text(stringResource(R.string.sync_disconnect)) }
},
dismissButton = {
TextButton(onClick = { confirmingUnlink = false }) {
Text(stringResource(R.string.editor_cancel))
}
},
)
}
}
/**
* The one setting this screen has.
*
* Reads as a statement of what the phone does rather than a feature name, and
* says what "automatically" means in minutes — an interval a person cannot see is
* one they cannot trust, and "syncs automatically" covers everything from every
* keystroke to once a day.
*
* Turning it off is not turning sync off. The copy says so, because a switch next
* to a Disconnect button invites exactly that reading.
*/
@Composable
private fun AutomaticRow(
automatic: Boolean,
enabled: Boolean,
onChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.sync_automatic),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text =
stringResource(
if (automatic) {
R.string.sync_automatic_on
} else {
R.string.sync_automatic_off
},
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = automatic, onCheckedChange = onChange, enabled = enabled)
}
}
@@ -0,0 +1,71 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Compatibility
import com.fabledsword.thoughtsync.core.SyncOutcome
// Turning sync results into sentences.
//
// Composables rather than plain functions, because every word here comes from
// strings.xml and `stringResource` needs a composition. The view model keeps the
// raw `SyncOutcome`, which is the same split `Time.kt` draws: the core decides
// what happened, the UI decides how a person reads it.
/**
* What a sync did, in one line.
*
* Counts what MOVED rather than everything the protocol reports. `batches`,
* `pages`, `noop` and `cursor` are all real numbers and none of them answer the
* question the person is actually asking, which is whether their notes are in
* step. A cycle that moved nothing says so plainly instead of listing zeroes.
*/
@Composable
fun syncSummary(outcome: SyncOutcome): String {
val sent = (outcome.push.created + outcome.push.applied).toInt()
val received = (outcome.pull.notesApplied + outcome.pull.notesDeleted).toInt()
val blobs = outcome.pull.blobsDownloaded.toInt()
val parts = mutableListOf<String>()
if (sent > 0) parts += stringResource(R.string.sync_summary_sent, sent)
if (received > 0) parts += stringResource(R.string.sync_summary_received, received)
if (blobs > 0) parts += pluralStringResource(R.plurals.sync_summary_attachments, blobs, blobs)
val line =
if (parts.isEmpty()) {
stringResource(R.string.sync_summary_uptodate)
} else {
stringResource(R.string.sync_summary, parts.joinToString(", "))
}
// Attachments that didn't arrive retry on the next cycle, so this is a note
// rather than an error — but saying nothing would leave a missing image
// looking like data loss.
val failed = outcome.pull.blobsFailed.toInt()
return if (failed > 0) {
line + " " + pluralStringResource(R.plurals.sync_summary_attachments_failed, failed, failed)
} else {
line
}
}
/**
* What a probed server's compatibility means for the person reading it.
*
* `Incompatible` carries the core's own reason and is shown verbatim: the core
* knows which protocol version is missing and phrases it for a human, and
* substituting a generic "not compatible" here would throw that away.
*/
@Composable
fun describeCompatibility(compatibility: Compatibility): String =
when (compatibility) {
is Compatibility.Ok -> stringResource(R.string.sync_compat_ok)
is Compatibility.Degraded ->
stringResource(
R.string.sync_compat_degraded,
compatibility.unavailable.joinToString(", "),
)
is Compatibility.Incompatible -> compatibility.reason
}
@@ -0,0 +1,350 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.fabledsword.thoughtsync.core.Compatibility
import com.fabledsword.thoughtsync.core.ProbeResult
import com.fabledsword.thoughtsync.core.RevokeOutcome
import com.fabledsword.thoughtsync.core.SyncOutcome
import com.fabledsword.thoughtsync.core.SyncStatus
import com.fabledsword.thoughtsync.core.ThoughtSync
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** How the device proves who it is when pairing. */
enum class LinkMode { PASSWORD, TOKEN }
/**
* What gets sent to pair this device, by mode.
*
* A sealed type rather than six loose strings, and not only for the parameter
* count: the two modes genuinely carry different things. `link_with_token` takes
* NO device name — the token was already minted against a named device in the web
* app — so a flat argument list meant collecting one in token mode and silently
* dropping it. Modelling it this way made that impossible to express.
*/
sealed interface Credentials {
data class Password(
val email: String,
val password: String,
val deviceName: String,
) : Credentials
data class Token(
val token: String,
) : Credentials
}
/**
* Everything the sync screen renders from.
*
* Deliberately holds NO credentials. Email, password, token, address and device
* name live in the screen's own state and are handed to [SyncViewModel.link] at
* submit time, so a secret never outlives the composable that collected it — and
* never lands in a view model that survives the screen being closed.
*
* Results are kept RAW ([lastOutcome], [lastRevoke]) rather than as prose. Turning
* a sync result into a sentence is localisation, which belongs where
* `stringResource` is in scope; the same split `Time.kt` draws for timestamps.
*/
data class SyncState(
val loading: Boolean = true,
val status: SyncStatus? = null,
val pending: Boolean = false,
val probing: Boolean = false,
val probe: ProbeResult? = null,
val probeError: String? = null,
val linking: Boolean = false,
val linkError: String? = null,
/** The account this device paired as, for the duration of the session. */
val linkedAs: String? = null,
/** Features this server doesn't have. Not an error — everything else syncs. */
val degraded: List<String> = emptyList(),
val syncing: Boolean = false,
val syncError: String? = null,
val lastOutcome: SyncOutcome? = null,
/** Set only when unlinking left the token alive server-side. */
val lastRevoke: RevokeOutcome? = null,
) {
val linked: Boolean get() = status?.linked == true
/** Any in-flight network call, for disabling the controls that would race it. */
val busy: Boolean get() = probing || linking || syncing
/**
* Whether the server is usable at all.
*
* An incompatible server is the one probe result that must not lead to a
* credential prompt — the core refuses the link anyway, and offering the form
* would collect a password only to throw it away.
*/
val probeUsable: Boolean
get() = probe != null && probe.compatibility !is Compatibility.Incompatible
}
/**
* Opt-in server pairing and sync.
*
* Being UNLINKED is the resting state, not an incomplete setup: the app is
* local-first and entirely usable having never opened this screen. Nothing here
* may frame it as a problem to be fixed.
*
* ## Threading
*
* The two shapes are genuinely different and are called differently. `probe`,
* `linkWithPassword`, `linkWithToken`, `unlink` and `syncNow` are Rust `async`
* exported through uniffi, so Kotlin sees `suspend` functions already driven by a
* tokio runtime — they are awaited directly, and wrapping them in
* [Dispatchers.IO] would park a thread to wait on something that never blocks one.
* `syncStatus` and `hasPending` are ordinary blocking FFI into SQLite and do need
* the IO dispatcher, exactly like the board's calls.
*
* ## Cancellation
*
* Leaving the screen cancels [viewModelScope], which drops the Rust future
* mid-sync. That is safe by construction rather than by luck: no async path in the
* core holds the store lock across an await, and `last_sync_at` is stamped only
* after both halves of a cycle succeed, so an interrupted sync resumes from the
* stored cursor next time (Scribe #2736).
*/
class SyncViewModel(
private val core: ThoughtSync,
/**
* Called after a sync that changed the store.
*
* The board is a separate view model holding its own snapshot of the notes,
* and a pull can have rewritten every one of them underneath it. Wiring the
* two together explicitly is less magic than a shared event bus and makes the
* dependency visible at the construction site.
*/
private val onStoreChanged: () -> Unit,
) : ViewModel() {
var state by mutableStateOf(SyncState())
private set
init {
refresh()
}
/** Read the stored link. Cheap and local — no network. */
fun refresh() {
viewModelScope.launch {
state =
try {
val status = withContext(Dispatchers.IO) { core.syncStatus() }
val pending = withContext(Dispatchers.IO) { core.hasPending() }
state.copy(status = status, pending = pending, loading = false)
} catch (e: Exception) {
// A store that won't answer is a real fault, but the screen
// still has to render — showing the unlinked state is honest,
// since without a readable link there is effectively none.
state.copy(loading = false, status = null, syncError = e.describe())
}
}
}
/**
* Ask a server who it is, committing to nothing.
*
* Separated from linking on purpose: it is what lets someone see what answered
* BEFORE handing over a password. A typo that reaches a stranger's server
* should cost a round trip, not a credential.
*/
fun probe(url: String) {
if (url.isBlank()) return
viewModelScope.launch {
state = state.copy(probing = true, probeError = null, probe = null, linkError = null)
state =
try {
state.copy(probe = core.probe(url), probing = false)
} catch (e: Exception) {
state.copy(probing = false, probeError = e.describe())
}
}
}
/** Discard the probe, so editing the address doesn't leave a stale answer up. */
fun clearProbe() {
state = state.copy(probe = null, probeError = null, linkError = null)
}
/**
* Pair with the probed server, then immediately sync.
*
* The sync is part of the action, not a separate step the user has to think
* of: connecting an account and then facing an empty board would read as the
* link having failed.
*
* `url` comes from the PROBE's normalised `base_url` where there is one, so
* the address that was inspected is the address that gets paired — not a
* re-parse of whatever is currently in the text field.
*/
fun link(
url: String,
credentials: Credentials,
) {
val target = state.probe?.baseUrl ?: url
viewModelScope.launch {
state = state.copy(linking = true, linkError = null)
state =
try {
val identity =
when (credentials) {
is Credentials.Password ->
core.linkWithPassword(
target,
credentials.email.trim(),
credentials.password,
credentials.deviceName.trim(),
)
is Credentials.Token ->
core.linkWithToken(target, credentials.token.trim())
}
val compatibility = state.probe?.compatibility
state.copy(
linking = false,
linkedAs = identity.email,
degraded =
(compatibility as? Compatibility.Degraded)?.unavailable ?: emptyList(),
// The probe has done its job; leaving it up would keep the
// connect form on screen next to a live connection.
probe = null,
status = withContext(Dispatchers.IO) { core.syncStatus() },
)
} catch (e: Exception) {
state.copy(linking = false, linkError = e.describe())
}
if (state.linked) syncNow()
}
}
/** A sync the person asked for. Failures are reported. */
fun syncNow() = sync(announce = true)
/**
* A sync nothing asked for — app resume, or the periodic worker.
*
* The difference is entirely in how FAILURE is treated. Someone who pulled
* the board down is owed an answer; someone who merely opened the app did not
* ask a question, and answering it with a red banner about a server being
* unreachable makes their own notes look broken when nothing of theirs is.
* The quiet channel for a persistent problem is the drawer badge, which reads
* `has_pending` and does not care how the attempt was made.
*
* It does NOT clear an existing error either: a failure the person was already
* shown stays shown until they dismiss it or a real sync succeeds.
*/
fun syncQuietly() = sync(announce = false)
private fun sync(announce: Boolean) {
viewModelScope.launch {
state = state.copy(syncing = true, syncError = if (announce) null else state.syncError)
state =
try {
val outcome = core.syncNow()
state.copy(
syncing = false,
// A success clears the error whoever started it: the
// condition it described is demonstrably over.
syncError = null,
lastOutcome = outcome,
status = outcome.status,
pending = withContext(Dispatchers.IO) { core.hasPending() },
)
} catch (e: Exception) {
state.copy(
syncing = false,
syncError = if (announce) e.describe() else state.syncError,
)
}
// Only when something actually arrived: a no-op sync must not make the
// board flash its loading state for nothing.
if (state.lastOutcome?.changedTheStore() == true) onStoreChanged()
}
}
/**
* Stop syncing, retiring this device's token on the server.
*
* The local half is unconditional in the core — someone unlinking because the
* phone is being sold must not be held to it by a server that is offline. The
* revoke outcome comes back so the screen can say plainly when the token is
* still live, which is the one thing about this flow worth interrupting for.
*/
fun unlink() {
viewModelScope.launch {
state = state.copy(syncing = true, syncError = null)
state =
try {
val revoked = core.unlink()
state.copy(
syncing = false,
lastRevoke = revoked,
status = withContext(Dispatchers.IO) { core.syncStatus() },
// Everything below described the connection that just ended.
linkedAs = null,
degraded = emptyList(),
lastOutcome = null,
pending = false,
)
} catch (e: Exception) {
state.copy(syncing = false, syncError = e.describe())
}
}
}
fun dismissRevokeNotice() {
state = state.copy(lastRevoke = null)
}
/**
* Acknowledge a sync failure.
*
* Exists because the BOARD reports these too, and a banner the person cannot
* get rid of is worse than the failure it describes. Clearing is honest here:
* an unsynced note is still pending, `hasPending` still says so, and the next
* cycle will report the same fault if it is still there.
*/
fun dismissSyncError() {
state = state.copy(syncError = null)
}
companion object {
fun factory(
core: ThoughtSync,
onStoreChanged: () -> Unit,
): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = SyncViewModel(core, onStoreChanged) as T
}
}
}
/**
* Whether a sync actually moved anything, so the board is only reloaded when its
* contents can have changed.
*
* Attachments count: a note whose image finally downloaded renders differently
* even though the note row itself is untouched.
*/
private fun SyncOutcome.changedTheStore(): Boolean =
pull.notesApplied > 0uL ||
pull.notesDeleted > 0uL ||
pull.labelsApplied > 0uL ||
pull.labelsDeleted > 0uL ||
pull.blobsDownloaded > 0uL
/**
* The message to show for a failure.
*
* The core writes these for people to read — "notes.example.com responded, but not
* with ThoughtSync's configuration" — so they are shown as-is rather than
* replaced with a generic string that would throw away the only useful part.
*/
private fun Exception.describe(): String = message ?: "Something went wrong."
@@ -0,0 +1,83 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// The brand colour: the same #F5C518 the web app's manifest, its <meta
// name="theme-color"> and the adaptive launcher icon all use.
private val Brand = Color(0xFFF5C518)
// Neutral surfaces lifted from the web app's palette so the three clients share a
// ground, not just an accent. style.css paints neutral-50 in light and neutral-950
// in dark, which is also what the desktop window is painted before the webview
// draws its first frame.
private val Neutral50 = Color(0xFFFAFAFA)
private val Neutral200 = Color(0xFFE5E5E5)
private val Neutral500 = Color(0xFF737373)
private val Neutral700 = Color(0xFF404040)
private val Neutral800 = Color(0xFF262626)
private val Neutral900 = Color(0xFF171717)
private val Neutral950 = Color(0xFF0A0A0A)
private val Ink = Color(0xFF1A1A1A)
private val LightColors =
lightColorScheme(
primary = Brand,
// Black on gold, never white: the brand colour is bright enough that white
// text on it fails contrast outright.
onPrimary = Ink,
primaryContainer = Brand,
onPrimaryContainer = Ink,
background = Neutral50,
onBackground = Neutral900,
surface = Neutral50,
onSurface = Neutral900,
surfaceVariant = Neutral200,
onSurfaceVariant = Neutral700,
outline = Neutral500,
outlineVariant = Neutral200,
)
private val DarkColors =
darkColorScheme(
primary = Brand,
onPrimary = Ink,
primaryContainer = Brand,
onPrimaryContainer = Ink,
background = Neutral950,
onBackground = Neutral50,
surface = Neutral950,
onSurface = Neutral50,
surfaceVariant = Neutral800,
onSurfaceVariant = Neutral200,
outline = Neutral500,
outlineVariant = Neutral700,
)
/**
* Material 3 in ThoughtSync's own colours, following the system light/dark setting.
*
* DELIBERATELY NOT Material You dynamic colour, which this used until the operator
* saw the first build. Dynamic colour is the more Android-native choice and it
* makes the app look like a different product on the phone than on the desktop and
* the web — on a stock device with no wallpaper it renders as undifferentiated
* grey. The three surfaces are peers held to one quality bar, so they share one
* identity; taking the wallpaper's palette instead would throw that away for
* platform convention.
*
* If dynamic colour is ever wanted it belongs behind a setting, not as the default.
*/
@Composable
fun ThoughtSyncTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
content = content,
)
}
@@ -0,0 +1,94 @@
package com.fabledsword.thoughtsync.ui
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
// The timestamp seam between the core and the phone.
//
// The core stores and syncs RFC3339 in UTC, because that is what SQLite holds and
// what the server speaks. Deciding how a human should READ an instant is the UI's
// job and the answer differs per device, so the conversion lives here — once,
// rather than in the card and the editor separately, where the two would
// eventually format the same reminder differently.
/**
* Exactly the shape the core writes: UTC, milliseconds, `Z`.
*
* `Instant.toString()` would also be valid RFC3339, but it varies its precision
* with the value — it drops the fractional part on a whole second. Matching the
* core's `to_rfc3339_opts(Millis, true)` byte for byte means a reminder set on the
* phone is indistinguishable from one set on the desktop, including to anything
* downstream that compares the strings rather than parsing them.
*/
private val RFC3339_UTC: DateTimeFormatter =
DateTimeFormatter
.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
.withZone(ZoneId.of("UTC"))
/** A local wall-clock time, as the instant the core will store. */
fun rfc3339(local: LocalDateTime): String = RFC3339_UTC.format(local.atZone(ZoneId.systemDefault()).toInstant())
/** An instant from the core, as this device's local wall-clock time. */
fun localTime(raw: String): LocalDateTime? =
runCatching {
OffsetDateTime.parse(raw).atZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime()
}.getOrNull()
/**
* A stored instant in the device's own locale and zone.
*
* Used for reminders and for "last synced" — anywhere the core hands the UI an
* RFC3339 string and a person has to read it.
*
* A string we cannot parse is shown verbatim rather than swallowed: a visibly odd
* reminder beats a silently missing one, and the raw value is what someone would
* need in order to report it.
*/
fun formatInstant(raw: String): String =
localTime(raw)
?.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT))
?: raw
/** The reminder as one line, with its repeat rule if it has one. */
fun reminderLabel(
raw: String,
recurrence: String?,
): String =
buildString {
append("")
append(formatInstant(raw))
if (!recurrence.isNullOrBlank()) {
append(" · ↻ ")
append(recurrence)
}
}
/** A stored instant as epoch milliseconds, or null if it will not parse. */
fun epochMillis(raw: String): Long? = runCatching { OffsetDateTime.parse(raw).toInstant().toEpochMilli() }.getOrNull()
/** Whether a stored reminder has already passed, for showing it as overdue. */
fun isPast(raw: String): Boolean {
val at = epochMillis(raw) ?: return false
return at < System.currentTimeMillis()
}
/**
* Whether a timestamp is older than [minutes] ago — or absent entirely.
*
* Null reads as stale, which is the answer that matters at the one call site:
* a device that has never completed a sync has the most to gain from one.
* Unparseable reads as stale too, for the same reason — guessing "recent" from a
* value we could not understand would suppress the sync that might fix it.
*/
fun olderThan(
raw: String?,
minutes: Long,
): Boolean {
val at = raw?.let { epochMillis(it) }
return at == null || at < System.currentTimeMillis() - minutes * MILLIS_PER_MINUTE
}
private const val MILLIS_PER_MINUTE = 60_000L
@@ -0,0 +1,197 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.AppUpdate
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.UpdateOutcome
/**
* Updating the app from the server it is linked to.
*
* Lives on the sync screen because that is what it IS — the server hands out the
* client as well as the notes. Putting it in a settings screen of its own would
* separate two halves of one relationship.
*
* Nothing here appears on an unlinked device; [UnlinkedUpdateNote] says why in one
* line instead, so the absence reads as a consequence of not being linked rather
* than as a missing feature.
*/
@Composable
fun UpdateCard(
state: UpdateState,
onCheck: () -> Unit,
onInstall: () -> Unit,
onDismissError: () -> Unit,
onOutcome: (UpdateOutcome.Result) -> Unit,
) {
val context = LocalContext.current
// The system answers an install through a BroadcastReceiver, which has no way
// back into a view model. This is the seam.
UpdateOutcome.latest?.let { result ->
LaunchedEffect(result) { onOutcome(result) }
}
Column(modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) {
Text(
text = stringResource(R.string.update_installed_version, state.installedVersion),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val available = state.available
if (available != null) {
Text(
text =
stringResource(
R.string.update_available,
available.version,
available.size / BYTES_PER_MB,
),
style = MaterialTheme.typography.bodyMedium,
)
} else if (state.upToDate) {
Text(
text = stringResource(R.string.update_current),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (state.working) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (available == null) {
TextButton(onClick = onCheck, enabled = !state.busy) {
Text(stringResource(R.string.update_check))
}
} else {
Button(onClick = onInstall, enabled = !state.busy) {
Text(stringResource(R.string.update_install))
}
}
}
// Android's "install unknown apps" grant is separate from anything in the
// manifest and only the person can give it. Said BEFORE a download rather
// than after, so nobody spends 55 MiB to be told no.
if (available != null && !AppUpdate.canInstall(context)) {
Notice(
tone = Tone.WARN,
title = stringResource(R.string.update_permission_title),
body = stringResource(R.string.update_permission_body),
actionLabel = stringResource(R.string.update_permission_action),
onAction = { context.startActivity(AppUpdate.installPermissionSettings(context)) },
)
}
state.error?.let {
Notice(
tone = Tone.ERROR,
title = stringResource(R.string.update_failed_title),
body = it,
onDismiss = onDismissError,
)
}
}
}
/**
* The nag: an update is waiting, said where someone will actually see it.
*
* Until this existed the only way to learn about a new build was to open the sync
* screen and press Check — so the updates that got installed were the ones somebody
* went looking for, and the rest were simply never found.
*
* 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
* wifi, which is where the fetch happens.
*
* Dismissible, but not permanently. "Later" clears it for this sitting; the next time
* the app comes forward it says so again. That is the difference between a reminder
* and a notice you can lose.
*/
@Composable
fun UpdateBanner(
version: String,
busy: Boolean,
onInstall: () -> Unit,
onDismiss: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = noteTint("blue")
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp)
.clip(RoundedCornerShape(BANNER_RADIUS))
.background(tint.background(dark))
.border(1.dp, tint.border(dark), RoundedCornerShape(BANNER_RADIUS))
.padding(start = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.update_banner_ready, version),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
if (busy) {
CircularProgressIndicator(modifier = Modifier.size(BANNER_SPINNER), strokeWidth = 2.dp)
Spacer(Modifier.size(12.dp))
} else {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.update_later)) }
TextButton(onClick = onInstall) { Text(stringResource(R.string.update_install)) }
}
}
}
private val BANNER_RADIUS = 12.dp
private val BANNER_SPINNER = 18.dp
/**
* The one line an unlinked device gets.
*
* Updates arrive from a linked server, so there is genuinely nothing to offer
* here — and a Check button that always found nothing would be worse than saying
* so.
*/
@Composable
fun UnlinkedUpdateNote() {
Text(
text = stringResource(R.string.update_needs_server),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
// Carries the bottom breathing room the connect button used to provide,
// now that it is the last thing on the unlinked screen.
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp),
)
}
private const val BYTES_PER_MB = 1024 * 1024
@@ -0,0 +1,232 @@
package com.fabledsword.thoughtsync.ui
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.fabledsword.thoughtsync.AppUpdate
import com.fabledsword.thoughtsync.UpdateOutcome
import com.fabledsword.thoughtsync.core.ClientUpdate
import com.fabledsword.thoughtsync.core.ThoughtSync
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** Everything the update card renders from. */
data class UpdateState(
/** What is running now. Shown even when there is nothing to update to. */
val installedVersion: Long = 0,
val checking: Boolean = false,
/** Only ever set to something NEWER — the core does that comparison. */
val available: ClientUpdate? = null,
/** A check completed and found nothing. Distinct from "not checked yet". */
val upToDate: Boolean = false,
/** The available build has been fetched and is sitting in the cache. */
val ready: Boolean = false,
val downloading: Boolean = false,
val working: Boolean = false,
val error: String? = null,
/** The banner has been waved away — until the app next comes forward. */
val nagDismissed: Boolean = false,
) {
val busy: Boolean get() = checking || downloading || working
/**
* Worth interrupting the board for.
*
* Gated on [ready], so the banner never appears until the bytes are on disk. An
* update that has been FOUND is not news anyone can act on quickly — offering it
* off wifi would turn one tap into a download somebody did not plan.
*
* Deliberately still true while [working]: the install is the one moment the
* banner has something to report, and hiding it there would look like the tap
* did nothing.
*/
val nagging: Boolean get() = ready && available != null && !nagDismissed
}
/**
* Updating the app from the server it syncs with.
*
* **Linked-only, and said out loud.** The app is local-first and completely usable
* having never touched a server, so an unlinked install has no update path at all.
* The card says that rather than offering a Check button that silently finds
* nothing — the same lesson as the desktop's unlink copy (issue 2110).
*
* 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
* secret this app holds across two languages for no gain.
*/
class UpdateViewModel(
private val core: ThoughtSync,
/**
* MUST be the application context — it outlives this view model, and holding an
* Activity here is the textbook way to leak a window.
*/
private val context: Context,
) : ViewModel() {
var state by mutableStateOf(UpdateState(installedVersion = AppUpdate.installedVersionCode(context)))
private set
/** When the last check ran, so coming back to the app twice in a minute is one. */
private var lastCheckAt = 0L
/** Ask the linked server what it has. The Check button on the sync screen. */
fun check() = runCheck(fetch = false)
/**
* The automatic path: look, fetch, then nag.
*
* Called when the app comes forward. Until this existed an update was only ever
* found by someone opening the sync screen and pressing a button — so the ones
* that mattered were the ones nobody went looking for.
*
* 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.
*/
fun checkInBackground() {
val now = System.currentTimeMillis()
when {
state.busy -> Unit
// Already fetched and waved away — say so again. "Later" is for that
// sitting, not forever, and without this branch a single dismissal would
// silence the update permanently. Which is precisely the "lost" this whole
// path exists to prevent.
state.ready -> if (state.nagDismissed) state = state.copy(nagDismissed = false)
// Found one and never fetched it — almost always because the last look
// happened on mobile data. Retry the FETCH rather than the check, and
// ignore the interval: this is what makes an update found on the train
// arrive when the person gets home instead of waiting out six hours.
state.available != null ->
if (AppUpdate.onWifi(context)) viewModelScope.launch { download() }
// Flicking between two apps is not a request to re-check.
now - lastCheckAt < CHECK_INTERVAL_MS -> Unit
else -> {
lastCheckAt = now
runCheck(fetch = true)
}
}
}
private fun runCheck(fetch: Boolean) {
viewModelScope.launch {
state = state.copy(checking = true, error = null, upToDate = false)
state =
try {
val found = core.clientUpdate(state.installedVersion)
state.copy(
checking = false,
available = found,
upToDate = found == null,
// A build that is still there is worth mentioning again. The
// dismissal was for that sitting, not for this version.
nagDismissed = if (found == null) state.nagDismissed else false,
)
} catch (e: Exception) {
// Broad by intent, as everywhere the core is called: it reports
// every failure as one error type carrying a message written to
// be read, and a failed check must not take the screen down.
state.copy(checking = false, error = e.message ?: FALLBACK)
}
// Fetched before anything is said, so the banner is a one-tap install
// rather than the start of a wait. Off wifi this simply does not happen
// and the app stays quiet — the next foreground on wifi picks it up.
if (fetch && state.available != null && AppUpdate.onWifi(context)) {
download()
}
}
}
/** Fetch the waiting build into the cache, leaving it for [install]. */
private suspend fun download() {
state = state.copy(downloading = true, error = null)
state =
try {
core.downloadClientUpdate(AppUpdate.downloadTarget(context).absolutePath)
state.copy(downloading = false, ready = true)
} catch (e: Exception) {
state.copy(downloading = false, error = e.message ?: FALLBACK_DOWNLOAD)
}
}
/** Stop nagging for this sitting. The next trip to the foreground says it again. */
fun dismissNag() {
state = state.copy(nagDismissed = true)
}
/**
* Hand the update to the system installer, downloading first if it is not already
* in the cache.
*
* Still one action from the outside. A downloaded APK is not a state anyone wants
* to think about, so whether the fetch already happened in the background is this
* class's problem rather than the person's.
*/
fun downloadAndInstall() {
viewModelScope.launch {
state = state.copy(working = true, error = null)
UpdateOutcome.clear()
val failure =
try {
val target = AppUpdate.downloadTarget(context)
if (!state.ready) core.downloadClientUpdate(target.absolutePath)
// Off the main thread: this streams ~55 MiB into the session.
withContext(Dispatchers.IO) { AppUpdate.install(context, target) }
} catch (e: Exception) {
e.message ?: FALLBACK
}
// `working` stays TRUE on success: the install is still in flight, and
// on a silent update this process is about to be replaced. Clearing it
// here would flash "ready" a moment before the app disappears.
state =
if (failure == null) state else state.copy(working = false, error = failure)
}
}
/**
* Take whatever the system finally said about the install.
*
* Called from the composition, because the answer arrives at a BroadcastReceiver
* the system owns and there is no other way back into this class.
*/
fun consumeInstallOutcome(result: UpdateOutcome.Result) {
UpdateOutcome.clear()
state = state.copy(working = false, error = result.error)
}
fun dismissError() {
state = state.copy(error = null)
}
companion object {
fun factory(
core: ThoughtSync,
context: Context,
): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
UpdateViewModel(core, context.applicationContext) as T
}
}
}
private const val FALLBACK = "The update couldn't be checked."
private const val FALLBACK_DOWNLOAD = "The update couldn't be downloaded."
/**
* How long a background check stays good for.
*
* Long enough that switching to another app and back is not a re-check; short enough
* that a build published this morning is offered today. The same reasoning as sync's
* STALE_MINUTES, at a slower cadence — an app update is not urgent, it is just
* something that must not get lost.
*/
private const val CHECK_INTERVAL_MS = 6L * 60 * 60 * 1000
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The status-bar icon for a reminder.
A flat white silhouette on transparency, because that is the only thing Android
renders here — a status-bar icon is used as a MASK, so the launcher icon (which
is a full-colour adaptive asset) would come out as a solid white blob. This is
the Material bell, matching the icon the editor's reminder button already uses,
so the same idea wears the same shape in both places.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.89,2 2,2zM18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32V4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z" />
</vector>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Adaptive icon. minSdk is 26, so this is the ONLY icon Android will ask for —
no legacy raster fallback is needed.
The foreground is the shared maskable asset the web app already ships
(frontend/public/icon-maskable-512.png), which is drawn with the safe-zone
padding adaptive icons require. Reusing it means the phone, the web app and the
desktop all wear the same face rather than three near-misses.
-->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Adaptive icon. minSdk is 26, so this is the ONLY icon Android will ask for —
no legacy raster fallback is needed.
The foreground is the shared maskable asset the web app already ships
(frontend/public/icon-maskable-512.png), which is drawn with the safe-zone
padding adaptive icons require. Reusing it means the phone, the web app and the
desktop all wear the same face rather than three near-misses.
-->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- The product's brand colour, same value the web app's manifest and
<meta name="theme-color"> already use. One source of truth for "what
colour is ThoughtSync" across the three surfaces. -->
<color name="ic_launcher_background">#F5C518</color>
</resources>
+203
View File
@@ -0,0 +1,203 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">ThoughtSync</string>
<!-- Search bar -->
<string name="search_hint">Search your notes</string>
<string name="search_clear">Clear search</string>
<string name="nav_open">Open navigation</string>
<string name="nav_labels">Labels</string>
<!-- Compose sheet -->
<string name="compose_open">New note</string>
<!-- Board -->
<string name="board_empty_note">Empty note</string>
<!-- The long-press menu. Its ITEMS are the editor_* strings, deliberately: a
note has one vocabulary of things you can do to it, and a board that said
"Delete" where the editor says "Move to trash" would be describing two
different apps. Only the wrapper and the undo need words of their own. -->
<string name="board_note_actions">Note actions</string>
<string name="board_trashed">Moved to trash</string>
<string name="board_undo">Undo</string>
<!-- Empty states. Each destination says something true of ITSELF; a single
"nothing here" reads as encouragement on the board and as a fault in Trash. -->
<string name="board_empty_title">Nothing here yet</string>
<string name="board_empty_body">Tap + to start a note or a list. Everything stays on this device until you connect a server.</string>
<string name="empty_search_title">No matches</string>
<string name="empty_search_body">Nothing matched “%1$s”.</string>
<string name="empty_trash_title">Trash is empty</string>
<string name="empty_trash_body">Deleted notes wait here before they are removed for good.</string>
<string name="empty_archive_title">Nothing archived</string>
<string name="empty_archive_body">Archived notes leave the board but stay searchable.</string>
<string name="empty_reminders_title">No reminders</string>
<string name="empty_reminders_body">Notes with a reminder set will appear here.</string>
<!-- Editor -->
<string name="board_open_note">Open note</string>
<string name="editor_back">Back to notes</string>
<string name="editor_add_checklist">Add a checklist</string>
<string name="editor_body_hint">Take a note…</string>
<string name="editor_add_item">Add item</string>
<string name="editor_remove_item">Remove item</string>
<string name="editor_remove_label">Remove label</string>
<string name="editor_reminder">Set a reminder</string>
<string name="editor_more">More actions</string>
<string name="editor_saving">Saving…</string>
<string name="editor_unsaved">Not saved yet</string>
<string name="editor_edited">Edited %1$s</string>
<string name="editor_just_now">just now</string>
<string name="editor_done">Done</string>
<string name="editor_pin">Pin</string>
<string name="editor_unpin">Unpin</string>
<string name="editor_labels">Labels…</string>
<string name="editor_archive">Archive</string>
<string name="editor_unarchive">Unarchive</string>
<string name="editor_trash">Move to trash</string>
<string name="editor_restore">Restore</string>
<string name="editor_cancel">Cancel</string>
<!-- Deleting for good is the only thing in the app that cannot be undone, so
the copy says exactly that rather than asking "Are you sure?". -->
<string name="editor_delete_forever">Delete forever</string>
<string name="editor_delete_forever_title">Delete this note?</string>
<string name="editor_delete_forever_body">It will be removed from this device and from every device you sync with. This cannot be undone.</string>
<string name="editor_delete_forever_confirm">Delete</string>
<!-- Pickers -->
<string name="label_picker_title">Labels</string>
<string name="label_new_hint">Type a label and press enter</string>
<string name="label_from_tag">from #tag</string>
<string name="label_none_body">No labels yet. Type one above, or write a #tag in a note and it becomes one.</string>
<string name="picker_next">Next</string>
<string name="picker_set">Set</string>
<string name="picker_time_title">Pick a time</string>
<!-- Reminders -->
<string name="reminder_title">Remind me</string>
<string name="reminder_later_today">Later today</string>
<string name="reminder_tomorrow">Tomorrow</string>
<string name="reminder_next_week">Next week</string>
<string name="reminder_pick">Pick a date &amp; time</string>
<string name="reminder_clear">Remove reminder</string>
<string name="reminder_done">Done</string>
<string name="reminder_snooze_hour">Snooze 1h</string>
<string name="reminder_snooze_day">Snooze 1d</string>
<string name="recurrence_none">Once</string>
<string name="recurrence_daily">Daily</string>
<string name="recurrence_weekly">Weekly</string>
<string name="recurrence_monthly">Monthly</string>
<string name="recurrence_yearly">Yearly</string>
<!-- Store failure -->
<string name="store_unavailable_title">Your notes couldn\'t be opened</string>
<string name="store_unavailable_body">The note store on this device could not be read. Reinstalling will start a fresh one, but anything not synced to a server would be lost.</string>
<!-- Sync. Opt-in, and the copy has to carry that: being unlinked is the
normal resting state of a local-first app, not unfinished setup. -->
<string name="sync_title">Sync</string>
<string name="sync_badge_on">On</string>
<string name="sync_badge_unsent">Unsent</string>
<!-- Linked -->
<string name="sync_connected_to">Connected to</string>
<string name="sync_linked_as">as %1$s</string>
<string name="sync_last_synced">Last synced %1$s</string>
<string name="sync_never">never</string>
<string name="sync_unsent">This device has changes that haven\'t been sent yet.</string>
<string name="sync_now">Sync now</string>
<string name="sync_disconnect">Disconnect</string>
<string name="sync_disconnect_title">Stop syncing with this server?</string>
<string name="sync_disconnect_body">Your notes stay on this device, and the copy on the server is left alone. This device\'s access token is revoked, so it can\'t be used to reach the server again.</string>
<string name="reminder_channel">Reminders</string>
<string name="reminder_channel_description">Notifies you when a note\'s reminder is due.</string>
<string name="reminder_notifications_blocked_title">Reminders can\'t notify you</string>
<string name="reminder_notifications_blocked_body">Notifications are turned off for ThoughtSync, so reminders will only show here on the board.</string>
<string name="reminder_open_settings">Open settings</string>
<string name="reminder_inexact_title">Reminders may arrive late</string>
<string name="reminder_inexact_body">Without permission for exact alarms, Android delivers reminders when it next wakes the phone — usually within a few minutes, sometimes longer.</string>
<string name="reminder_allow_exact">Allow exact timing</string>
<string name="sync_automatic">Sync automatically</string>
<string name="sync_automatic_on">Checks about every 15 minutes, and whenever you open the app.</string>
<string name="sync_automatic_off">Only when you pull the board down or tap Sync now.</string>
<string name="update_installed_version">This app is build %1$d.</string>
<string name="update_available">Build %1$s is available (%2$d MB).</string>
<string name="update_current">You\'re on the newest build this server has.</string>
<string name="update_check">Check for an update</string>
<string name="update_install">Update</string>
<string name="update_banner_ready">Build %1$s is downloaded and ready.</string>
<string name="update_later">Later</string>
<string name="update_failed_title">The update didn\'t install</string>
<string name="update_permission_title">Android needs your permission</string>
<string name="update_permission_body">ThoughtSync has to be allowed to install apps before it can update itself. This is a one-time setting.</string>
<string name="update_permission_action">Allow installing</string>
<string name="update_needs_server">App updates come from a server you connect. Until then, install new builds yourself.</string>
<string name="sync_footer">Your notes live on this device either way — syncing just keeps a server copy in step, so your other devices can catch up.</string>
<string name="sync_failed_title">Sync failed</string>
<string name="sync_rejected_title">The server wouldn\'t accept some changes</string>
<plurals name="sync_rejected_body">
<item quantity="one">%1$d change was rejected: %2$s</item>
<item quantity="other">%1$d changes were rejected: %2$s</item>
</plurals>
<string name="sync_degraded_title">Some features aren\'t available here</string>
<string name="sync_degraded_body">This server doesn\'t support: %1$s. Everything else syncs normally.</string>
<!-- Unlinked -->
<string name="sync_offline_title">Working offline on this device</string>
<string name="sync_offline_body">Everything works without a server — your notes are stored on this phone. Connect a ThoughtSync server if you want them to reach your other devices.</string>
<string name="sync_address_label">Server address</string>
<string name="sync_address_hint">notes.example.com</string>
<string name="sync_address_help">Uses https unless you type http:// yourself.</string>
<string name="sync_check">Check</string>
<string name="sync_probe_failed">Couldn\'t reach that server</string>
<string name="sync_link_failed">Couldn\'t connect</string>
<string name="sync_server_generic">ThoughtSync server</string>
<string name="sync_server_version">v%1$s</string>
<string name="sync_compat_ok">Fully compatible.</string>
<string name="sync_compat_degraded">Compatible, but these features aren\'t available on this server: %1$s.</string>
<!-- Shown before any credential field, whenever the probed address is http://.
Android blocks cleartext by default and this app allows it so that a
self-hosted server on a LAN works at all; this is the other half of
that trade. -->
<string name="sync_insecure_title">This connection isn\'t encrypted</string>
<string name="sync_insecure_body">You\'re about to sign in over plain http. Anyone on the same network can read your password and your notes. Use https unless this is a server you control, on a network you trust.</string>
<string name="sync_signin">Sign in</string>
<string name="sync_mode_password">Email and password</string>
<string name="sync_mode_token">Device token</string>
<string name="sync_email">Email</string>
<string name="sync_password">Password</string>
<string name="sync_token">Paste a device token</string>
<string name="sync_token_help">Create one in the web app under Account → Linked devices.</string>
<string name="sync_device_name">Name for this device</string>
<string name="sync_device_name_help">Shown in your account\'s list of linked devices.</string>
<string name="sync_connect">Connect and sync</string>
<!-- An unlink whose server-side revoke didn\'t land leaves a live credential.
Never a transient message: someone disconnecting to retire a phone has to
still find this when they come back to check. -->
<string name="sync_revoke_title">This device\'s token is still valid on the server</string>
<string name="sync_revoke_unsupported">This server is older than in-app sign-out, so this device\'s token had to be left in place. Revoke it in the web app under Account → Linked devices.</string>
<string name="sync_revoke_failed">%1$s Until it\'s revoked, this device\'s token still works — you can revoke it in the web app under Account → Linked devices.</string>
<!-- What a sync did. Counts what MOVED; batches, pages and cursors are real
numbers that answer nobody\'s question. -->
<string name="sync_summary">Synced — %1$s.</string>
<string name="sync_summary_sent">sent %1$d</string>
<string name="sync_summary_received">received %1$d</string>
<string name="sync_summary_uptodate">Already up to date.</string>
<plurals name="sync_summary_attachments">
<item quantity="one">%d attachment</item>
<item quantity="other">%d attachments</item>
</plurals>
<plurals name="sync_summary_attachments_failed">
<item quantity="one">%d attachment didn\'t download — it\'ll retry on the next sync.</item>
<item quantity="other">%d attachments didn\'t download — they\'ll retry on the next sync.</item>
</plurals>
<!-- Errors -->
<string name="error_dismiss">Dismiss</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
A bare Material3 parent. The real palette is applied in Compose
(ui/Theme.kt) so light/dark follows the system without a second source of
truth in XML — the same reason the desktop reads its live theme rather
than hardcoding a window colour.
-->
<style name="Theme.ThoughtSync" parent="android:Theme.Material.NoActionBar" />
</resources>
@@ -0,0 +1,137 @@
package com.fabledsword.thoughtsync.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
/**
* Pins the derived-colour rule against `frontend/src/notes/colors.ts`.
*
* These are not tests of Kotlin — they are the ONE mechanical guard the mirrored pair
* has. The web side is TypeScript with no test runner (its CI lane is `vue-tsc
* --noEmit` and nothing else), so if these values drift, nothing on that surface will
* say so and a tag will simply be a different colour on the phone than in the browser.
* The same names and hashes are written into colors.ts as a comment; changing either
* side means changing both and re-checking here.
*
* SMALLER SINCE M315. Half of what this file used to pin — the generated card fill, and
* the resolution order that chose between a picked colour, a tag's and a generated one
* — went with the code it guarded when the card became one neutral. The four UUID
* hashes stay because they are what the hash ITSELF is pinned by; nothing derives a
* colour from an id any more, only from a tag's name.
*/
class DerivedTintTest {
@Test
fun `hashes match the fixture shared with the web`() {
// Kotlin's Int is signed, so the two hashes above 0x7FFFFFFF are written as
// their negative literal. The unsigned value in the comment is what colors.ts
// records and what an implementation of FNV-1a will actually produce.
assertEquals(-0x41B8712F, tintHash("00000000-0000-0000-0000-000000000000")) // 0xbe478ed1
assertEquals(0x3D75CC01, tintHash("11111111-1111-1111-1111-111111111111"))
assertEquals(-0x0EF71AD0, tintHash("6ba7b810-9dad-11d1-80b4-00c04fd430c8")) // 0xf108e530
assertEquals(0x5B651540, tintHash("f47ac10b-58cc-4372-a567-0e02b2c3d479"))
}
@Test
fun `colours match the fixture shared with the web`() {
assertEquals("purple", derivedTint("00000000-0000-0000-0000-000000000000"))
assertEquals("blue", derivedTint("11111111-1111-1111-1111-111111111111"))
assertEquals("orange", derivedTint("6ba7b810-9dad-11d1-80b4-00c04fd430c8"))
assertEquals("orange", derivedTint("f47ac10b-58cc-4372-a567-0e02b2c3d479"))
}
/** Half of all 32-bit hashes are negative as Kotlin Ints; a signed remainder would
* index out of the list for those. The bug this catches is a crash, not a wrong
* colour, so it is worth more than one name's worth of coverage. */
@Test
fun `every derived colour is a real palette key, over many names`() {
for (n in 0 until 2000) {
assertEquals(true, derivedTint("tag-$n") in DERIVED_TINT_KEYS)
}
}
@Test
fun `the derived palette excludes default`() {
assertEquals(false, "default" in DERIVED_TINT_KEYS)
assertEquals(9, DERIVED_TINT_KEYS.size)
}
/** The order IS the mapping — reordering silently recolours every tag on one
* surface only. Written out longhand so a reorder fails here loudly. */
@Test
fun `key order matches colors ts`() {
assertEquals(
listOf("red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray"),
DERIVED_TINT_KEYS,
)
}
/** A tag with no colour of its own derives one from its NAME, which is what makes
* every `#todo` chip the same colour rather than nine different ones. */
@Test
fun `a label with no colour derives one from its name`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
val todo = resolvedLabelColor("todo", "default", known)
assertEquals(derivedTint("todo"), todo)
assertNotEquals("default", todo)
}
/** Tags dedupe case-insensitively, so `#Todo` and `#todo` are one tag and must not
* be two colours. This is the whole reason the name is lowercased first. */
@Test
fun `label colour ignores case`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
assertEquals(
resolvedLabelColor("todo", "default", known),
resolvedLabelColor("ToDo", "default", known),
)
}
/** `teal` deliberately, NOT the colour "todo" derives to (pink) — asserting the
* derived value here would pass even with the explicit branch deleted. */
@Test
fun `an explicitly picked label colour still wins`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
assertNotEquals("teal", derivedTint("todo"))
assertEquals("teal", resolvedLabelColor("todo", "teal", known))
}
/** An unreadable key is not a choice — it is data from a server newer than this
* client, and the tag should still be drawn as something. */
@Test
fun `an unknown colour key falls back to the derived colour`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
assertEquals(derivedTint("todo"), resolvedLabelColor("todo", "chartreuse", known))
}
/** A label with no name at all has nothing to hash. Neutral, not a random hue. */
@Test
fun `a nameless label stays default`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
assertEquals("default", resolvedLabelColor("", "", known))
assertEquals("default", resolvedLabelColor("", "default", known))
}
/**
* The spread is real, and collisions are real too.
*
* Nine keys means two tags sharing a colour is not a bug and cannot be designed
* out — in this very sample `home`/`reading` are both gray and `work`/`ideas` are
* both green. Colour is a hint that two chips are distinct, never a claim that two
* of one colour are the same tag; the chip's TEXT is what says which tag it is.
*/
@Test
fun `different tag names spread across the palette`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
val names = listOf("todo", "grocery", "work", "home", "ideas", "reading", "urgent")
val colours = names.map { resolvedLabelColor(it, "default", known) }
assertEquals(true, colours.toSet().size >= 5)
}
/** The reason the feature exists: two tags on one board should not look identical. */
@Test
fun `the derived colour spreads across the palette`() {
val seen = (0 until 500).map { derivedTint("spread-$it") }.toSet()
assertEquals(DERIVED_TINT_KEYS.size, seen.size)
}
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "thoughtsync-uniffi-bindgen"
version = "0.1.0"
description = "Generates the Kotlin bindings for thoughtsync-ffi"
authors = ["bvandeusen"]
edition = "2021"
# A crate whose ONLY dependency is uniffi itself.
#
# This started life as a `[[bin]]` inside thoughtsync-ffi, which failed: building
# it compiled that crate and therefore the core, reqwest, native-tls and
# openssl-sys — for the HOST. The vendored-OpenSSL block in core/Cargo.toml is
# scoped to `cfg(target_os = "android")`, so a host build looks for a system
# OpenSSL that ci-rust-android has no reason to carry, and the generator died
# with "failed to run custom build command for openssl-sys".
#
# Adding libssl-dev to the image would have worked and been wrong: a code
# generator should not link the app's TLS stack to emit Kotlin. Splitting it out
# means the generator compiles ~15 small crates and nothing else.
#
# 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
# halves of one ABI, and a separate lockfile is exactly how they would drift.
[dependencies]
uniffi = { version = "0.32", features = ["cli"] }
+16
View File
@@ -0,0 +1,16 @@
//! The Kotlin generator.
//!
//! Invoked by Gradle (see android/app/build.gradle.kts) as:
//!
//! ```text
//! cargo run --locked -p thoughtsync-uniffi-bindgen -- \
//! generate --library <path/to/libthoughtsync_ffi.so> \
//! --language kotlin --out-dir <build/generated/uniffi>
//! ```
//!
//! `--library` mode reads uniffi's metadata straight out of the compiled artifact,
//! so the generated bindings can never describe a different version of the Rust
//! than the one being packaged.
fn main() {
uniffi::uniffi_bindgen_main()
}
+9
View File
@@ -0,0 +1,9 @@
plugins {
alias(libs.plugins.android.application) apply false
// 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
// BaseExtension. Same conclusion Minstrel reached on this toolchain pair.
alias(libs.plugins.compose.compiler) apply false
// ktlint/detekt are run from the CI image's pinned CLIs, not as Gradle
// plugins — see the note in gradle/libs.versions.toml.
}
+80
View File
@@ -0,0 +1,80 @@
# Per-rule overrides layered on top of detekt's defaults
# (`--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
# exit code instead.
naming:
# Composables conventionally use PascalCase function names. Matches every
# mainstream Compose codebase, and mirrors the ktlint exemption in
# android/.editorconfig — the two tools have to agree or one of them is always
# wrong.
FunctionNaming:
ignoreAnnotated:
- "Composable"
style:
MagicNumber:
ignoreAnnotated:
- "Composable"
# Colour literals and dp constants are declared as named properties, which is
# exactly the "define it as a well-named constant" the rule asks for — the
# number simply appears in the declaration itself. Flagging
# `private val Brand = Color(0xFFF5C518)` would demand a constant holding the
# constant.
ignorePropertyDeclaration: true
complexity:
# Compose breaks the PREMISE of both rules below, not just their thresholds.
#
# * LongParameterList assumes a long list means an over-general function. A
# composable's parameters ARE its UI contract — Material's own TextField
# takes twenty — and collapsing them into a parameter object makes the call
# site worse, not better, because named arguments are what keep a Compose
# tree readable.
# * LongMethod assumes length tracks branching. A composable's length tracks
# how many ELEMENTS are on the screen; a full-screen editor with a title, a
# body, a checklist, labels and a reminder row is long because it renders
# five things, and cutting it into five one-call wrappers would add
# indirection without removing a single decision.
#
# Scoped to @Composable rather than disabled: on ordinary functions both rules
# are right, and one of them still fires below (see BoardViewModel).
LongParameterList:
ignoreAnnotated:
- "Composable"
LongMethod:
ignoreAnnotated:
- "Composable"
exceptions:
TooGenericExceptionCaught:
# Catching broadly is DELIBERATE in these two places, and each site says so.
#
# * the ViewModel — a note that fails to save must become a visible error
# banner, never a crash. Narrowing this would mean an unanticipated
# failure takes the app down instead of being reported, which is strictly
# worse for the user.
# * the Application — the store failing to open is the one thing that must
# still let the app start, so it can explain itself.
# * the background Worker — it runs with nobody present, so an escaping
# exception is a crash report for a job the person never asked for. Every
# realistic failure there (no route, server down, token rotating) has the
# same right answer, which is Result.retry().
# * the reminder BroadcastReceiver — same argument, one step worse: it can be
# woken at 3am by an alarm or by BOOT_COMPLETED, and every path inside it
# has already logged its own failure by the time this catches anything.
# * the self-updater — the install path throws IOException from three
# different calls and SecurityException when the "install unknown apps"
# grant has been revoked since it was checked. All of them mean one thing
# to the person ("it did not install"), and none should take the app down
# while it is holding their notes.
#
# Scoped to those paths rather than disabled globally: elsewhere the rule is
# right and still applies.
excludes:
- "**/ui/**"
- "**/ThoughtSyncApplication.kt"
- "**/SyncWorker.kt"
- "**/ReminderReceiver.kt"
- "**/AppUpdate.kt"
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "thoughtsync-ffi"
version = "0.1.0"
description = "uniffi bindings exposing thoughtsync-core to the native Android client"
authors = ["bvandeusen"]
edition = "2021"
[lib]
# cdylib is the `.so` Android's System.loadLibrary opens. `lib` alongside it so the
# bindgen binary below — and this crate's own tests — can use the crate normally;
# a cdylib-only crate is unusable from Rust.
crate-type = ["cdylib", "lib"]
name = "thoughtsync_ffi"
[dependencies]
thoughtsync-core = { path = "../../core" }
serde_json = { workspace = true }
log = { workspace = true }
# tokio lets an exported `async fn` be driven by a tokio runtime, which the sync
# engine needs: it is reqwest all the way down.
uniffi = { version = "0.32", features = ["tokio"] }
# reqwest requires a reactor; uniffi's `async_runtime = "tokio"` needs one to exist.
# rt-multi-thread rather than current_thread: a sync cycle is network-bound and a
# Compose UI may have more than one call in flight.
tokio = { version = "1", features = ["rt-multi-thread"] }
# Display + Error impls for the error enum uniffi turns into a Kotlin exception.
thiserror = "2"
+904
View File
@@ -0,0 +1,904 @@
//! uniffi bindings: `thoughtsync-core` as seen from Kotlin.
//!
//! This crate is to Android what `desktop/src-tauri/src/commands/` is to the desktop
//! — a thin shim over the shared core, holding no logic of its own. If something here
//! starts making decisions about notes or sync, it belongs in the core where the
//! desktop gets it too (Scribe note 2730).
//!
//! ## Shape
//!
//! One `ThoughtSync` object holds the store and the blob directory, mirroring how
//! Tauri manages them as app state. Kotlin constructs it once, keeps it for the
//! process lifetime, and calls methods on it.
//!
//! ## Async
//!
//! The sync engine is reqwest all the way down, so it needs a reactor. Async methods
//! are exported with `async_runtime = "tokio"`, which uniffi turns into Kotlin
//! `suspend` functions driven by a tokio runtime on the Rust side.
//!
//! Cancellation works, and not by accident: when a coroutine is cancelled uniffi
//! drops the Rust future, and none of the core's async paths hold the store lock
//! across an `await` — a `std::sync::MutexGuard` isn't `Send`, so the compiler has
//! been enforcing that all along. A cancelled sync therefore leaves the store
//! consistent; it simply hasn't stamped `last_sync_at`, which is only written after
//! BOTH halves of a cycle succeed. The next cycle resumes from the stored cursor.
//!
//! ## A known consequence of the release profile
//!
//! The workspace sets `panic = "abort"` (Tauri's profile, for binary size). uniffi
//! would otherwise catch a panic crossing the FFI boundary and raise it in Kotlin as
//! an exception; with `abort` it takes the process down instead. That is the same
//! behaviour the desktop already has, so no surface is worse off than another — but
//! it is a deliberate cost, not an oversight. Revisit if a panic in the core ever
//! turns out to be recoverable enough that a phone should survive it.
pub mod models;
use std::path::PathBuf;
use std::sync::Arc;
use thoughtsync_core::local::{self, Db};
use thoughtsync_core::sync::blobs::BlobStore;
use thoughtsync_core::sync::{client, compat, engine, push, state};
use models::{
patch_from, BodyItem, BodyTag, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit,
NoteQuery, ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus,
};
uniffi::setup_scaffolding!();
/// Everything that can go wrong, as a Kotlin exception.
///
/// The core reports failures as plain `String`s today, so most of them land in
/// `Store` or `Network` by where they were raised rather than by a distinction the
/// core actually draws. `NotLinked` is the exception and earns its own variant: it
/// is the one failure that is a NORMAL state rather than a fault — an unlinked app is
/// working exactly as intended — and the UI's response is to offer linking, not to
/// show an error.
#[derive(Debug, thiserror::Error, uniffi::Error)]
// FLAT, so the Kotlin side gets the message on `Throwable` where it belongs.
//
// Without this, uniffi generates an exception subclass with a `message` PROPERTY
// per variant — which collides with `Throwable.message` and fails to compile:
// "'message' hides member of supertype 'Throwable' and needs an 'override'
// modifier". Renaming the field would dodge the collision but leave
// `e.message` null in Kotlin, so every call site would have to know the variant
// just to read the text.
//
// Flat keeps what actually matters: each variant is still its own Kotlin
// subclass, so `catch (e: CoreException.NotLinked)` still works and a `when` is
// still exhaustive. Only the FIELDS stop crossing, and the Display string —
// which is the field, for every variant that has one — comes through as the
// exception message.
#[uniffi(flat_error)]
pub enum CoreError {
/// No server is linked. Not a fault; the app is local-first and this is its
/// resting state.
#[error("this device isn't linked to a server")]
NotLinked,
/// The on-device store failed.
#[error("{message}")]
Store { message: String },
/// Talking to the server failed, or it refused.
#[error("{message}")]
Network { message: String },
}
impl CoreError {
fn store(e: impl std::fmt::Display) -> Self {
CoreError::Store {
message: e.to_string(),
}
}
fn network(e: impl std::fmt::Display) -> Self {
CoreError::Network {
message: e.to_string(),
}
}
}
/// The client handle: the on-device store plus the attachment directory beside it.
///
/// Held by Kotlin for the process lifetime. Both halves are `Send + Sync` — the store
/// behind its mutex, the blob store being a path — which is what lets uniffi share
/// one instance across coroutines.
#[derive(uniffi::Object)]
pub struct ThoughtSync {
db: Db,
blobs: BlobStore,
}
#[uniffi::export]
impl ThoughtSync {
/// Open (creating on first run) the store under `data_dir`, and the attachment
/// directory beside it.
///
/// `data_dir` comes from Kotlin because only Android knows where its app-private
/// storage is; the core must not guess at a platform path. The layout inside is
/// the core's business and matches the desktop's exactly — `thoughtsync.db` and
/// `blobs/` — so a store is readable by any client that opens it.
#[uniffi::constructor]
pub fn new(data_dir: String) -> Result<Arc<Self>, CoreError> {
let dir = PathBuf::from(data_dir);
std::fs::create_dir_all(&dir).map_err(CoreError::store)?;
let db = local::open(&dir.join("thoughtsync.db")).map_err(CoreError::store)?;
log::info!("local store ready — {}", local::summary(&db));
let blobs = BlobStore::new(dir.join("blobs")).map_err(CoreError::store)?;
Ok(Arc::new(ThoughtSync { db, blobs }))
}
/// A one-line count summary, for the boot log.
pub fn summary(&self) -> String {
local::summary(&self.db)
}
// ─────────────────────────────── notes ───────────────────────────────
pub fn list_notes(&self, query: NoteQuery) -> Result<Vec<Note>, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
let notes = local::store::list_notes(&conn, &query.into()).map_err(CoreError::store)?;
Ok(notes.into_iter().map(Note::from).collect())
}
pub fn get_note(&self, id: String) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::get_note(&conn, &id)
.map(Note::from)
.map_err(CoreError::store)
}
pub fn create_note(&self, draft: NoteDraft) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::create_note(&conn, &draft.into())
.map(Note::from)
.map_err(CoreError::store)
}
/// Apply a batch of field edits. See `NoteEdit` for why this is a list rather
/// than a struct of nullable fields.
pub fn update_note(&self, id: String, edits: Vec<NoteEdit>) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::update_note(&conn, &id, &patch_from(edits))
.map(Note::from)
.map_err(CoreError::store)
}
/// Full-text search across titles, bodies and checklist items.
///
/// The core owns the query — it searches the same columns the desktop and web
/// search, so "what matches" cannot drift between surfaces. Filtering the
/// board list in Kotlin would have been less code and a different product.
pub fn search_notes(&self, query: String) -> Result<Vec<Note>, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
let notes = local::store::search(&conn, &query).map_err(CoreError::store)?;
Ok(notes.into_iter().map(Note::from).collect())
}
/// Notes carrying a reminder, soonest first.
///
/// A dedicated call rather than a board `view`, because that is how the core
/// models it — `list_notes` only understands trashed/archived/default.
pub fn reminder_notes(&self) -> Result<Vec<Note>, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
let notes = local::store::reminders(&conn).map_err(CoreError::store)?;
Ok(notes.into_iter().map(Note::from).collect())
}
/// Every label with its note count, for the navigation drawer.
pub fn list_labels(&self) -> Result<Vec<Label>, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
let labels = local::store::list_labels(&conn).map_err(CoreError::store)?;
Ok(labels.into_iter().map(Label::from).collect())
}
pub fn trash_note(&self, id: String) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::trash(&conn, &id)
.map(Note::from)
.map_err(CoreError::store)
}
pub fn restore_note(&self, id: String) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::restore(&conn, &id)
.map(Note::from)
.map_err(CoreError::store)
}
/// Remove a note permanently.
///
/// Returns nothing, unlike every other mutation here: there is no note left to
/// return. The core also records a pending delete, so a linked device tells the
/// server rather than having the next pull resurrect the row.
pub fn delete_note_forever(&self, id: String) -> Result<(), CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::delete_forever(&conn, &id).map_err(CoreError::store)
}
// ──────────────────────────── checklist items ────────────────────────────
//
// Every one of these returns the whole reloaded note rather than the item it
// touched. That is the core's shape, and it is the right one for a UI: ticking
// a box changes `updated_at` and can change what the board shows, so handing
// back only the item would leave Kotlin to guess at the rest.
pub fn add_item(&self, note_id: String, text: String) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::add_item(&conn, &note_id, &text)
.map(Note::from)
.map_err(CoreError::store)
}
/// Retitle one item.
///
/// Split from `set_item_checked` rather than exposing the core's
/// `{text?, checked?}` patch, for the same reason `NoteEdit` exists: an
/// optional-field struct cannot say "leave this alone" in Kotlin without
/// colliding with "set it to null", and two unambiguous calls beat one
/// ambiguous one when each is three lines.
pub fn set_item_text(
&self,
note_id: String,
item_id: String,
text: String,
) -> Result<Note, CoreError> {
self.patch_item(&note_id, &item_id, serde_json::json!({ "text": text }))
}
pub fn set_item_checked(
&self,
note_id: String,
item_id: String,
checked: bool,
) -> Result<Note, CoreError> {
self.patch_item(
&note_id,
&item_id,
serde_json::json!({ "checked": checked }),
)
}
pub fn delete_item(&self, note_id: String, item_id: String) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::delete_item(&conn, &note_id, &item_id)
.map(Note::from)
.map_err(CoreError::store)
}
// ─────────────────────────────── reminders ───────────────────────────────
/// Clear the reminder, marking it dealt with.
///
/// Distinct from `NoteEdit::ClearRemindAt` even though today they do the same
/// thing: the core reserves this one for "the reminder fired and is finished",
/// which is where recurrence advancement lands when it is built. A UI that
/// called the generic clear instead would silently stop recurring reminders
/// from recurring the day that changes.
pub fn complete_reminder(&self, id: String) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::complete_reminder(&conn, &id)
.map(Note::from)
.map_err(CoreError::store)
}
/// Push the reminder out by `minutes` from now.
///
/// The core computes the new instant from its own clock rather than taking one
/// from the caller — so "in an hour" means the same thing on every surface,
/// and a phone with a skewed clock can't write a reminder the server reads as
/// already past.
pub fn snooze_reminder(&self, id: String, minutes: i64) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::snooze_reminder(&conn, &id, minutes)
.map(Note::from)
.map_err(CoreError::store)
}
// ───────────────────────────────── labels ────────────────────────────────
/// Replace the note's MANUAL labels.
///
/// `#tag` labels are owned by the body text and the core re-derives them on
/// every body edit, so they are deliberately untouched here. A picker that
/// sent the full visible set would strip a tag label the text still mandates —
/// and the next keystroke in the body would put it straight back, which is the
/// kind of fight a UI should never pick with its store.
pub fn set_note_labels(
&self,
note_id: String,
label_ids: Vec<String>,
) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::set_labels(&conn, &note_id, &label_ids)
.map(Note::from)
.map_err(CoreError::store)
}
/// Find or create a label by name, returning it either way.
///
/// Find-or-create rather than create: the core matches case-insensitively, so
/// typing "Errands" when "errands" exists has to attach the existing label
/// instead of minting a near-duplicate that then diverges on colour.
pub fn create_label(&self, name: String) -> Result<Label, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::create_label(&conn, &name)
.map(Label::from)
.map_err(CoreError::store)
}
// ─────────────────────────────── sync ────────────────────────────────
pub fn sync_status(&self) -> Result<SyncStatus, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
state::status(&conn)
.map(SyncStatus::from)
.map_err(CoreError::store)
}
/// Whether anything is waiting to be sent — so the UI can show an honest
/// "unsynced changes" state without running a sync to find out.
pub fn has_pending(&self) -> Result<bool, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
push::has_pending(&conn).map_err(CoreError::store)
}
}
/// Async methods, driven by a tokio runtime and surfaced to Kotlin as `suspend`
/// functions. Split into its own impl block so the runtime attribute — and the fact
/// that everything in here touches the network — is visible at a glance.
#[uniffi::export(async_runtime = "tokio")]
impl ThoughtSync {
/// Ask a server who it is, without committing to anything. Called as the user
/// finishes typing an address, so they see what answered before handing over
/// credentials.
pub async fn probe(&self, url: String) -> Result<ProbeResult, CoreError> {
client::probe(&url)
.await
.map(ProbeResult::from)
.map_err(CoreError::network)
}
/// Pair with a server using an email/password, minting a device token named for
/// this phone.
///
/// The handshake runs FIRST, and an incompatible server aborts before any
/// credential is sent — an incompatible server is exactly the case where a later
/// failure would be hardest to attribute.
pub async fn link_with_password(
&self,
url: String,
email: String,
password: String,
device_name: String,
) -> Result<Identity, CoreError> {
let probe = client::probe(&url).await.map_err(CoreError::network)?;
if let compat::Compatibility::Incompatible { reason, .. } = &probe.compatibility {
return Err(CoreError::Network {
message: reason.clone(),
});
}
let (token, identity) =
client::device_login(&probe.base_url, &email, &password, &device_name)
.await
.map_err(CoreError::network)?;
self.store_link(&probe.base_url, &token, probe.server.trash_retention_days)?;
Ok(identity.into())
}
/// Pair using a device token pasted from the web app — for anyone who would
/// rather not type a password into an app, or whose account is behind SSO.
///
/// The token is verified before it is stored, so a copy/paste slip fails here
/// rather than at the next sync.
pub async fn link_with_token(&self, url: String, token: String) -> Result<Identity, CoreError> {
let probe = client::probe(&url).await.map_err(CoreError::network)?;
if let compat::Compatibility::Incompatible { reason, .. } = &probe.compatibility {
return Err(CoreError::Network {
message: reason.clone(),
});
}
let identity = client::fetch_identity(&probe.base_url, &token)
.await
.map_err(CoreError::network)?;
self.store_link(&probe.base_url, &token, probe.server.trash_retention_days)?;
Ok(identity.into())
}
/// Stop syncing, and retire this device's token on the server.
///
/// The local half is unconditional. Someone unlinking because the phone is being
/// sold or handed on must not be held to it by a server that is offline or gone,
/// so the revoke is attempted first, its outcome returned for the UI to report
/// honestly, and the link cleared either way.
pub async fn unlink(&self) -> Result<RevokeOutcome, CoreError> {
// Read and release before the network call: a std MutexGuard isn't Send, so
// it cannot be held across an await, and holding the store through a
// round-trip would freeze every note operation in the UI.
let link = {
let conn = self.db.conn().map_err(CoreError::store)?;
let current = state::read(&conn).map_err(CoreError::store)?;
current.server_url.zip(current.device_token)
};
let revoked = match &link {
Some((base_url, token)) => client::revoke_self(base_url, token).await,
None => client::RevokeOutcome::Skipped,
};
let conn = self.db.conn().map_err(CoreError::store)?;
state::clear_link(&conn).map_err(CoreError::store)?;
log::info!("unlinked from server (server-side token: {revoked:?})");
Ok(revoked.into())
}
/// Run one full sync: push local changes, then pull the server's.
///
/// The only sync entry point, on purpose. Push and pull exist separately inside
/// the core, but offering a bare "pull" would let the UI overwrite unsent local
/// edits — the ordering isn't a suggestion, it's what keeps them.
/// The Android client the linked server is offering, if any.
///
/// `None` covers two different-looking situations that are one answer to the
/// app: this server has no client, or it has one and it is not newer than what
/// is already installed. Comparing here rather than in Kotlin keeps the rule —
/// version CODE decides, never the name — in the layer that also has to get it
/// right for the desktop.
pub async fn client_update(
&self,
installed_version_code: i64,
) -> Result<Option<ClientUpdate>, CoreError> {
let (base_url, token) = self.credentials()?;
let release = client::fetch_client_release(&base_url, &token)
.await
.map_err(CoreError::network)?;
Ok(release
.filter(|r| r.version_code > installed_version_code)
.map(ClientUpdate::from))
}
/// Download that client to `dest_path`, verified.
///
/// Takes the destination rather than choosing one: only Android knows a
/// directory its own package installer can read from, and the core has no
/// business guessing at platform paths — the same reason `ThoughtSync::new`
/// takes a data dir.
pub async fn download_client_update(&self, dest_path: String) -> Result<(), CoreError> {
let (base_url, token) = self.credentials()?;
let release = client::fetch_client_release(&base_url, &token)
.await
.map_err(CoreError::network)?
// Re-read rather than trusting what the caller was shown: the server
// may have published a new build between the check and the tap, and
// downloading against a stale digest would fail verification on bytes
// that are perfectly good.
.ok_or_else(|| {
CoreError::network("This server no longer has an Android client.".to_string())
})?;
client::download_client(
&base_url,
&token,
&release,
std::path::Path::new(&dest_path),
)
.await
.map_err(CoreError::network)
}
pub async fn sync_now(&self) -> Result<SyncOutcome, CoreError> {
let (base_url, token) = self.credentials()?;
engine::run_cycle(&self.db, &self.blobs, &base_url, &token)
.await
.map(SyncOutcome::from)
.map_err(CoreError::network)
}
}
// ── checklist text, as pure functions ───────────────────────────────────────
//
// The pair the block editor is built on: one to read a body apart, one to put a line
// back together. Between them, Kotlin can render a checklist as real checkboxes and
// write the markdown back without owning the grammar — which is the point. Three
// implementations of it is the price already being paid (Rust, Python, TypeScript);
// a fourth in Compose would be one more place for a checklist to change shape when
// it syncs.
//
// Free functions rather than methods, because they touch no database. The editor's
// body is LOCAL state — autosaved on an idle debounce, not written per keystroke —
// so editing a checklist there has to rewrite the text the editor is holding, not a
// row the store would hand back a moment later and overwrite the typing with.
/// One checklist item as the body line that stores it. For an editor that shows a
/// checkbox instead of the markup and has to write the markup back.
#[uniffi::export]
pub fn checklist_render(text: String, checked: bool) -> String {
local::derive::render_item(&text, checked)
}
/// Every checklist item in a body, with the line each one sits on — so a renderer
/// walking the body line by line knows which lines are boxes and what is in them.
#[uniffi::export]
pub fn checklist_items(body: String) -> Vec<BodyItem> {
local::derive::extract_items(&body)
.into_iter()
.map(BodyItem::from)
.collect()
}
/// Every `#tag` in a body, with the line and the UTF-16 span each one occupies — so
/// a card can colour the tag where it was typed instead of printing it twice.
///
/// The same argument as `checklist_items` above, and the same answer: the grammar for
/// what a `#tag` is already exists in Rust, Python and TypeScript. Matching it a
/// fourth time in Compose would be a fourth place for a tag to change shape when it
/// syncs — and this one would fail silently, as the wrong characters tinted.
#[uniffi::export]
pub fn body_tags(body: String) -> Vec<BodyTag> {
local::derive::extract_tag_spans(&body)
.into_iter()
.map(BodyTag::from)
.collect()
}
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
/// block names, so these stay Rust-side.
impl ThoughtSync {
/// Apply a `{text}` or `{checked}` patch to one checklist item.
///
/// The two public setters differ only in the key they write, and the lock +
/// convert + map-error dance around it is identical, so it lives once here.
fn patch_item(
&self,
note_id: &str,
item_id: &str,
changes: serde_json::Value,
) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::update_item(&conn, note_id, item_id, &changes)
.map(Note::from)
.map_err(CoreError::store)
}
/// The server URL + token, or the `NotLinked` state. Every networked call needs
/// exactly this, and none of them may hold the lock past it.
fn credentials(&self) -> Result<(String, String), CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
let current = state::read(&conn).map_err(CoreError::store)?;
match (current.server_url, current.device_token) {
(Some(url), Some(token)) => Ok((url, token)),
_ => Err(CoreError::NotLinked),
}
}
/// Persist a fresh link, adopting the server's retention window at the same time
/// so the Trash view stops counting down against this device's offline default
/// the moment it is no longer the policy in force.
fn store_link(
&self,
base_url: &str,
token: &str,
retention_days: Option<u32>,
) -> Result<(), CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
state::set_link(&conn, base_url, token).map_err(CoreError::store)?;
if let Some(days) = retention_days {
state::set_server_retention(&conn, days as i64).map_err(CoreError::store)?;
}
log::info!("linked to {base_url}");
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A scratch directory unique to this process and call.
///
/// Process id + a counter rather than a uuid dependency: the FFI crate has no
/// business pulling one in to name a temp folder, and this is the same approach
/// the desktop's updater tests settled on.
fn scratch_dir() -> String {
use std::sync::atomic::{AtomicU32, Ordering};
static NEXT: AtomicU32 = AtomicU32::new(0);
let dir = std::env::temp_dir().join(format!(
"thoughtsync-ffi-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
dir.to_string_lossy().into_owned()
}
fn draft(body: &str) -> NoteDraft {
NoteDraft {
body: body.to_string(),
items: None,
}
}
/// The round trip the Android skeleton has to make: open a store in a directory
/// that doesn't exist yet, write a note, read it back through the FFI types.
/// Proving it here means a failure on device is an Android problem, not a
/// binding problem.
#[test]
fn creates_a_store_and_round_trips_a_note() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let created = app
.create_note(draft("Groceries\nmilk"))
.expect("create should succeed");
assert_eq!(created.body, "Groceries\nmilk");
let fetched = app
.get_note(created.id.clone())
.expect("get should succeed");
assert_eq!(fetched.id, created.id);
// The NAME is the first line — there is no title field to have set (M13 step 3).
assert_eq!(fetched.display_title, "Groceries");
std::fs::remove_dir_all(&dir).ok();
}
/// Every note has to be nameable — that is what `display_title` is for, and the
/// Android board relies on it exactly as the desktop does.
#[test]
fn a_note_is_named_by_its_first_line() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let created = app
.create_note(draft("just a thought"))
.expect("create should succeed");
assert_eq!(created.display_title, "just a thought");
std::fs::remove_dir_all(&dir).ok();
}
/// The hole that made removing the title unsafe until checklists stopped being
/// their own kind of thing: a note with no body text still needs a name.
#[test]
fn a_note_with_only_items_is_named_by_its_first_item() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let created = app
.create_note(NoteDraft {
body: String::new(),
items: Some(vec!["milk".to_string(), "eggs".to_string()]),
})
.expect("create should succeed");
assert_eq!(created.display_title, "milk");
std::fs::remove_dir_all(&dir).ok();
}
/// An unlinked app is a normal, working app. Asking it to sync is the one
/// failure that isn't a fault, and it has to arrive as `NotLinked` so the UI can
/// offer linking rather than show an error.
#[test]
fn syncing_unlinked_reports_not_linked() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let status = app.sync_status().expect("status should read");
assert!(!status.linked);
assert_eq!(status.server_url, None);
assert!(matches!(app.credentials(), Err(CoreError::NotLinked)));
std::fs::remove_dir_all(&dir).ok();
}
/// The editor's whole checklist loop, in one pass: add a row, tick it, retitle
/// it, drop it. Each call returns the reloaded note, which is what the UI
/// splices back into the board rather than re-querying.
#[test]
fn checklist_items_can_be_added_ticked_retitled_and_removed() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app
.create_note(NoteDraft {
body: "Packing".to_string(),
items: Some(vec!["socks".to_string()]),
})
.expect("create");
assert_eq!(note.items.len(), 1);
let with_two = app
.add_item(note.id.clone(), "charger".to_string())
.expect("add");
assert_eq!(with_two.items.len(), 2);
// Appended, not prepended — a new row belongs at the bottom of the list the
// user is looking at.
assert_eq!(with_two.items[1].text, "charger");
let item_id = with_two.items[1].id.clone();
let ticked = app
.set_item_checked(note.id.clone(), item_id.clone(), true)
.expect("tick");
assert!(ticked.items[1].checked);
assert_eq!(
ticked.items[1].text, "charger",
"ticking a box must not disturb its text — both setters rewrite the \
same line of the body now, so one clobbering the other is a live risk \
rather than a theoretical one"
);
let renamed = app
.set_item_text(note.id.clone(), item_id.clone(), "usb-c cable".to_string())
.expect("rename");
assert_eq!(renamed.items[1].text, "usb-c cable");
assert!(
renamed.items[1].checked,
"and the same in the other direction"
);
let trimmed = app
.delete_item(note.id.clone(), item_id)
.expect("delete item");
assert_eq!(trimmed.items.len(), 1);
assert_eq!(trimmed.items[0].text, "socks");
std::fs::remove_dir_all(&dir).ok();
}
/// A `#tag` in the body owns its label. The picker replaces MANUAL labels only,
/// so sending an empty set must not strip one the text still mandates —
/// otherwise the next body edit would re-derive it and the UI would appear to
/// fight itself.
#[test]
fn setting_labels_leaves_tag_derived_ones_alone() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app
.create_note(draft("Trip\nbook the ferry #travel"))
.expect("create");
assert_eq!(
note.labels.len(),
1,
"the #tag should have attached a label"
);
assert!(note.labels[0].via_tag);
let errands = app
.create_label("errands".to_string())
.expect("create label");
let tagged = app
.set_note_labels(note.id.clone(), vec![errands.id.clone()])
.expect("set labels");
assert_eq!(tagged.labels.len(), 2);
let cleared = app
.set_note_labels(note.id.clone(), vec![])
.expect("clear manual labels");
assert_eq!(cleared.labels.len(), 1);
assert!(cleared.labels[0].via_tag);
// Find-or-create, not create: a second "Errands" must be the same label,
// or the picker mints near-duplicates that then diverge on colour.
let again = app
.create_label("Errands".to_string())
.expect("create label again");
assert_eq!(again.id, errands.id);
std::fs::remove_dir_all(&dir).ok();
}
/// Deleting forever has to actually remove the row, and the note must then be
/// unreadable rather than merely hidden.
#[test]
fn deleting_forever_removes_the_note() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app.create_note(draft("Ephemeral\nbody")).expect("create");
app.delete_note_forever(note.id.clone())
.expect("delete forever");
assert!(
app.get_note(note.id.clone()).is_err(),
"a permanently deleted note must not still load"
);
std::fs::remove_dir_all(&dir).ok();
}
/// Snooze writes a future instant from the CORE's clock; complete clears it.
#[test]
fn reminders_can_be_snoozed_and_completed() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app.create_note(draft("Call back")).expect("create");
assert_eq!(note.remind_at, None);
let snoozed = app.snooze_reminder(note.id.clone(), 60).expect("snooze");
let at = snoozed.remind_at.expect("snoozing must set a reminder");
let parsed = chrono_free_parse(&at);
assert!(
parsed > 0,
"the reminder must be a parseable RFC3339 instant, got {at:?}"
);
let done = app.complete_reminder(note.id.clone()).expect("complete");
assert_eq!(done.remind_at, None);
std::fs::remove_dir_all(&dir).ok();
}
/// The path the notification's Done button takes.
///
/// Completing a RECURRING reminder must move it, not end it — this is the
/// behaviour the web has had all along and the clients did not, which made
/// "Done" on a daily reminder quietly the last time it ever fired.
#[test]
fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app.create_note(draft("Water the plants")).expect("create");
let armed = app
.update_note(
note.id.clone(),
vec![
NoteEdit::RemindAt {
value: "2026-07-01T09:00:00.000Z".into(),
},
NoteEdit::Recurrence {
value: "daily".into(),
},
],
)
.expect("arm a daily reminder");
assert_eq!(armed.recurrence.as_deref(), Some("daily"));
let done = app.complete_reminder(note.id.clone()).expect("complete");
let next = done
.remind_at
.expect("a daily reminder must still have a next occurrence");
assert!(
next.as_str() > "2026-07-01T09:00:00.000Z",
"it must move FORWARD, got {next:?}"
);
assert!(
next.ends_with("T09:00:00.000Z"),
"the time of day is what was asked for and must survive, got {next:?}"
);
assert_eq!(
done.recurrence.as_deref(),
Some("daily"),
"the rule outlives the occurrence"
);
// A one-off clears BOTH fields, so an unrecognised rule cannot linger
// invisibly on a note with no reminder.
let once = app.create_note(draft("Post the letter")).expect("create");
app.update_note(
once.id.clone(),
vec![NoteEdit::RemindAt {
value: "2026-07-01T09:00:00.000Z".into(),
}],
)
.expect("arm a one-off");
let finished = app.complete_reminder(once.id.clone()).expect("complete");
assert_eq!(finished.remind_at, None);
assert_eq!(finished.recurrence, None);
std::fs::remove_dir_all(&dir).ok();
}
/// A crude RFC3339 sanity check that doesn't pull a date crate into this
/// crate's dev-dependencies to assert one field is well-formed.
fn chrono_free_parse(raw: &str) -> usize {
if raw.len() >= 20 && raw.as_bytes()[4] == b'-' && raw.contains('T') {
raw.len()
} else {
0
}
}
}
+759
View File
@@ -0,0 +1,759 @@
//! The types that cross into Kotlin.
//!
//! These MIRROR `thoughtsync_core::local::models` rather than reusing it. The core's
//! shapes are serde structs whose field names and optionality are contracted with the
//! shared Vue frontend; hanging uniffi derives on them would couple two very
//! different consumers to one definition and put a `serde_json::Value` (which has no
//! uniffi representation) in the middle of it.
//!
//! The cost of mirroring is drift — an Android client quietly missing a field the
//! desktop gained. Every conversion below therefore DESTRUCTURES the core struct
//! exhaustively instead of reading fields it cares about. Add a field to
//! `core::local::models::Note` and this file stops compiling until Android is told
//! what to do with it. That is the entire reason for the `let Core { .. } = value`
//! style here; please keep it.
use thoughtsync_core::local::models as core_models;
use thoughtsync_core::sync::client as core_client;
use thoughtsync_core::sync::compat as core_compat;
use thoughtsync_core::sync::engine as core_engine;
use thoughtsync_core::sync::pull as core_pull;
use thoughtsync_core::sync::push as core_push;
use thoughtsync_core::sync::state as core_state;
/// A note, with everything needed to render a card or open the editor.
///
/// Timestamps are RFC3339 strings, not a date type: that is what SQLite holds and
/// what the server speaks, and converting here would mean this layer picking a
/// calendar/timezone policy that belongs to the UI.
#[derive(Debug, Clone, uniffi::Record)]
pub struct Note {
pub id: String,
/// The note's NAME: its first non-blank body line, else its first checklist item.
/// Always present. Derived by the core, never stored.
pub display_title: String,
pub body: String,
pub position: i64,
pub pinned: bool,
pub archived: bool,
pub trashed: bool,
pub deleted_at: Option<String>,
pub remind_at: Option<String>,
pub recurrence: Option<String>,
pub labels: Vec<NoteLabel>,
pub items: Vec<ChecklistItem>,
pub attachments: Vec<Attachment>,
pub previews: Vec<LinkPreview>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
/// A checklist item as it sits in a note's body.
///
/// Mirrors `derive::DerivedItem`. Carries the LINE because every renderer that walks
/// a body line by line needs the text, the state and the position together — the card
/// to draw a box in the right place, the block editor to know where one block ends.
#[derive(Debug, Clone, uniffi::Record)]
pub struct BodyItem {
pub line: u32,
pub text: String,
pub checked: bool,
}
impl From<thoughtsync_core::local::derive::DerivedItem> for BodyItem {
fn from(i: thoughtsync_core::local::derive::DerivedItem) -> Self {
let thoughtsync_core::local::derive::DerivedItem {
text,
checked,
line,
} = i;
BodyItem {
line,
text,
checked,
}
}
}
/// One `#tag` and where it sits in a note's body.
///
/// Mirrors `derive::DerivedTag`. The card colours the tag where it was typed rather
/// than repeating it as a chip, so it needs the SPAN — and the offsets are UTF-16
/// code units precisely because Kotlin's `AnnotatedString` counts that way.
#[derive(Debug, Clone, uniffi::Record)]
pub struct BodyTag {
pub line: u32,
pub start: u32,
pub end: u32,
pub name: String,
}
impl From<thoughtsync_core::local::derive::DerivedTag> for BodyTag {
fn from(t: thoughtsync_core::local::derive::DerivedTag) -> Self {
let thoughtsync_core::local::derive::DerivedTag {
line,
start,
end,
name,
} = t;
BodyTag {
line,
start,
end,
name,
}
}
}
/// An Android build the linked server is offering, already judged to be newer.
///
/// A mirror rather than a re-export of `client::ClientRelease`, for the same
/// reason every other record here is one: the core's shapes are contracted with
/// other consumers, and `url` in particular is an implementation detail of how
/// the download is fetched — the app never needs it, because it asks the core to
/// do the downloading.
#[derive(Debug, Clone, uniffi::Record)]
pub struct ClientUpdate {
/// For people to read.
pub version: String,
/// For machines to compare.
pub version_code: i64,
pub size: i64,
}
impl From<thoughtsync_core::sync::client::ClientRelease> for ClientUpdate {
fn from(r: thoughtsync_core::sync::client::ClientRelease) -> Self {
// Destructured exhaustively, like every other conversion in this file: a
// field added upstream stops this compiling until Android is told what to
// do with it, which turns silent drift into a build error.
let thoughtsync_core::sync::client::ClientRelease {
version,
version_code,
size,
sha256: _,
url: _,
} = r;
ClientUpdate {
version,
version_code,
size,
}
}
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct NoteLabel {
pub id: String,
pub name: String,
pub color: String,
/// True when attached because of a `#tag` in the body, so the UI can show it is
/// owned by the text and not independently removable.
pub via_tag: bool,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct ChecklistItem {
pub id: String,
pub text: String,
pub checked: bool,
pub position: i64,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct Attachment {
pub id: String,
pub url: String,
pub filename: Option<String>,
pub mime: String,
pub size: Option<i64>,
pub sha256: Option<String>,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct LinkPreview {
pub id: String,
pub url: String,
pub title: Option<String>,
pub description: Option<String>,
pub image_url: Option<String>,
pub site_name: Option<String>,
}
impl From<core_models::Note> for Note {
fn from(value: core_models::Note) -> Self {
// Exhaustive on purpose — see the module header.
let core_models::Note {
id,
display_title,
body,
position,
pinned,
archived,
trashed,
deleted_at,
remind_at,
recurrence,
labels,
items,
attachments,
previews,
created_at,
updated_at,
} = value;
Note {
id,
display_title,
body,
position,
pinned,
archived,
trashed,
deleted_at,
remind_at,
recurrence,
labels: labels.into_iter().map(NoteLabel::from).collect(),
items: items.into_iter().map(ChecklistItem::from).collect(),
attachments: attachments.into_iter().map(Attachment::from).collect(),
previews: previews.into_iter().map(LinkPreview::from).collect(),
created_at,
updated_at,
}
}
}
impl From<core_models::NoteLabel> for NoteLabel {
fn from(value: core_models::NoteLabel) -> Self {
let core_models::NoteLabel {
id,
name,
color,
via_tag,
} = value;
NoteLabel {
id,
name,
color,
via_tag,
}
}
}
impl From<core_models::ChecklistItem> for ChecklistItem {
fn from(value: core_models::ChecklistItem) -> Self {
let core_models::ChecklistItem {
id,
text,
checked,
position,
} = value;
ChecklistItem {
id,
text,
checked,
position,
}
}
}
impl From<core_models::Attachment> for Attachment {
fn from(value: core_models::Attachment) -> Self {
let core_models::Attachment {
id,
url,
filename,
mime,
size,
sha256,
} = value;
Attachment {
id,
url,
filename,
mime,
size,
sha256,
}
}
}
impl From<core_models::LinkPreview> for LinkPreview {
fn from(value: core_models::LinkPreview) -> Self {
let core_models::LinkPreview {
id,
url,
title,
description,
image_url,
site_name,
} = value;
LinkPreview {
id,
url,
title,
description,
image_url,
site_name,
}
}
}
/// A label, as the sidebar lists them.
#[derive(Debug, Clone, uniffi::Record)]
pub struct Label {
pub id: String,
pub name: String,
/// Same colour vocabulary as notes, so one palette serves both.
pub color: String,
/// How many notes carry it. Only populated in listings — `None` elsewhere,
/// matching the REST single-label responses.
pub count: Option<i64>,
}
impl From<core_models::Label> for Label {
fn from(value: core_models::Label) -> Self {
let core_models::Label {
id,
name,
color,
count,
} = value;
Label {
id,
name,
color,
count,
}
}
}
// ───────────────────────────── queries and edits ─────────────────────────────
/// What the board is asking for. Mirrors the core's `ListQuery`.
#[derive(Debug, Clone, uniffi::Record)]
pub struct NoteQuery {
/// "notes" | "archive" | "trash" | "reminders" | "labels" — the core validates.
pub view: String,
pub label_id: Option<String>,
pub sort: Option<String>,
pub facets: Option<NoteFacets>,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct NoteFacets {
pub q: Option<String>,
pub label: Option<Vec<String>>,
pub has_reminder: Option<bool>,
pub has_attachment: Option<bool>,
pub created_after: Option<String>,
pub created_before: Option<String>,
}
impl From<NoteQuery> for core_models::ListQuery {
fn from(value: NoteQuery) -> Self {
let NoteQuery {
view,
label_id,
sort,
facets,
} = value;
core_models::ListQuery {
view,
label_id,
sort,
facets: facets.map(core_models::Facets::from),
}
}
}
impl From<NoteFacets> for core_models::Facets {
fn from(value: NoteFacets) -> Self {
let NoteFacets {
q,
label,
has_reminder,
has_attachment,
created_after,
created_before,
} = value;
core_models::Facets {
q,
label,
has_reminder,
has_attachment,
created_after,
created_before,
}
}
}
/// A new note.
#[derive(Debug, Clone, uniffi::Record)]
pub struct NoteDraft {
pub body: String,
/// Checklist lines. A note can carry both a body and items (M13 step 2), so this
/// is not an alternative to `body` — it is an addition to it.
pub items: Option<Vec<String>>,
}
impl From<NoteDraft> for core_models::NoteCreateInput {
fn from(value: NoteDraft) -> Self {
let NoteDraft { body, items } = value;
core_models::NoteCreateInput { body, items }
}
}
/// One field-level change to a note.
///
/// A LIST of these rather than a struct of optional fields, because the core's patch
/// semantics distinguish three states — leave alone, set to a value, and clear to
/// null — and Kotlin has no way to express the third with a nullable field.
/// `remindAt: null` in a data class is indistinguishable from `remindAt` unset, so
/// the editor could never clear a reminder. Explicit `Clear*` variants say it out
/// loud, and Kotlin gets a sealed class it can `when` over exhaustively.
#[derive(Debug, Clone, uniffi::Enum)]
pub enum NoteEdit {
Body { value: String },
Pinned { value: bool },
Archived { value: bool },
RemindAt { value: String },
ClearRemindAt,
Recurrence { value: String },
ClearRecurrence,
}
impl NoteEdit {
/// The (key, value) pair this edit contributes to the core's JSON patch.
///
/// The core reads a patch object where a present key means "change this" and a
/// null value means "clear it" — the shape the REST API and the Tauri commands
/// both already speak. Translating here keeps that one patch format in one
/// place instead of teaching a second dialect to the store.
fn entry(self) -> (&'static str, serde_json::Value) {
use serde_json::Value;
match self {
NoteEdit::Body { value } => ("body", Value::String(value)),
NoteEdit::Pinned { value } => ("pinned", Value::Bool(value)),
NoteEdit::Archived { value } => ("archived", Value::Bool(value)),
NoteEdit::RemindAt { value } => ("remind_at", Value::String(value)),
NoteEdit::ClearRemindAt => ("remind_at", Value::Null),
NoteEdit::Recurrence { value } => ("recurrence", Value::String(value)),
NoteEdit::ClearRecurrence => ("recurrence", Value::Null),
}
}
}
/// Fold a list of edits into the single patch object the store applies.
///
/// Later edits win on a repeated key, which is what a caller batching "set a
/// reminder, then clear it" would expect.
pub fn patch_from(edits: Vec<NoteEdit>) -> serde_json::Value {
let mut map = serde_json::Map::new();
for edit in edits {
let (key, value) = edit.entry();
map.insert(key.to_string(), value);
}
serde_json::Value::Object(map)
}
// ───────────────────────────────── sync ─────────────────────────────────
/// What the UI may know about the link. Carries no device token, deliberately —
/// the core withholds it from `Status` for the same reason, and a bearer token has
/// no business in UI state.
#[derive(Debug, Clone, uniffi::Record)]
pub struct SyncStatus {
pub linked: bool,
pub server_url: Option<String>,
pub last_cursor: i64,
pub last_sync_at: Option<String>,
}
impl From<core_state::Status> for SyncStatus {
fn from(value: core_state::Status) -> Self {
let core_state::Status {
linked,
server_url,
last_cursor,
last_sync_at,
} = value;
SyncStatus {
linked,
server_url,
last_cursor,
last_sync_at,
}
}
}
/// What a server said about itself, before committing to anything.
#[derive(Debug, Clone, uniffi::Record)]
pub struct ProbeResult {
/// Normalised by the core — this, not what the user typed, is what gets stored.
pub base_url: String,
pub site_name: Option<String>,
pub version: Option<String>,
pub trash_retention_days: Option<u32>,
pub compatibility: Compatibility,
}
/// Whether this client and that server can sync at all.
#[derive(Debug, Clone, uniffi::Enum)]
pub enum Compatibility {
Ok,
/// Safe to sync, but these named capabilities are missing. The UI should say so
/// rather than let a feature silently do nothing.
Degraded {
unavailable: Vec<String>,
},
/// Do not sync. `client_must_update` says which side can fix it, so the message
/// can be actionable.
Incompatible {
reason: String,
client_must_update: bool,
},
}
impl From<core_compat::Compatibility> for Compatibility {
fn from(value: core_compat::Compatibility) -> Self {
match value {
core_compat::Compatibility::Ok => Compatibility::Ok,
core_compat::Compatibility::Degraded { unavailable } => {
Compatibility::Degraded { unavailable }
}
core_compat::Compatibility::Incompatible {
reason,
client_must_update,
} => Compatibility::Incompatible {
reason,
client_must_update,
},
}
}
}
impl From<core_client::ProbeResult> for ProbeResult {
fn from(value: core_client::ProbeResult) -> Self {
let core_client::ProbeResult {
base_url,
server,
compatibility,
} = value;
let core_compat::ServerInfo {
site_name,
version,
// Protocol numbers are the raw material of the compatibility verdict,
// which is already carried above in a form the UI can act on. Sending
// them too would invite a second, worse judgement being made in Kotlin.
sync_protocol_version: _,
min_client_protocol_version: _,
sync_features: _,
trash_retention_days,
} = server;
ProbeResult {
base_url,
site_name,
version,
trash_retention_days,
compatibility: compatibility.into(),
}
}
}
/// Who the server thinks this device belongs to.
#[derive(Debug, Clone, uniffi::Record)]
pub struct Identity {
pub id: String,
pub email: String,
pub display_name: String,
}
impl From<core_client::Identity> for Identity {
fn from(value: core_client::Identity) -> Self {
let core_client::Identity {
id,
email,
display_name,
} = value;
Identity {
id,
email,
display_name,
}
}
}
/// What became of this device's token on the server during an unlink.
///
/// Separate from the local result because the local half always succeeds and the
/// remote half may not — someone unlinking a machine they are selling deserves to be
/// told plainly that the token is still live.
#[derive(Debug, Clone, uniffi::Enum)]
pub enum RevokeOutcome {
Revoked,
/// This server predates the self-revoke route. Only the web app can retire it.
Unsupported,
Failed {
reason: String,
},
/// Nothing to revoke; the app wasn't linked.
Skipped,
}
impl From<core_client::RevokeOutcome> for RevokeOutcome {
fn from(value: core_client::RevokeOutcome) -> Self {
match value {
core_client::RevokeOutcome::Revoked => RevokeOutcome::Revoked,
core_client::RevokeOutcome::Unsupported => RevokeOutcome::Unsupported,
core_client::RevokeOutcome::Failed { reason } => RevokeOutcome::Failed { reason },
core_client::RevokeOutcome::Skipped => RevokeOutcome::Skipped,
}
}
}
/// The result of one full push-then-pull cycle.
#[derive(Debug, Clone, uniffi::Record)]
pub struct SyncOutcome {
pub push: PushSummary,
pub pull: PullSummary,
/// The state after the cycle, so the UI refreshes from one call rather than
/// following every sync with a status query.
pub status: SyncStatus,
}
/// Counts are `u64` because the core uses `usize`, which has no uniffi
/// representation. Widening is lossless on every target we build for; narrowing to
/// u32 would be a silent truncation waiting for a very large sync.
#[derive(Debug, Clone, uniffi::Record)]
pub struct PushSummary {
pub batches: u64,
pub sent: u64,
pub created: u64,
pub applied: u64,
/// The server had a newer edit and kept it. Not a failure — the local row stops
/// being dirty and the following pull adopts the server's version.
pub kept: u64,
pub noop: u64,
/// Still dirty, and surfaced: these need a human (a duplicate label name is the
/// realistic case). Silently retrying forever would be the wrong shape.
pub rejected: u64,
pub errors: Vec<String>,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct PullSummary {
pub pages: u64,
pub notes_applied: u64,
pub notes_deleted: u64,
pub labels_applied: u64,
pub labels_deleted: u64,
pub cursor: i64,
/// Rows that still held unpushed local edits when the server's version landed on
/// top. Should be 0 in a normal cycle, because push runs first; anything higher
/// means local work was overwritten, which is worth saying out loud.
pub clobbered_dirty: u64,
pub blobs_downloaded: u64,
/// Attachments whose bytes couldn't be fetched or failed verification. Counted
/// rather than fatal.
pub blobs_failed: u64,
}
impl From<core_push::PushSummary> for PushSummary {
fn from(value: core_push::PushSummary) -> Self {
let core_push::PushSummary {
batches,
sent,
created,
applied,
kept,
noop,
rejected,
errors,
} = value;
PushSummary {
batches: batches as u64,
sent: sent as u64,
created: created as u64,
applied: applied as u64,
kept: kept as u64,
noop: noop as u64,
rejected: rejected as u64,
errors,
}
}
}
impl From<core_pull::PullSummary> for PullSummary {
fn from(value: core_pull::PullSummary) -> Self {
let core_pull::PullSummary {
pages,
notes_applied,
notes_deleted,
labels_applied,
labels_deleted,
cursor,
clobbered_dirty,
blobs_downloaded,
blobs_failed,
} = value;
PullSummary {
pages: pages as u64,
notes_applied: notes_applied as u64,
notes_deleted: notes_deleted as u64,
labels_applied: labels_applied as u64,
labels_deleted: labels_deleted as u64,
cursor,
clobbered_dirty: clobbered_dirty as u64,
blobs_downloaded: blobs_downloaded as u64,
blobs_failed: blobs_failed as u64,
}
}
}
impl From<core_engine::SyncOutcome> for SyncOutcome {
fn from(value: core_engine::SyncOutcome) -> Self {
let core_engine::SyncOutcome { push, pull, status } = value;
SyncOutcome {
push: push.into(),
pull: pull.into(),
status: status.into(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_set_and_a_clear_are_different_patch_entries() {
let set = patch_from(vec![NoteEdit::RemindAt {
value: "2026-01-01T00:00:00Z".to_string(),
}]);
assert_eq!(set["remind_at"], serde_json::json!("2026-01-01T00:00:00Z"));
let cleared = patch_from(vec![NoteEdit::ClearRemindAt]);
assert!(
cleared["remind_at"].is_null(),
"a clear must reach the store as JSON null — an absent key means \
'leave alone', which is a different instruction"
);
}
#[test]
fn an_empty_edit_list_is_an_empty_patch() {
// Not merely tidy: the core rejects a non-object patch, and a UI that
// batches edits may well end up sending none.
assert_eq!(patch_from(vec![]), serde_json::json!({}));
}
#[test]
fn later_edits_win_on_a_repeated_field() {
let patch = patch_from(vec![
NoteEdit::RemindAt {
value: "2026-01-01T00:00:00Z".to_string(),
},
NoteEdit::ClearRemindAt,
]);
assert!(patch["remind_at"].is_null());
}
}
+5
View File
@@ -0,0 +1,5 @@
# Where the generated Kotlin lands. Matches the app's package so the bindings are
# `com.fabledsword.thoughtsync.core.*` rather than something the app has to alias.
[bindings.kotlin]
package_name = "com.fabledsword.thoughtsync.core"
cdylib_name = "thoughtsync_ffi"
+16
View File
@@ -0,0 +1,16 @@
# --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
# native-platform jar trips via System.load(). Same opt-in Minstrel needs on the
# same Gradle/JDK pair; future JDKs promote the warning to an error.
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 --enable-native-access=ALL-UNNAMED
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
android.useAndroidX=true
android.nonTransitiveRClass=true
# Matches Minstrel: detekt 2.0-alpha and the ktlint Gradle plugin still have
# intermittent configuration-cache holes. Warn rather than fail so the CC speedup
# applies where it can.
org.gradle.configuration-cache.problems=warn
kotlin.code.style=official
+57
View File
@@ -0,0 +1,57 @@
[versions]
# Pinned as a MATRIX, matching Minstrel's proven combination on the same JDK:
# - Gradle 9.1.0 supports JDK 25 (see gradle-wrapper.properties)
# - AGP 9.0.1 requires Gradle 9.1.0+
# - Kotlin 2.3.x is AGP 9's built-in Kotlin path
# ci-rust-android ships JDK 25, so the wrapper floor is load-bearing: an older
# Gradle fails on that JDK with an opaque "25.0.3" message.
agp = "9.0.1"
kotlin = "2.3.21"
compose-bom = "2026.05.01"
lifecycle = "2.8.7"
activity-compose = "1.9.3"
coroutines = "1.9.0"
# 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`,
# `CoroutineWorker`) has moved into `work-runtime` itself. Verified by unpacking
# both artifacts, not from memory.
work = "2.11.2"
# ktlint and detekt are NOT Gradle plugins here. ci-rust-android already ships
# both as pinned CLIs (M12 step 3), and the CI lane invokes those directly. Adding
# the Gradle plugins would mean a SECOND pinned version of each tool, resolved at
# build time, that has to be kept in lockstep with the image's by hand — and the
# 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.
# The @aar classifier matters — the plain jar has no Android native payload and
# fails at runtime with UnsatisfiedLinkError rather than at build time.
jna = "5.14.0"
junit = "4.13.2"
[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.13.1" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
compose-ui = { module = "androidx.compose.ui:ui" }
compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-material3 = { module = "androidx.compose.material3:material3" }
# Icons only from -core, deliberately: it carries the common set (Menu, Search,
# Close, Add) and is already on the material3 path. -extended adds ~1,000 vectors
# for the handful the drawer would use.
compose-material-icons-core = { module = "androidx.compose.material:material-icons-core" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
androidx-work-runtime = { module = "androidx.work:work-runtime", version.ref = "work" }
junit = { module = "junit:junit", version.ref = "junit" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn ( ) {
echo "$*"
}
die ( ) {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
esac
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
function splitJvmOpts() {
JVM_OPTS=("$@")
}
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
+90
View File
@@ -0,0 +1,90 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windowz variants
if not "%OS%" == "Windows_NT" goto win9xME_args
if "%@eval[2+2]" == "4" goto 4NT_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
goto execute
:4NT_args
@rem Get arguments from the 4NT Shell from JP Software
set CMD_LINE_ARGS=%$
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "ThoughtSync"
// `ffi/` sits beside `app/` but is deliberately NOT a Gradle module: it is a Rust
// crate belonging to the Cargo workspace at the repo root. Gradle reaches it by
// invoking cargo-ndk (see app/build.gradle.kts), not by building it.
include(":app")
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Check every R.string / R.plurals reference against strings.xml.
Three ways a resource reference compiles and then fails, none of which ktlint,
detekt or the Kotlin compiler will catch:
1. the name does not exist -> resource-not-found at runtime
2. `stringResource` on a plural (or the reverse) -> wrong overload, wrong text
3. the format string takes more arguments than the call passes -> the format
silently renders `%2$s` as literal text, or throws
python3 android/tools/check-strings.py
Exits non-zero on any problem.
"""
import glob
import os
import re
import sys
import xml.etree.ElementTree as ET
BASE = (
sys.argv[1]
if len(sys.argv) > 1
else os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
)
STRINGS = os.path.join(BASE, "app", "src", "main", "res", "values", "strings.xml")
SOURCES = os.path.join(BASE, "app", "src", "main", "java", "**", "*.kt")
CALL = re.compile(
r"(stringResource|pluralStringResource)\(\s*R\.(string|plurals)\.(\w+)"
r"((?:[^()]|\([^()]*\))*)\)"
)
def arity(text):
"""How many distinct arguments a format string consumes."""
numbered = set(re.findall(r"%(\d)\$", text))
return len(numbered) if numbered else len(re.findall(r"%[sd]", text))
def supplied_args(rest):
"""Count top-level commas in an argument tail.
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
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
another argument.
"""
rest = rest.rstrip()
if rest.endswith(","):
rest = rest[:-1]
depth = 0
count = 0
for ch in rest:
if ch in "([{":
depth += 1
elif ch in ")]}":
depth -= 1
elif ch == "," and depth == 0:
count += 1
return count
def main():
root = ET.parse(STRINGS).getroot()
strings = {e.get("name"): "".join(e.itertext()) for e in root.findall("string")}
plurals = {
e.get("name"): max(
(arity("".join(i.itertext())) for i in e.findall("item")), default=0
)
for e in root.findall("plurals")
}
problems = 0
for path in glob.glob(SOURCES, recursive=True):
with open(path, encoding="utf-8") as fh:
src = fh.read()
for match in CALL.finditer(src):
fn, kind, name, rest = match.groups()
line = src[: match.start()].count("\n") + 1
where = f"{os.path.basename(path)}:{line} {name}"
if kind == "string" and name not in strings:
print(f"MISSING {where}: no such string")
problems += 1
continue
if kind == "plurals" and name not in plurals:
print(f"MISSING {where}: no such plural")
problems += 1
continue
if (fn == "pluralStringResource") != (kind == "plurals"):
print(f"KIND {where}: {fn} used on R.{kind}")
problems += 1
continue
passed = supplied_args(rest)
# A plural call passes the count first, then the format arguments.
wanted = arity(strings[name]) if kind == "string" else plurals[name] + 1
if passed != wanted:
print(f"ARITY {where}: wants {wanted}, call passes {passed}")
problems += 1
print(f"\n{len(strings)} strings, {len(plurals)} plurals, {problems} problems")
return 1 if problems else 0
if __name__ == "__main__":
sys.exit(main())
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Flag capitalised identifiers that are neither imported nor declared locally.
This exists because ktlint and detekt are both structurally blind to it: they
parse Kotlin without resolving symbols, so a *missing import* is invisible to
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
one; this is a cheap pre-push filter for the single mistake that survives every
other local gate. It errs toward false positives — anything it cannot account
for is reported rather than assumed fine.
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
function between two objects and forgetting to paste it into the second: the
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
outside this package, members reached through a variable rather than a type
name, and every question about types. Those are what `compileDebugKotlin` is for.
python3 android/tools/check-symbols.py [source-root]
Exits non-zero when something is unaccounted for.
"""
import collections
import os
import re
import sys
DEFAULT_ROOT = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "app", "src", "main", "java"
)
# Available without an import: kotlin.* and kotlin.collections.*, plus
# java.lang.* which Kotlin/JVM also imports by default.
IMPLICIT = set(
"""
String Int Long Short Byte Boolean Char Float Double Unit Any Nothing Number
UInt ULong UShort UByte Array List Set Map MutableList MutableSet MutableMap
Collection Iterable Iterator Sequence Pair Triple Comparable Comparator
Throwable Exception RuntimeException IllegalArgumentException IllegalStateException
Error Result Regex StringBuilder CharSequence Enum Annotation Function
Deprecated Suppress OptIn JvmStatic JvmField JvmName JvmOverloads Volatile
Synchronized Throws Target Retention Repeatable MustBeDocumented
System Math Object Class Thread Runnable Void Integer Character
StringBuffer
""".split()
)
# `R` is generated at build time and never imported from the app's own package.
GENERATED = {"R"}
DECL = re.compile(
r"^\s*(?:@\w+\s+)*(?:public |private |internal |protected )?"
r"(?:expect |actual |external |abstract |final |open |sealed |data |value |"
r"inline |enum |annotation |fun |companion |const |lateinit )*"
r"(?:class|interface|object|typealias|fun|val|var)\s+"
r"(?:<[^>]*>\s*)?([A-Za-z_]\w*)",
re.M,
)
def strip(src: str) -> str:
"""Blank out comments and string literals.
Order matters: raw strings before block comments, and line comments must NOT
use DOTALL — `//.*` with re.S eats from the first comment to end of file,
which silently empties the input and makes the whole check pass vacuously.
"""
src = re.sub(r'"""(?:.|\n)*?"""', '""', src)
src = re.sub(r"/\*(?:.|\n)*?\*/", " ", src)
src = re.sub(r"//[^\n]*", " ", src)
src = re.sub(r'"(?:\\.|[^"\\\n])*"', '""', src)
return src
def object_members(src: str) -> dict:
"""Map each `object Foo` declared here to the names declared directly in it.
Brace-counted rather than regex-matched: an object body contains nested
braces (lambdas, apply blocks, companions) and no regex closes correctly over
them. Only top-level members count — anything nested deeper is not reachable
as `Foo.member` anyway.
"""
members = {}
for match in re.finditer(r"^(?:internal |private )?object (\w+)\s*\{", src, re.M):
name = match.group(1)
depth = 0
body_start = match.end() - 1
for i in range(body_start, len(src)):
if src[i] == "{":
depth += 1
elif src[i] == "}":
depth -= 1
if depth == 0:
break
body = src[body_start + 1 : i]
own = set()
depth = 0
for line in body.splitlines():
if depth == 0:
# Nested TYPES count as members too: `Foo.Bar` where Bar is a
# data class inside object Foo is an ordinary reference, and
# leaving them out made the checker report four false positives
# the first time an object held one.
decl = re.match(
r"\s*(?:@\w+\s+)*(?:public |private |internal |protected )?"
r"(?:const |lateinit |inline |suspend |data |sealed |enum |value |abstract |open )*"
r"(?:fun|val|var|class|object|interface)\s+"
r"(?:<[^>]*>\s*)?(\w+)",
line,
)
if decl:
own.add(decl.group(1))
depth += line.count("{") - line.count("}")
members[name] = own
return members
def main() -> int:
root = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_ROOT
files = [
os.path.join(d, n)
for d, _, names in os.walk(root)
for n in names
if n.endswith(".kt")
]
declared = collections.defaultdict(set)
objects = {}
parsed = {}
for path in files:
with open(path, encoding="utf-8") as fh:
raw = fh.read()
package = re.search(r"^package\s+([\w.]+)", raw, re.M).group(1)
src = strip(raw)
parsed[path] = (package, src, raw)
objects.update(object_members(src))
for match in DECL.finditer(src):
declared[package].add(match.group(1))
# Enum entries are declarations too; DECL only sees the class itself.
for match in re.finditer(r"enum class \w+[^{]*\{([^};]*)", src):
for entry in match.group(1).split(","):
name = entry.strip().split("(")[0].strip()
if re.fullmatch(r"[A-Z]\w*", name):
declared[package].add(name)
problems = 0
for path in sorted(files):
package, src, raw = parsed[path]
imported = set()
for match in re.finditer(r"^import\s+([\w.]+)(?:\s+as\s+(\w+))?", raw, re.M):
imported.add(match.group(2) or match.group(1).split(".")[-1])
# Type parameters are declared inline at their use site.
type_params = set()
for match in re.finditer(r"(?:fun|class|interface)\s*<([^>]*)>", src):
type_params |= set(
re.findall(r"\b([A-Z]\w*)\b(?=\s*(?::|,|$))", match.group(1))
)
known = imported | declared[package] | IMPLICIT | GENERATED | type_params
# Capitalised tokens NOT preceded by a dot: `Icons.Filled` resolves
# through `Icons`, so only the leading segment needs to be accounted for.
for match in re.finditer(r"(?<![\w.])@?([A-Z][A-Za-z0-9_]*)\b", src):
name = match.group(1)
if name in known:
continue
line = src[: match.start()].count("\n") + 1
print(f"{os.path.relpath(path, root)}:{line}: unresolved '{name}'")
problems += 1
# Members of objects declared in this package.
for match in re.finditer(r"(?<![\w.])([A-Z][A-Za-z0-9_]*)\.(\w+)", src):
owner, member = match.group(1), match.group(2)
if owner not in objects or member in objects[owner]:
continue
line = src[: match.start()].count("\n") + 1
print(
f"{os.path.relpath(path, root)}:{line}: "
f"'{owner}' has no member '{member}'"
)
problems += 1
print(f"\n{len(files)} files, {problems} unresolved")
return 1 if problems else 0
if __name__ == "__main__":
sys.exit(main())
+376 -4
View File
@@ -23,7 +23,7 @@ build (docker buildx).
- ruff — lint job runs `ruff check src/` with zero install overhead
- uv — test job creates the venv (`uv venv /opt/venv`) and installs the package
with dev deps
- docker CLI + buildx — build job pushes the dev/release image to the Forgejo
- docker CLI + buildx — build job pushes the dev/release image to the Fabled-Git
registry
## Per-job tool installs
@@ -45,6 +45,70 @@ entirely on `ci-python:3.14`.
(family rule 46).
- The production runtime `Dockerfile` tracks python:3.12 so test results stay
representative of the deployed image.
- **Artifacts — use the mirrored upload action, never `actions/upload-artifact`.**
```yaml
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
```
Upstream's `actions/upload-artifact@v4` cannot work against this instance and
no server-side change will help: its `isGhes()` rejects any hostname that isn't
`github.com` / `*.ghe.com` / `*.localhost` and throws before it opens a
connection, so the server is never asked what it supports. `@v3` is worse — it
reports success, and Gitea then serves artifacts back only through the v4 API
(`content_encoding = application/zip`), so a v3 upload is stored but invisible
to every retrieval path. A green job producing nothing retrievable.
`bvandeusen/upload-artifact` is our pull mirror of `forgejo/upload-artifact`
(the Forgejo project's fork, one commit on upstream v5.0.0 disabling that
check). Mirrored so CI depends on a commit we hold; pinned by SHA because the
mirror auto-syncs and a moved upstream tag would otherwise change what runs.
Both desktop upload steps also set `if-no-files-found: error` and carry **no**
`continue-on-error`. They previously had both defaults inverted, which is how
110 unreachable artifacts accumulated on this repo without anyone noticing —
the upload could fail or match nothing and the run still went green. Scribe
issues 2255 / 2270 have the full teardown.
Download: `GET /api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts`
for the id (global run id, not the repo-scoped run number), then
`…/actions/artifacts/{id}/zip`. Note the workstation has no `unzip` — use
`python3 -m zipfile -e`.
## The integration lane
Added 2026-08-23. Before it, `alembic upgrade head` ran for the first time when the
operator's container started — 26 revisions, none of them ever executed by CI — and
the schema the migrations build had never been checked against the models that read
it. 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
parts. Three of them are family rules for a reason:
- **Job key `integration`, no `name:`** (rule 80). act_runner derives the service
container's name from the truncated job DISPLAY name, and the discovery step filters
`docker ps` by it. A spaced or underscored name breaks the filter.
- **Service hostnames are not routable** on this runner (rule 79), so the step resolves
the Postgres container's bridge IP with `docker ps --filter` + `docker inspect` and
builds `THOUGHTSYNC_DATABASE_URL` from it. `postgres:5432` will not connect.
- **`run:` is busybox sh** (rule 81) — no `/dev/tcp` — so the readiness wait is a small
Python heredoc. Its terminator must dedent to column 0 after YAML strips the block
indent; check with `yaml.safe_load` and print the `run` string if you edit it.
`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
`-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.
Like `test`, it runs for visibility and does **not** gate the build.
There is no local way to run it — that would mean standing up Postgres on the
workstation, which rule 12 reserves for an explicit request. This lane is verified in
CI.
## Desktop (Tauri) lane — separate workflow
@@ -58,9 +122,17 @@ backend/frontend push.
`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 fmt --check``cargo clippy -D warnings``cargo test`
`cargo tauri build` (produces `.deb` + `.AppImage`) → de-bundle the AppImage's
graphics libs → verify the `.deb` → repackage for pacman.
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):
@@ -78,9 +150,309 @@ backend/frontend push.
`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 `thoughtsync-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
`thoughtsync-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:
```
IMG=git.fabledsword.com/bvandeusen/ci-tauri:1.97
DOCK="docker run --rm --user $(id -u):$(id -g) -e CARGO_HOME=/tmp/cargo -v $PWD:/w -w /w"
$DOCK $IMG cargo fmt --all --check
$DOCK $IMG cargo clippy --locked --workspace --all-targets -- -D warnings
$DOCK $IMG cargo test --locked --workspace
```
Drop `--check` from the first to apply it. `--user` keeps the container from
leaving root-owned files behind; `CARGO_HOME` points somewhere writable for that
user. Commands are IDENTICAL to the workflow's, deliberately — a local check that
differs from CI is worse than none.
**This reproduces CI exactly, not approximately.** On the 2026-08-18 run the
local test binary hashes (`thoughtsync_core-bbaae79723888ad1`,
`thoughtsync_desktop_lib-9d162263f8d0aca3`, `thoughtsync_ffi-fc557b96dc795e27`)
matched CI run 3931's byte for byte. Same image, same lockfile, same units.
`target/` persists on the host between runs, so after the first cold build these
take seconds (~30s for clippy). It is gitignored and reaches ~1.4 GB; delete it
whenever the space is wanted.
**Run these on every Rust-touching push, not just the ones that feel risky.** Four
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`
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
Same technique, same authorisation, same reason — and it covers a gap the Rust gate
cannot: `vue-tsc --noEmit` type-checks only the SCRIPT block, so a malformed TEMPLATE
passes the typecheck lane and fails `vite build` in a different workflow. `npm run
build` runs both, which is exactly what the desktop lanes run.
```
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"
```
The typecheck lane uses the `ci-python` image too — it is the node the frontend jobs
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
`Cargo.lock` is **committed** at the workspace root, per Cargo's own guidance for
binary crates. Without it every CI run re-resolved the graph, which meant a
released `.deb`/`.AppImage`/`.exe` couldn't be rebuilt from its tag, a build
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 Linux, a dedicated `cargo fetch --locked --target
x86_64-pc-windows-msvc` step on Windows. If the manifest and the lockfile
disagree, the run fails there instead of silently re-resolving; everything after
it in the same job then compiles the recorded versions, so the flag isn't
repeated on the bundle build. The Windows step exists separately because that
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`
above, and resolution is neither a test run nor a build:
```
docker run --rm --user "$(id -u):$(id -g)" -e CARGO_HOME=/tmp/cargo \
-v "$PWD:/w" -w /w \
git.fabledsword.com/bvandeusen/ci-tauri:1.97 cargo fetch
```
**`cargo fetch`, not `cargo generate-lockfile`.** Both update the lockfile, but
generate-lockfile re-resolves the whole graph from scratch and will happily bump
crates that have nothing to do with your change — turning a two-line manifest
edit into a few-hundred-line lockfile diff nobody can review. `cargo fetch`
performs the minimal resolution: existing pins are preserved, only the new
entries are added. Verify it stayed additive before committing (`git diff
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.
## Pushing: `dev` is both a branch and a tag
`git push origin dev` fails in this repo:
```
error: src refspec dev matches more than one
```
The rolling update channel is a release on a **fixed tag named `dev`** (the tag
never moves — Fabled-Git has no `/releases/latest/download/<asset>` route, so the
updater needs a permanent URL). Once that tag is fetched locally, the short name
`dev` resolves to both `refs/heads/dev` and `refs/tags/dev`. Fully qualify it:
```
git push origin refs/heads/dev:refs/heads/dev
```
## Shell scripts have no CI lane
Nothing lints `desktop/packaging/*.sh`, and a broken installer or publish script
fails at the moment a user runs it, not in a build. Check them before pushing —
`install.sh` is POSIX sh, the rest are bash:
```
dash -n desktop/packaging/install.sh # or: sh -n
bash -n desktop/packaging/publish-release.sh
```
Where a script resolves URLs from the Fabled-Git API, exercise the resolution
against the live instance (plain `curl` reads, no install) rather than trusting
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
its request bodies as shell strings, and quoting context decides what survives
into the JSON — a `` \ `` inside an unquoted heredoc loses its backslash to the
shell, the same `` \ `` inside a single-quoted variable does not, and reaches
Fabled-Git as an illegal escape (HTTP 422, one wasted build). `sh -n` cannot see
this. Extract the body block and parse it for every branch it can take:
```
sed -n '/^# The install command printed/,/^JSON$/p' desktop/packaging/publish-release.sh > /tmp/body.sh
echo ')' >> /tmp/body.sh
bash -c 'GITHUB_SERVER_URL=https://git.fabledsword.com GITHUB_REPOSITORY=o/r \
TAG=dev RELEASE_PRERELEASE=true; . /tmp/body.sh; printf "%s" "$BODY" | python3 -m json.tool >/dev/null'
```
+10
View File
@@ -0,0 +1,10 @@
CI drops the Android client here on every image build, and the Dockerfile copies
the directory into the image (see ci.yml "Fetch the Android client to bake in").
This file exists so the directory does too. `COPY client/ ...` fails outright on a
missing source, which would break every local `docker build` on a tree that has
never run that CI step — and an image with no Android client is a supported
state, not an error.
The artifacts themselves are gitignored: a 55 MiB binary does not belong in git
history, and it is fetched fresh anyway.
+45
View File
@@ -0,0 +1,45 @@
[package]
name = "thoughtsync-core"
version = "0.1.0"
description = "ThoughtSync client core — local-first SQLite store and opt-in sync engine"
authors = ["bvandeusen"]
edition = "2021"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
# Local-first store (M10.4): bundled = compile SQLite in, so there's no system
# libsqlite dependency to vary across the AppImage / native / Windows / Android builds.
rusqlite = { version = "0.32", features = ["bundled"] }
uuid = { version = "1", features = ["v4"] }
# 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"] }
# HTTP for the opt-in server handshake (M10.6) and the sync engine (M10.7).
#
# 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
# has to cross-compile on the Windows lane, which is the fragile one. rustls would
# instead pull in ring/aws-lc-rs and their assembler. On Linux native-tls uses
# OpenSSL, whose headers (libssl-dev) ci-tauri already ships.
# default-features off drops http2/charset we don't need for a JSON API.
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] }
# Verifying downloaded attachment bytes against the sha256 the server advertised.
sha2 = "0.10"
# Android has no system OpenSSL to link against, and `native-tls` resolves to
# OpenSSL there — unlike Windows, where it lands on schannel and costs nothing.
# Without this the build dies at `openssl-sys`: "Could not find directory of
# OpenSSL installation".
#
# `vendored` compiles OpenSSL from source with the NDK toolchain. The alternative
# was rustls on Android only, which builds faster — but rustls ships its own root
# store, so the phone would trust a DIFFERENT set of certificates than the desktop
# does. A self-hosted server behind a private or enterprise CA would then work on
# one surface and fail on another, and "the surfaces behave the same" is worth more
# than build minutes.
#
# 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.
[target.'cfg(target_os = "android")'.dependencies]
openssl-sys = { version = "0.9", features = ["vendored"] }
+14
View File
@@ -0,0 +1,14 @@
//! ThoughtSync's client core: the on-device SQLite store and the sync engine.
//!
//! Deliberately free of any UI framework. The desktop wraps it in Tauri commands;
//! the Android client binds it through uniffi. Neither owns it, and a change to
//! 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.
//! - `sync` — entirely opt-in. Nothing in it runs until a server is linked.
pub mod local;
pub mod sync;
+773
View File
@@ -0,0 +1,773 @@
//! Deriving structure from a note's body — the local mirror of what the server
//! computes on save. Pure string scanning (no regex dependency), kept in lockstep
//! with the frontend's inline rules (see frontend notes/markdown.ts):
//!
//! - `#tag`: `#` at a word boundary followed by tag characters (letter first).
//! On save these become labels attached with `via_tag = true`.
//! - `- [ ] item`: a checklist item. The body IS the checklist (M304) — there is no
//! table of items beside it, so a list can sit between two paragraphs instead of
//! only after them.
//!
//! The two are the same idea at different strengths. Tags MATERIALISE into label
//! rows, because the board queries by label. Items materialise into nothing,
//! because nothing queries them: their only readers are the card, the editor and
//! `display_title`. So `extract_items` is the whole storage layer for a checklist,
//! and the rewriters below are how one is edited.
//!
//! 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.
///
/// Char indices rather than byte offsets so the spans can be used to cut the tags
/// back out of the line without ever landing mid-codepoint — see
/// [`lift_standalone_tags`], which is the only reason the spans exist.
fn line_tags(chars: &[char]) -> Vec<(usize, usize, String)> {
let mut out: Vec<(usize, usize, String)> = Vec::new();
let mut i = 0;
while i < chars.len() {
if chars[i] == '#' {
let boundary = i == 0 || (!is_tag_char(chars[i - 1]) && chars[i - 1] != '#');
// A tag must start with a letter (so "#1" or a bare "#" is not a tag).
if boundary && i + 1 < chars.len() && chars[i + 1].is_alphabetic() {
let mut j = i + 1;
while j < chars.len() && is_tag_char(chars[j]) {
j += 1;
}
out.push((i, j, chars[i + 1..j].iter().collect()));
i = j;
continue;
}
}
i += 1;
}
out
}
/// Extract every `#tag` name (without the leading `#`) from `body`.
///
/// Line by line, which changes nothing: a line start and a `\n` are both boundaries,
/// so the same tags come out. It means there is ONE scanner rather than two — this and
/// [`lift_standalone_tags`] cannot disagree about what a tag is.
pub fn extract_tags(body: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for line in body.split('\n') {
let chars: Vec<char> = line.chars().collect();
for (_, _, name) in line_tags(&chars) {
push_unique(&mut out, &name);
}
}
out
}
/// One `#tag` and exactly where it sits, for a renderer drawing the body itself.
///
/// The card no longer prints a chip for a tag whose text is still in the note — it
/// colours the token where it was typed instead. To do that a renderer needs the
/// 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.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DerivedTag {
/// Which body line it sits on, like [`DerivedItem::line`].
pub line: u32,
/// Offsets into that line, in UTF-16 code units — INCLUDING the leading `#`.
///
/// UTF-16 rather than chars or bytes because the two languages that consume this
/// both index strings that way: Kotlin's `AnnotatedString` and JavaScript. A char
/// index is right up until somebody puts an emoji before a tag, and then it lands
/// mid-token with no error anywhere.
pub start: u32,
pub end: u32,
pub name: String,
}
/// Every `#tag` in `body` with its position — the same scan [`extract_tags`] does,
/// keeping the spans instead of throwing them away.
///
/// Not deduped, unlike `extract_tags`: two mentions of `#todo` are two pieces of text
/// to colour. Fences are not skipped either, and that is deliberate — `extract_tags`
/// does not skip them, so a `#tag` inside a code block IS a label on the note, and a
/// renderer that left it plain would be the only surface disagreeing.
pub fn extract_tag_spans(body: &str) -> Vec<DerivedTag> {
let mut out = Vec::new();
for (n, line) in body.split('\n').enumerate() {
let chars: Vec<char> = line.chars().collect();
let spans = line_tags(&chars);
if spans.is_empty() {
continue;
}
// Prefix sums, built once per tagged line: char index -> UTF-16 offset.
let mut units: Vec<u32> = Vec::with_capacity(chars.len() + 1);
let mut total: u32 = 0;
units.push(0);
for c in &chars {
total += c.len_utf16() as u32;
units.push(total);
}
for (start, end, name) in spans {
out.push(DerivedTag {
line: n as u32,
start: units[start],
end: units[end],
name,
});
}
}
out
}
/// Whether a line opens or closes a fenced code block.
fn is_fence(line: &str) -> bool {
let trimmed = line.trim_start();
trimmed.starts_with("```") || trimmed.starts_with("~~~")
}
/// Runs of three or more newlines become two, and the ends are trimmed.
///
/// Removing a line must not leave a hole where it was.
fn collapse_blank_runs(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut run = 0;
for c in text.chars() {
if c == '\n' {
run += 1;
if run <= 2 {
out.push(c);
}
} else {
run = 0;
out.push(c);
}
}
out.trim_matches('\n').to_string()
}
/// Split a body's tags by whether the text around them can be taken away.
///
/// Returns `(standalone, inline, lifted_body)`.
///
/// THE RULE: a line containing nothing but tags and whitespace is removed. Anything
/// else is left exactly as written.
///
/// 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
/// moment it synced. Same discipline, and the same reason, as `DerivedTint`.
///
/// 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,
/// `remember to call #mom` is the sentence's object, and lifting the second leaves
/// "remember to call". A tag sharing a line with words keeps its words.
///
/// `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
/// one. `inline` tags stay derived exactly as before. That is what `via_tag` means from
/// here on — backed by text still in the body.
pub fn lift_standalone_tags(body: &str) -> (Vec<String>, Vec<String>, String) {
let mut standalone: Vec<String> = Vec::new();
let mut inline: Vec<String> = Vec::new();
let mut kept: Vec<&str> = Vec::new();
let mut in_fence = false;
for line in body.split('\n') {
if is_fence(line) {
in_fence = !in_fence;
kept.push(line);
continue;
}
let chars: Vec<char> = line.chars().collect();
let spans = line_tags(&chars);
// Cut the tags out and see whether anything is left. That is what
// "standalone" means, and it is the whole rule.
let mut remainder = String::new();
let mut pos = 0;
for (start, end, _) in &spans {
remainder.extend(chars[pos..*start].iter());
pos = *end;
}
remainder.extend(chars[pos..].iter());
// A fence's contents are CODE: a `#tag` there is a shell comment in somebody's
// snippet, and deleting the line would eat part of their example.
if in_fence || spans.is_empty() || !remainder.trim().is_empty() {
for (_, _, name) in &spans {
push_unique(&mut inline, name);
}
kept.push(line);
} else {
for (_, _, name) in &spans {
push_unique(&mut standalone, name);
}
}
}
let lifted = collapse_blank_runs(&kept.join("\n"));
if !body.trim().is_empty() && lifted.trim().is_empty() {
// The note was NOTHING but tags. Lifting would leave a blank card, which is a
// worse outcome than a duplicated chip — so leave it alone.
let mut all = standalone;
for name in &inline {
push_unique(&mut all, name);
}
return (Vec::new(), all, body.to_string());
}
// A tag that ALSO appears in prose stays derived: the prose copy still backs it,
// so deleting that copy should still detach the label.
let inline_lower: Vec<String> = inline.iter().map(|n| n.to_lowercase()).collect();
let standalone = standalone
.into_iter()
.filter(|n| !inline_lower.contains(&n.to_lowercase()))
.collect();
(standalone, inline, lifted)
}
fn is_tag_char(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '-'
}
fn push_unique(out: &mut Vec<String>, candidate: &str) {
if !out.iter().any(|x| x.eq_ignore_ascii_case(candidate)) {
out.push(candidate.to_string());
}
}
// ── checklist items ─────────────────────────────────────────────────────────
//
// The grammar, in one place, because three languages implement it (here,
// `notes/checklist.py`, `notes/markdown.ts`) and a difference between any two of
// them is a checklist that changes shape when it syncs:
//
// optional indent, `-` or `*`, one-or-more spaces, `[ ]`/`[x]`/`[X]`,
// then either end-of-line or one-or-more spaces and the text.
//
// `*` is accepted because markdown.ts already accepts it for a plain bullet, and a
// grammar that takes `* item` but not `* [ ] item` would be a rule with no reason
// anyone could guess. `- [ ]` with nothing after it IS an item with empty text:
// that is exactly what pressing Enter on a list leaves behind, and refusing to
// 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
/// its identity — the same thing `position` meant when these were rows, and all the
/// wire ever carried (`push.rs` sent text and checked, never an id).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DerivedItem {
pub text: String,
pub checked: bool,
/// Which body line it sits on.
///
/// Carried here rather than offered as a second function, because every renderer
/// that walks a body line by line — the Android card, the block editor — needs the
/// text, the state AND the position together, and asking for them separately is
/// how two calls come to disagree about a body that changed between them.
pub line: u32,
}
/// One parsed task line, holding enough to put it back exactly as it was found.
struct TaskLine<'a> {
indent: &'a str,
/// Preserved rather than normalised to `-`: rewriting someone's `*` bullets
/// because they ticked a box would be an edit they did not ask for.
bullet: char,
checked: bool,
text: &'a str,
}
fn parse_task_line(line: &str) -> Option<TaskLine<'_>> {
let indent_len = line.len() - line.trim_start().len();
let (indent, rest) = line.split_at(indent_len);
let bullet = rest.chars().next()?;
if bullet != '-' && bullet != '*' {
return None;
}
// At least one space after the bullet. `-[ ] x` is not a list item in any
// markdown either, so it stays prose here too.
let rest = &rest[bullet.len_utf8()..];
let gap = rest.len() - rest.trim_start_matches(' ').len();
if gap == 0 {
return None;
}
let rest = &rest[gap..];
let mut chars = rest.chars();
if chars.next()? != '[' {
return None;
}
let mark = chars.next()?;
if chars.next()? != ']' {
return None;
}
// Decided BEFORE the slice below, which is what guarantees `mark` is one byte
// and `[?]` is exactly three.
let checked = match mark {
' ' => false,
'x' | 'X' => true,
_ => return None,
};
let rest = &rest[3..];
let text = if rest.is_empty() {
// "- [ ]" — an empty item, which is what an unfinished list line is.
rest
} else {
let gap = rest.len() - rest.trim_start_matches(' ').len();
// "- [ ]x" is prose: without the space this is not a marker, it is a
// sentence that happens to start with brackets.
if gap == 0 {
return None;
}
&rest[gap..]
};
Some(TaskLine {
indent,
bullet,
checked,
text,
})
}
/// One item as the line that stores it, in canonical form.
///
/// Public because a block editor has to write a line back after someone edits it in a
/// widget that never showed them the marker. Rendering is trivial where PARSING is
/// not, but it still belongs here: this is the file that decides what canonical looks
/// like, and a caller inventing its own `- [x] ` would be a fourth opinion on it.
pub fn render_item(text: &str, checked: bool) -> String {
render_task_line("", '-', checked, text)
}
fn render_task_line(indent: &str, bullet: char, checked: bool, text: &str) -> String {
// Always lowercase `x`, whatever was parsed: one canonical output is what makes
// a round trip stable, so `- [X]` normalises the first time it is touched and
// never again.
let mark = if checked { 'x' } else { ' ' };
if text.is_empty() {
format!("{indent}{bullet} [{mark}]")
} else {
format!("{indent}{bullet} [{mark}] {text}")
}
}
/// The text of a line with its task marker removed, or the line as it was.
///
/// For naming a note: a list-only note is named by its first item, and calling one
/// "- [ ] milk" would be showing someone the storage instead of the note.
pub fn strip_marker(line: &str) -> &str {
match parse_task_line(line) {
Some(t) => t.text,
None => line,
}
}
/// Every checklist item in `body`, in the order they appear.
pub fn extract_items(body: &str) -> Vec<DerivedItem> {
let mut out = Vec::new();
for (n, line) in body.split('\n').enumerate() {
if let Some(t) = parse_task_line(line) {
out.push(DerivedItem {
text: t.text.to_string(),
checked: t.checked,
line: n as u32,
});
}
}
out
}
/// Rewrite the `index`-th task line, or drop it when `f` returns None.
///
/// A body with fewer task lines than that is returned UNCHANGED rather than
/// panicking: the index comes from a UI that may be a moment behind the store, and
/// a stale tap should do nothing rather than take the app down.
fn map_task_line<F>(body: &str, index: usize, f: F) -> String
where
F: FnOnce(&TaskLine<'_>) -> Option<String>,
{
let lines: Vec<&str> = body.split('\n').collect();
let mut target: Option<usize> = None;
let mut seen = 0usize;
for (n, line) in lines.iter().enumerate() {
if parse_task_line(line).is_some() {
if seen == index {
target = Some(n);
break;
}
seen += 1;
}
}
let target = match target {
Some(n) => n,
None => return body.to_string(),
};
let replacement = match parse_task_line(lines[target]) {
Some(parsed) => f(&parsed),
None => return body.to_string(),
};
let mut out: Vec<String> = Vec::with_capacity(lines.len());
for (n, line) in lines.iter().enumerate() {
if n != target {
out.push((*line).to_string());
} else if let Some(new_line) = &replacement {
out.push(new_line.clone());
}
// None at the target line drops it, which is `remove_item`.
}
out.join("\n")
}
/// Tick or untick the `index`-th item.
pub fn set_item_checked(body: &str, index: usize, checked: bool) -> String {
map_task_line(body, index, |t| {
Some(render_task_line(t.indent, t.bullet, checked, t.text))
})
}
/// Replace the text of the `index`-th item, keeping its state and its bullet.
pub fn set_item_text(body: &str, index: usize, text: &str) -> String {
map_task_line(body, index, |t| {
Some(render_task_line(t.indent, t.bullet, t.checked, text.trim()))
})
}
/// Delete the `index`-th item, line and all.
pub fn remove_item(body: &str, index: usize) -> String {
map_task_line(body, index, |_| None)
}
/// Add an item at the end of the body.
///
/// 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
/// cosmetic: the server migration folds existing rows into bodies using the same
/// layout, so an export taken before the migration and one taken after have to
/// agree byte for byte.
///
/// `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
/// item from the UI passes false.
pub fn append_item(body: &str, text: &str, checked: bool) -> String {
let line = render_task_line("", '-', checked, text.trim());
let trimmed = body.trim_end_matches('\n');
if trimmed.trim().is_empty() {
return line;
}
let follows_a_list = trimmed
.split('\n')
.next_back()
.is_some_and(|l| parse_task_line(l).is_some());
if follows_a_list {
format!("{trimmed}\n{line}")
} else {
format!("{trimmed}\n\n{line}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tags_basic() {
assert_eq!(
extract_tags("a #todo and #Work-item_2 here"),
vec!["todo", "Work-item_2"]
);
}
#[test]
fn tags_require_letter_start_and_boundary() {
// "#1" (digit) and an in-word "#" (email-ish) are not tags.
assert_eq!(extract_tags("#1 nope a#b no but #Yes"), vec!["Yes"]);
}
#[test]
fn tags_dedupe_case_insensitive() {
assert_eq!(extract_tags("#Home #home #HOME"), vec!["Home"]);
}
#[test]
fn empty_body() {
assert!(extract_tags("").is_empty());
}
// ── tag spans, for the renderer that draws them in place ─────────────────
#[test]
fn tag_spans_carry_the_hash_and_the_line() {
let spans = extract_tag_spans("buy milk #grocery\nand call #mom about #mom");
assert_eq!(spans.len(), 3);
assert_eq!((spans[0].line, spans[0].start, spans[0].end), (0, 9, 17));
assert_eq!(spans[0].name, "grocery");
// Not deduped: two mentions are two pieces of text to colour.
assert_eq!(spans[1].line, 1);
assert_eq!(spans[2].name, "mom");
assert_eq!((spans[2].start, spans[2].end), (20, 24));
}
#[test]
fn tag_spans_are_utf16_offsets_not_char_indices() {
// The emoji is ONE char and TWO UTF-16 code units. Kotlin and JS both index
// the second way, so a char index would highlight one character too early.
let spans = extract_tag_spans("🎁 #gift");
assert_eq!(spans.len(), 1);
assert_eq!((spans[0].start, spans[0].end), (3, 8));
}
#[test]
fn tag_spans_agree_with_extract_tags_about_what_a_tag_is() {
let body = "#1 nope a#b no but #Yes ##no";
let names: Vec<String> = extract_tag_spans(body)
.into_iter()
.map(|t| t.name)
.collect();
assert_eq!(names, extract_tags(body));
}
// ── lifting standalone tags ──────────────────────────────────────────────
//
// 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
// moment it synced, so these are the cases that file agrees to.
#[test]
fn lifts_a_line_that_is_nothing_but_tags() {
let (standalone, inline, body) = lift_standalone_tags("#todo\nreorganize the homepage");
assert_eq!(standalone, vec!["todo"]);
assert!(inline.is_empty());
assert_eq!(body, "reorganize the homepage");
let (standalone, _, body) = lift_standalone_tags("needs a tauri app\n#todo");
assert_eq!(standalone, vec!["todo"]);
assert_eq!(body, "needs a tauri app");
let (standalone, _, body) = lift_standalone_tags("#todo #work\nreal text");
assert_eq!(standalone, vec!["todo", "work"]);
assert_eq!(body, "real text");
}
/// The cases that must come back byte-identical. Getting any of these wrong
/// destroys somebody's words, which is why the rule is the conservative one:
/// a trailing tag is ambiguous and the text does not say which kind it is.
#[test]
fn leaves_a_tag_that_shares_its_line_with_words() {
for prose in [
"remember to call #mom tomorrow",
"buy milk #grocery",
"#2024\nreal",
] {
let (standalone, _, body) = lift_standalone_tags(prose);
assert!(standalone.is_empty(), "{prose}");
assert_eq!(body, prose, "{prose}");
}
}
#[test]
fn removing_a_line_leaves_no_hole() {
let (_, _, body) = lift_standalone_tags("foo\n\n#todo\n\nbar");
assert_eq!(body, "foo\n\nbar");
}
/// A `#tag` in a fence is a shell comment in somebody's snippet. It still becomes
/// a label — it always has — but the line is never touched.
#[test]
fn never_touches_a_fenced_line() {
let fenced = "code:\n```\n#!/bin/sh\n#deploy\n```\ndone";
let (standalone, inline, body) = lift_standalone_tags(fenced);
assert!(standalone.is_empty());
assert_eq!(inline, vec!["deploy"]);
assert_eq!(body, fenced);
}
/// Lifting would leave a blank card, which is worse than the duplication this
/// removes. So the note keeps its text and its tags stay derived.
#[test]
fn will_not_blank_a_note_that_is_only_tags() {
let (standalone, inline, body) = lift_standalone_tags("#todo");
assert!(standalone.is_empty());
assert_eq!(inline, vec!["todo"]);
assert_eq!(body, "#todo");
}
/// Appearing on its own line does NOT lift a tag also written in a sentence — the
/// sentence still backs it, so deleting the sentence should still detach it.
#[test]
fn a_tag_still_in_prose_stays_derived() {
let (standalone, inline, body) = lift_standalone_tags("#todo\nremember the #todo list");
assert!(standalone.is_empty());
assert_eq!(inline, vec!["todo"]);
assert_eq!(body, "remember the #todo list");
}
#[test]
fn lifting_an_empty_body_is_a_no_op() {
let (standalone, inline, body) = lift_standalone_tags("");
assert!(standalone.is_empty());
assert!(inline.is_empty());
assert_eq!(body, "");
}
// ── checklist items ─────────────────────────────────────────────────────
fn item(text: &str, checked: bool, line: u32) -> DerivedItem {
DerivedItem {
text: text.to_string(),
checked,
line,
}
}
#[test]
fn items_basic() {
let body = "shopping\n\n- [ ] milk\n- [x] eggs";
assert_eq!(
extract_items(body),
vec![item("milk", false, 2), item("eggs", true, 3)]
);
}
#[test]
fn items_may_sit_between_paragraphs() {
// The whole reason the body owns the list: a table of rows could only ever
// render after the prose.
let body = "before\n- [ ] middle\nafter";
assert_eq!(extract_items(body), vec![item("middle", false, 1)]);
}
#[test]
fn items_reject_near_misses() {
// Each of these is prose, and each has been someone's bug report somewhere.
for body in [
"-[ ] no space after the dash",
"- [] empty brackets",
"- [ ]no space after the brackets",
"- [y] not a mark",
"a [ ] mid sentence",
"[ ] no bullet at all",
] {
assert!(extract_items(body).is_empty(), "should be prose: {body}");
}
}
#[test]
fn items_accept_star_bullets_and_indentation() {
// `*` because markdown.ts already takes it for a plain bullet.
let body = "* [ ] star\n - [x] indented";
assert_eq!(
extract_items(body),
vec![item("star", false, 0), item("indented", true, 1)]
);
}
#[test]
fn an_empty_item_is_still_an_item() {
// What pressing Enter on a list leaves behind.
assert_eq!(extract_items("- [ ]"), vec![item("", false, 0)]);
assert_eq!(extract_items("- [ ] "), vec![item("", false, 0)]);
}
#[test]
fn uppercase_x_parses_and_normalises_on_rewrite() {
assert_eq!(extract_items("- [X] done"), vec![item("done", true, 0)]);
// Touching it once canonicalises it, and never again.
assert_eq!(set_item_checked("- [X] done", 0, true), "- [x] done");
}
#[test]
fn checking_preserves_indent_bullet_and_text() {
assert_eq!(set_item_checked(" * [ ] milk", 0, true), " * [x] milk");
assert_eq!(set_item_checked("- [x] milk", 0, false), "- [ ] milk");
}
#[test]
fn checking_addresses_items_not_lines() {
let body = "note\n- [ ] a\nprose\n- [ ] b";
assert_eq!(
set_item_checked(body, 1, true),
"note\n- [ ] a\nprose\n- [x] b"
);
}
#[test]
fn set_text_keeps_state() {
assert_eq!(set_item_text("- [x] old", 0, "new"), "- [x] new");
}
#[test]
fn remove_takes_the_whole_line() {
let body = "keep\n- [ ] drop\n- [ ] stay";
assert_eq!(remove_item(body, 0), "keep\n- [ ] stay");
}
#[test]
fn append_spaces_like_the_exporter() {
// Prose then a blank line then the list — byte-for-byte what
// import_export.py:_note_markdown writes, which is what the server
// migration will fold existing rows into.
assert_eq!(append_item("a note", "milk", false), "a note\n\n- [ ] milk");
// Nothing between consecutive items.
let one = "a note\n\n- [ ] milk";
assert_eq!(
append_item(one, "eggs", false),
format!("{one}\n- [ ] eggs")
);
// A list-only note starts at the first line.
assert_eq!(append_item("", "milk", false), "- [ ] milk");
assert_eq!(append_item("\n\n", "milk", false), "- [ ] milk");
// Carries state, which is what the two migrations need of it.
assert_eq!(append_item("", "done", true), "- [x] done");
}
#[test]
fn strip_marker_names_a_list_only_note() {
assert_eq!(strip_marker("- [x] milk"), "milk");
assert_eq!(strip_marker("just prose"), "just prose");
}
#[test]
fn render_item_is_what_extract_reads_back() {
assert_eq!(render_item("milk", false), "- [ ] milk");
assert_eq!(render_item("done", true), "- [x] done");
// An empty item has no trailing space, so a round trip does not grow it.
assert_eq!(render_item("", false), "- [ ]");
let line = render_item("milk", true);
assert_eq!(extract_items(&line), vec![item("milk", true, 0)]);
}
#[test]
fn items_carry_the_line_they_sit_on() {
let found = extract_items("a\n- [ ] x\nb\n- [x] y");
assert_eq!(found.iter().map(|i| i.line).collect::<Vec<_>>(), vec![1, 3]);
}
#[test]
fn a_stale_index_does_nothing() {
// The index comes from a UI that may be a moment behind the store. A tap
// that arrives late should be inert, not fatal.
let body = "- [ ] only";
assert_eq!(set_item_checked(body, 7, true), body);
assert_eq!(remove_item(body, 7), body);
assert_eq!(set_item_text(body, 7, "x"), body);
}
#[test]
fn a_plain_body_is_returned_byte_identical() {
let body = "just prose\nwith two lines";
assert_eq!(set_item_checked(body, 0, true), body);
assert_eq!(set_item_text(body, 0, "x"), body);
assert_eq!(remove_item(body, 0), body);
}
#[test]
fn round_trip_is_stable() {
let body = "- [ ] a\n- [x] b\n- [ ] c";
let items = extract_items(body);
// Ticking and unticking returns the original bytes.
let touched = set_item_checked(&set_item_checked(body, 0, true), 0, false);
assert_eq!(touched, body);
assert_eq!(extract_items(&touched), items);
}
}
+79
View File
@@ -0,0 +1,79 @@
//! The local-first store: on-device SQLite, and the source of truth for every
//! client. A client built on this is fully usable with no server and no account.
//!
//! Framework-free on purpose. The desktop reaches it through Tauri commands and
//! Android through uniffi, but neither of those concerns appears in here.
pub mod derive;
pub mod models;
pub mod recur;
pub mod retention;
pub mod schema;
pub mod store;
use std::path::Path;
use std::sync::Mutex;
use rusqlite::Connection;
/// The shared database handle. rusqlite connections aren't `Sync`, so a `Mutex`
/// serializes access — fine, since operations are quick and a client is single-user.
/// How it is held is the caller's business: Tauri manages it as state, Android holds
/// it in the uniffi object.
pub struct Db(pub Mutex<Connection>);
impl Db {
/// Lock the store, reporting a poisoned lock as a message rather than a panic.
///
/// Every consumer was writing `db.0.lock().map_err(|e| e.to_string())?` at each
/// call site. Beyond the repetition, that spelling forces the caller to NAME
/// `rusqlite::Connection` in any helper that returns the guard — which would make
/// rusqlite a dependency of a layer whose whole point is not to know what the
/// store is made of. Returning it from here means callers can bind the guard by
/// inference and never name the type.
///
/// A poisoned lock means some earlier call panicked while holding it. The store
/// is not necessarily corrupt, but this connection can't be trusted blind, so it
/// surfaces as an error the UI can show instead of a second panic.
pub fn conn(&self) -> Result<std::sync::MutexGuard<'_, Connection>, String> {
self.0
.lock()
.map_err(|_| "the local store lock was poisoned by an earlier panic".to_string())
}
}
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
pub fn open(path: &Path) -> rusqlite::Result<Db> {
let conn = Connection::open(path)?;
schema::migrate(&conn)?;
Ok(Db(Mutex::new(conn)))
}
/// Open a migrated, in-memory store.
///
/// 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
/// crate was doing before the core was extracted. The Android bindings will want the
/// same thing.
pub fn open_in_memory() -> rusqlite::Result<Db> {
let conn = Connection::open_in_memory()?;
schema::migrate(&conn)?;
Ok(Db(Mutex::new(conn)))
}
/// A one-line count summary of the store, for the startup log.
pub fn summary(db: &Db) -> String {
let conn = match db.0.lock() {
Ok(c) => c,
Err(_) => return "counts unavailable (lock poisoned)".to_string(),
};
let count = |sql: &str| {
conn.query_row(sql, [], |r| r.get::<_, i64>(0))
.unwrap_or(-1)
};
format!(
"{} notes, {} labels",
count("SELECT COUNT(*) FROM notes"),
count("SELECT COUNT(*) FROM labels"),
)
}
@@ -8,17 +8,18 @@ use serde::{Deserialize, Serialize};
#[derive(Serialize)]
pub struct Note {
pub id: String,
pub title: Option<String>,
/// title if set, else the note's first body line — always present, so body-only
/// notes are still nameable and `[[link]]`-able. Derived, never stored.
/// The note's NAME: its first non-blank body line, else its first checklist item.
/// Always present, so every note has something to be called. Derived at read time,
/// never stored.
pub display_title: String,
pub body: String,
pub color: String,
pub kind: String,
pub position: i64,
pub pinned: bool,
pub archived: bool,
pub trashed: bool,
/// When it was trashed (null unless trashed). Named for the server's field so the
/// shared frontend counts down the retention window identically either way.
pub deleted_at: Option<String>,
pub remind_at: Option<String>,
pub recurrence: Option<String>,
pub labels: Vec<NoteLabel>,
@@ -69,7 +70,6 @@ pub struct LinkPreview {
#[derive(Serialize)]
pub struct NoteRevision {
pub id: String,
pub title: Option<String>,
pub body: String,
pub created_at: Option<String>,
}
@@ -91,12 +91,6 @@ pub struct TitleEntry {
pub title: String,
}
#[derive(Serialize)]
pub struct Backlink {
pub id: String,
pub title: String,
}
#[derive(Serialize)]
pub struct SavedFilter {
pub id: String,
@@ -112,6 +106,7 @@ pub struct PublicConfig {
pub allow_registration: bool,
pub version: String,
pub enable_url_unfurl: bool,
pub trash_retention_days: u32,
}
/// The synthetic single user the offline core reports, so the app's auth-gated
@@ -125,20 +120,10 @@ pub struct User {
pub is_admin: bool,
}
fn default_color() -> String {
"default".to_string()
}
#[derive(Deserialize)]
pub struct NoteCreateInput {
#[serde(default)]
pub title: String,
#[serde(default)]
pub body: String,
#[serde(default = "default_color")]
pub color: String,
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub items: Option<Vec<String>>,
}
@@ -162,10 +147,6 @@ pub struct Facets {
#[serde(default)]
pub q: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub label: Option<Vec<String>>,
#[serde(default)]
pub has_reminder: Option<bool>,
+171
View File
@@ -0,0 +1,171 @@
//! Recurring-reminder math: where a reminder goes when it is marked done.
//!
//! A deliberate port of the server's `src/thoughtsync/notes/recurrence.py`, kept
//! behaviourally identical rather than merely similar. The same note can be
//! completed from the web (server code) or from the desktop and Android (this
//! code), and the two must land on the same instant — otherwise completing a
//! reminder on a phone and then syncing would silently move it relative to
//! completing it in a browser, and neither surface would look wrong on its own.
//!
//! Advancement is measured from the reminder's OWN time, never from now. That is
//! what keeps a 09:00 daily reminder at 09:00 after being dealt with at 09:47,
//! and a monthly one on the same day of the month.
//!
//! Known limitation, shared with the server: the arithmetic is in UTC, and a note
//! carries no timezone. So a daily reminder crossing a DST boundary keeps its UTC
//! time and shifts by an hour locally. Fixing that means storing a zone per note
//! and is a change to the wire format, not to this file.
use chrono::{DateTime, Duration, Months, Utc};
/// The four intervals every surface offers. Anything else is not a recurrence.
pub const RECURRENCES: [&str; 4] = ["daily", "weekly", "monthly", "yearly"];
/// A recurrence we recognise, or nothing.
///
/// Values reach the store from three clients and a sync payload, so "not a rule
/// we know" is an ordinary case rather than a corruption to shout about.
pub fn normalize(value: Option<&str>) -> Option<&str> {
value.filter(|v| RECURRENCES.contains(v))
}
/// One step forward. `None` for an unrecognised rule.
///
/// Months and years clamp the day to the target month's length — 31 January plus
/// a month is 28 February, and the following step is 28 March rather than back to
/// the 31st. `chrono`'s `checked_add_months` does that clamping, matching
/// `_add_months` in the Python to the day.
fn advance_once(at: DateTime<Utc>, recurrence: &str) -> Option<DateTime<Utc>> {
match recurrence {
"daily" => at.checked_add_signed(Duration::days(1)),
"weekly" => at.checked_add_signed(Duration::weeks(1)),
"monthly" => at.checked_add_months(Months::new(1)),
"yearly" => at.checked_add_months(Months::new(12)),
_ => None,
}
}
/// The first fire time strictly after `after`, rolling past anything missed.
///
/// A phone left in a drawer for a fortnight should not come back to fourteen
/// pending occurrences of the same daily reminder — it should come back to
/// tomorrow's. `None` when the rule is not one we know, which the caller reads as
/// "this reminder is finished".
pub fn next_occurrence(
remind_at: DateTime<Utc>,
recurrence: &str,
after: DateTime<Utc>,
) -> Option<DateTime<Utc>> {
let mut next = advance_once(remind_at, recurrence)?;
while next <= after {
match advance_once(next, recurrence) {
// The equality check is a guard against a step that does not move,
// which would spin here forever. It cannot happen with the four rules
// above; it is cheap insurance against a fifth that does not advance.
Some(step) if step != next => next = step,
_ => break,
}
}
Some(next)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn utc(y: i32, m: u32, d: u32, h: u32, min: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(y, m, d, h, min, 0).unwrap()
}
/// Mirrors `test_normalize_recurrence` in `tests/test_notes.py`.
#[test]
fn only_the_four_known_rules_are_recurrences() {
for rule in RECURRENCES {
assert_eq!(normalize(Some(rule)), Some(rule));
}
assert_eq!(normalize(Some("none")), None);
assert_eq!(normalize(Some("")), None);
assert_eq!(normalize(Some("hourly")), None);
assert_eq!(normalize(None), None);
}
/// Mirrors `test_next_occurrence_daily_weekly`.
#[test]
fn daily_and_weekly_keep_the_time_of_day() {
let base = utc(2026, 7, 1, 9, 0);
let after = utc(2026, 7, 1, 12, 0);
assert_eq!(
next_occurrence(base, "daily", after),
Some(utc(2026, 7, 2, 9, 0))
);
assert_eq!(
next_occurrence(base, "weekly", after),
Some(utc(2026, 7, 8, 9, 0))
);
}
/// Mirrors `test_next_occurrence_skips_missed`.
#[test]
fn missed_occurrences_are_rolled_past_not_queued() {
let base = utc(2026, 7, 1, 9, 0);
let after = utc(2026, 7, 10, 12, 0);
assert_eq!(
next_occurrence(base, "daily", after),
Some(utc(2026, 7, 11, 9, 0))
);
}
/// Mirrors `test_next_occurrence_monthly_clamps_month_end`.
#[test]
fn monthly_clamps_to_a_shorter_month() {
let base = utc(2026, 1, 31, 8, 0);
let after = utc(2026, 2, 1, 0, 0);
assert_eq!(
next_occurrence(base, "monthly", after),
Some(utc(2026, 2, 28, 8, 0))
);
}
/// Mirrors `test_next_occurrence_yearly_and_none`.
#[test]
fn yearly_advances_a_year_and_an_unknown_rule_advances_nothing() {
let base = utc(2026, 3, 15, 7, 0);
let after = utc(2026, 3, 16, 0, 0);
assert_eq!(
next_occurrence(base, "yearly", after),
Some(utc(2027, 3, 15, 7, 0))
);
assert_eq!(next_occurrence(base, "none", after), None);
}
/// Not in the Python suite, and the one that would bite hardest in practice:
/// a monthly reminder set on the 31st must not walk itself back to the 28th
/// permanently. Each step is taken from the ORIGINAL date, so February's clamp
/// does not become March's date.
#[test]
fn a_clamped_month_does_not_drag_later_months_back() {
let base = utc(2026, 1, 31, 8, 0);
// Far enough ahead that the loop takes several steps.
let after = utc(2026, 4, 15, 0, 0);
// Jan 31 -> Feb 28 -> Mar 28 -> Apr 28. The clamp is sticky once applied,
// which matches the server exactly — asserted so a future "fix" to either
// side has to change both.
assert_eq!(
next_occurrence(base, "monthly", after),
Some(utc(2026, 4, 28, 8, 0))
);
}
/// A reminder completed before it was ever due still moves forward one step,
/// rather than staying put and firing again immediately.
#[test]
fn completing_early_still_advances() {
let base = utc(2026, 7, 10, 9, 0);
let after = utc(2026, 7, 1, 12, 0);
assert_eq!(
next_occurrence(base, "daily", after),
Some(utc(2026, 7, 11, 9, 0))
);
}
}
+228
View File
@@ -0,0 +1,228 @@
//! Trash retention for a device with no server (M11.3).
//!
//! 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
//! what it's told. This module exists for the case the server can't cover — an
//! offline-only install, where trash would otherwise sit forever and the attachment
//! bytes with it.
//!
//! Which is why the sweep refuses to run while linked. If it didn't, a device could
//! decide on its own that a note had expired, destroy it, and then push that delete
//! upstream — overruling a server that was deliberately keeping it (retention off, or
//! a longer window than this constant). A client's local policy must never outrank
//! the server's.
use chrono::{DateTime, Duration, Utc};
use rusqlite::Connection;
use super::store;
use crate::sync::state;
/// The window an unlinked device uses. Matches the server's default so a device that
/// later links doesn't see its trash behave differently from one that always was.
pub const LOCAL_RETENTION_DAYS: i64 = 30;
/// Purge trash older than `retention_days`. Returns how many notes went.
///
/// `now` is a parameter so the window arithmetic is testable without waiting a month.
pub fn sweep_expired_trash(
conn: &Connection,
retention_days: i64,
now: DateTime<Utc>,
) -> rusqlite::Result<usize> {
if retention_days <= 0 {
return Ok(0);
}
let cutoff = now - Duration::days(retention_days);
let mut expired: Vec<String> = Vec::new();
{
let mut stmt = conn.prepare(
"SELECT id, trashed_at FROM notes WHERE trashed = 1 AND trashed_at IS NOT NULL",
)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let id: String = row.get(0)?;
let stamped: String = row.get(1)?;
// PARSED, not string-compared. The server writes `+00:00` offsets and this
// client writes `Z`, so two timestamps for the same instant don't sort
// against each other as text — and the failure would be silent.
//
// An unparseable stamp means "age unknown", and the only safe reading of
// that is to keep the note. Deleting on a guess is the one outcome nobody
// can undo.
let Ok(trashed_at) = DateTime::parse_from_rfc3339(&stamped) else {
continue;
};
if trashed_at.with_timezone(&Utc) < cutoff {
expired.push(id);
}
}
}
for id in &expired {
// Through delete_forever, so a `pending_deletes` tombstone is recorded. That's
// right even here: while unlinked this device holds the only copy, so if it
// links later the server should learn the note was deleted, not re-send it.
store::delete_forever(conn, id)?;
}
Ok(expired.len())
}
/// The startup sweep: runs only on an unlinked device (see the module note).
/// Returns `None` when it didn't run because the device is linked.
pub fn sweep_if_unlinked(conn: &Connection) -> rusqlite::Result<Option<usize>> {
if state::read(conn)?.server_url.is_some() {
return Ok(None);
}
sweep_expired_trash(conn, LOCAL_RETENTION_DAYS, Utc::now()).map(Some)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::local::schema;
fn db() -> Connection {
let conn = Connection::open_in_memory().expect("in-memory db");
schema::migrate(&conn).expect("migrate");
conn
}
/// A trashed note of a given age, stamped in the format the CLIENT writes
/// (`...Z`, millisecond precision — see `store::now`).
fn trashed_note_aged(conn: &Connection, id: &str, age: Duration) {
let when = Utc::now() - age;
let stamped = when.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
conn.execute(
"INSERT INTO notes (id, body, created_at, updated_at, trashed, trashed_at)
VALUES (?1, 'B', ?2, ?2, 1, ?2)",
rusqlite::params![id, stamped],
)
.expect("insert");
}
fn trashed_note(conn: &Connection, id: &str, days_ago: i64) {
trashed_note_aged(conn, id, Duration::days(days_ago));
}
fn sweep(conn: &Connection, days: i64) -> usize {
sweep_expired_trash(conn, days, Utc::now()).expect("sweep")
}
fn note_count(conn: &Connection) -> i64 {
conn.query_row("SELECT COUNT(*) FROM notes", [], |r| r.get(0))
.expect("count")
}
#[test]
fn purges_trash_past_the_window_and_keeps_the_rest() {
let conn = db();
trashed_note(&conn, "old", 40);
trashed_note(&conn, "fresh", 3);
let purged = sweep(&conn, 30);
assert_eq!(purged, 1);
assert_eq!(note_count(&conn), 1, "only the expired note should go");
}
#[test]
fn a_note_just_inside_the_window_survives() {
// 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
// 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 almost = Duration::days(30) - Duration::minutes(1);
trashed_note_aged(&conn, "boundary", almost);
assert_eq!(sweep(&conn, 30), 0);
assert_eq!(note_count(&conn), 1);
}
#[test]
fn retention_off_purges_nothing() {
let conn = db();
trashed_note(&conn, "ancient", 4000);
assert_eq!(sweep(&conn, 0), 0);
assert_eq!(sweep(&conn, -1), 0);
assert_eq!(note_count(&conn), 1);
}
#[test]
fn an_untrashed_note_is_never_swept() {
let conn = db();
conn.execute(
"INSERT INTO notes (id, body, created_at, updated_at, trashed)
VALUES ('live', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 0)",
[],
)
.expect("insert");
assert_eq!(sweep(&conn, 30), 0);
assert_eq!(note_count(&conn), 1);
}
#[test]
fn an_unparseable_timestamp_keeps_the_note() {
// "Age unknown" must never resolve to "delete it".
let conn = db();
conn.execute(
"INSERT INTO notes (id, body, created_at, updated_at, trashed, trashed_at)
VALUES ('weird', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 1, 'not a date')",
[],
)
.expect("insert");
assert_eq!(sweep(&conn, 30), 0);
assert_eq!(note_count(&conn), 1);
}
#[test]
fn a_server_style_offset_timestamp_is_understood() {
// The server serializes with a `+00:00` offset, not `Z`. Comparing those as
// strings would quietly never match — this is the case that catches it.
let conn = db();
let stamped = (Utc::now() - Duration::days(40)).to_rfc3339();
conn.execute(
"INSERT INTO notes (id, body, created_at, updated_at, trashed, trashed_at)
VALUES ('server', 'B', ?1, ?1, 1, ?1)",
rusqlite::params![stamped],
)
.expect("insert");
assert_eq!(sweep(&conn, 30), 1);
}
#[test]
fn a_purged_note_leaves_a_pending_delete_behind() {
// Without the tombstone, linking this device later would let the server
// re-send a note the user already destroyed here.
let conn = db();
trashed_note(&conn, "old", 40);
sweep(&conn, 30);
let pending: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pending_deletes WHERE entity = 'note' AND id = 'old'",
[],
|r| r.get(0),
)
.expect("count");
assert_eq!(pending, 1);
}
#[test]
fn a_linked_device_does_not_sweep() {
// The whole safety rule: with a server present, purging is the server's call.
let conn = db();
trashed_note(&conn, "old", 400);
state::set_link(&conn, "https://notes.example", "token").expect("link");
assert_eq!(sweep_if_unlinked(&conn).expect("sweep"), None);
assert_eq!(
note_count(&conn),
1,
"the note must survive on a linked device"
);
}
#[test]
fn an_unlinked_device_sweeps() {
let conn = db();
trashed_note(&conn, "old", 400);
assert_eq!(sweep_if_unlinked(&conn).expect("sweep"), Some(1));
assert_eq!(note_count(&conn), 0);
}
}
+499
View File
@@ -0,0 +1,499 @@
//! Local SQLite schema + migrations. The schema mirrors the note/label model so an
//! offline note can later sync 1:1 with the server. Each syncable row carries local
//! `sync_revision` + `dirty` bookkeeping (consumed by the sync engine in M10.7);
//! `#tags` are NOT stored as such (derived at query time into labels), matching
//! docs/sync.md.
//!
//! Migrations are gated on `PRAGMA user_version`; bump it and add a block per change.
use rusqlite::{params, Connection, OptionalExtension};
use crate::local::derive;
const SCHEMA_V1: &str = r#"
CREATE TABLE notes (
id TEXT PRIMARY KEY,
title TEXT,
body TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'default', -- dropped in v9; kept so DROP COLUMN has something to drop
kind TEXT NOT NULL DEFAULT 'text', -- dropped in v6; kept so DROP COLUMN has something to drop
position INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
trashed INTEGER NOT NULL DEFAULT 0,
remind_at TEXT,
recurrence TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
sync_revision INTEGER NOT NULL DEFAULT 0,
dirty INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE labels (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT 'default',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
sync_revision INTEGER NOT NULL DEFAULT 0,
dirty INTEGER NOT NULL DEFAULT 1
);
CREATE UNIQUE INDEX idx_labels_name ON labels (lower(name));
CREATE TABLE note_labels (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
via_tag INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (note_id, label_id)
);
CREATE INDEX idx_note_labels_label ON note_labels (label_id);
CREATE TABLE checklist_items (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
text TEXT NOT NULL DEFAULT '',
checked INTEGER NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_items_note ON checklist_items (note_id);
-- Both dropped in v8; kept here so an existing database has something to migrate
-- FROM, exactly as `kind` above is kept for v6.
CREATE TABLE attachments (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
url TEXT NOT NULL,
filename TEXT,
mime TEXT NOT NULL DEFAULT 'application/octet-stream',
size INTEGER,
sha256 TEXT,
position INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_attachments_note ON attachments (note_id);
CREATE TABLE link_previews (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
url TEXT NOT NULL,
title TEXT,
description TEXT,
image_url TEXT,
site_name TEXT,
position INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_previews_note ON link_previews (note_id);
CREATE TABLE note_revisions (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
title TEXT,
body TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX idx_revisions_note ON note_revisions (note_id, created_at);
CREATE TABLE saved_filters (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
params TEXT NOT NULL DEFAULT '{}', -- NoteFacets JSON
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
-- Single-row sync bookkeeping (server URL / device token / last-consumed cursor).
CREATE TABLE sync_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
server_url TEXT,
device_token TEXT,
last_cursor TEXT
);
INSERT INTO sync_state (id) VALUES (1);
"#;
// v2 (M10.7c): local tombstones.
//
// A permanent delete previously just dropped the row, which left NO record that it
// ever existed. Offline, that means the delete can never be pushed — and the next
// pull would faithfully resurrect the note from the server. A deletion that undoes
// 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#"
CREATE TABLE pending_deletes (
entity TEXT NOT NULL, -- 'note' | 'label'
id TEXT NOT NULL,
deleted_at TEXT NOT NULL,
PRIMARY KEY (entity, id)
);
"#;
// v3 (M10.7e): when the last successful sync finished.
//
// 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.
// The UI needs a timestamp to say anything honest.
const SCHEMA_V3: &str = r#"
ALTER TABLE sync_state ADD COLUMN last_sync_at TEXT;
"#;
// v4 (M11.3): WHEN a note was trashed.
//
// The table only ever recorded THAT a note was trashed, which is enough to draw a
// Trash view and nothing else. Retention needs an age: without a timestamp there is
// no way to tell a note trashed this morning from one trashed last spring, so an
// 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.
// Once linked, the server's policy is the one that actually applies, so showing this
// device's offline default would put a countdown on screen that doesn't match what
// happens — a wrong deadline is worse than none.
const SCHEMA_V4: &str = r#"
ALTER TABLE notes ADD COLUMN trashed_at TEXT;
UPDATE notes SET trashed_at = updated_at WHERE trashed = 1;
ALTER TABLE sync_state ADD COLUMN server_retention_days INTEGER;
"#;
// v5 (M10.9): small key/value app preferences.
//
// 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
// purpose: the next device-local preference shouldn't need another migration.
const SCHEMA_V5: &str = r#"
CREATE TABLE prefs (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"#;
// v6 (M13 step 2): `kind` is gone. A checklist is something a note HAS, not something
// a note IS — the column was a mode flag with no enum and no constraint behind it,
// and `note_items` was never tied to it. Dropping it loses nothing: a note that was
// 'list' keeps every one of its items.
//
// SQLite has supported DROP COLUMN since 3.35 (2021); rusqlite bundles well past it.
const SCHEMA_V6: &str = r#"
ALTER TABLE notes DROP COLUMN kind;
"#;
// v7 (M13 step 3): the title field is gone. 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 —
// derived at read time, never stored (see store::display_title).
//
// note_revisions loses its copy for the same reason: a revision snapshots a body.
const SCHEMA_V7: &str = r#"
ALTER TABLE notes DROP COLUMN title;
ALTER TABLE note_revisions DROP COLUMN title;
"#;
// v8 (M304): `checklist_items` is gone. The body IS the checklist — a `- [ ] milk`
// 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.
//
// Rust rather than a SQL const, for two reasons. The fold has to produce EXACTLY what
// `derive::append_item` produces, and expressing that in SQL would be a second
// implementation of the layout rule. And `group_concat` only gained a guaranteed
// ORDER BY in SQLite 3.44 — a checklist that silently reordered itself during the
// migration would be a poor way to find that out.
//
// `updated_at` and `dirty` are deliberately NOT touched. The server's Alembic
// migration folds the same rows with the same spacing, so both sides land on
// identical bodies and this needs no sync at all; marking every note dirty would
// push a body the server already has, and would do it for every device at once.
fn migrate_v8(conn: &Connection) -> rusqlite::Result<()> {
// Grouped in one pass — the query is ordered by note, so a change of note_id is
// the group boundary. `rowid` breaks ties, because `position` was only ever
// advisory and two rows sharing one is not a reason to reorder someone's list.
let mut grouped: Vec<(String, Vec<(String, bool)>)> = Vec::new();
{
let mut stmt = conn.prepare(
"SELECT note_id, text, checked FROM checklist_items
ORDER BY note_id ASC, position ASC, rowid ASC",
)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let note_id: String = row.get(0)?;
let text: String = row.get(1)?;
let checked: bool = row.get(2)?;
match grouped.last_mut() {
Some((id, items)) if *id == note_id => items.push((text, checked)),
_ => grouped.push((note_id, vec![(text, checked)])),
}
}
}
for (note_id, items) in grouped {
let existing: Option<String> = conn
.query_row("SELECT body FROM notes WHERE id = ?1", [&note_id], |r| {
r.get(0)
})
.optional()?;
// An item whose note is already gone has nothing to fold into. The foreign key
// should make this impossible; skipping costs nothing, and failing here would
// leave the only copy of someone's notes half-migrated.
let mut body = match existing {
Some(b) => b,
None => continue,
};
for (text, checked) in items {
body = derive::append_item(&body, &text, checked);
}
conn.execute(
"UPDATE notes SET body = ?1 WHERE id = ?2",
params![body, note_id],
)?;
}
conn.execute_batch(
"DROP INDEX IF EXISTS idx_items_note;
DROP TABLE checklist_items;",
)?;
Ok(())
}
/// Bring the database up to the latest schema. Idempotent.
// v9 (M315): `notes.color` is gone. A card is one neutral surface now and colour lives
// only on a tag, so the column was written by a picker nothing read and read by nothing
// 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
// 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
// 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.
//
// The second guard is a LIKE and not `json_extract(...) IS NOT NULL`, which is the
// obvious way to write it and is a trap: SQLite does not promise to short-circuit AND,
// so `json_extract` can be evaluated against the very rows `json_valid` was there to
// exclude — and on malformed input it does not return NULL, it RAISES, which would
// abort the migration for every other row too. `LIKE` is total over any text.
const SCHEMA_V9: &str = r#"
ALTER TABLE notes DROP COLUMN color;
UPDATE saved_filters
SET params = json_remove(params, '$.color')
WHERE json_valid(params)
AND params LIKE '%"color"%';
"#;
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
if version < 1 {
conn.execute_batch(SCHEMA_V1)?;
conn.execute_batch("PRAGMA user_version = 1;")?;
}
if version < 2 {
conn.execute_batch(SCHEMA_V2)?;
conn.execute_batch("PRAGMA user_version = 2;")?;
}
if version < 3 {
conn.execute_batch(SCHEMA_V3)?;
conn.execute_batch("PRAGMA user_version = 3;")?;
}
if version < 4 {
conn.execute_batch(SCHEMA_V4)?;
conn.execute_batch("PRAGMA user_version = 4;")?;
}
if version < 5 {
conn.execute_batch(SCHEMA_V5)?;
conn.execute_batch("PRAGMA user_version = 5;")?;
}
if version < 6 {
conn.execute_batch(SCHEMA_V6)?;
conn.execute_batch("PRAGMA user_version = 6;")?;
}
if version < 7 {
conn.execute_batch(SCHEMA_V7)?;
conn.execute_batch("PRAGMA user_version = 7;")?;
}
if version < 8 {
migrate_v8(conn)?;
conn.execute_batch("PRAGMA user_version = 8;")?;
}
if version < 9 {
conn.execute_batch(SCHEMA_V9)?;
conn.execute_batch("PRAGMA user_version = 9;")?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// A database as it stood before M304 — items still in their own table.
fn v7_db() -> Connection {
let conn = Connection::open_in_memory().expect("open");
conn.execute_batch("PRAGMA foreign_keys = ON;").expect("fk");
for batch in [
SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4, SCHEMA_V5, SCHEMA_V6, SCHEMA_V7,
] {
conn.execute_batch(batch).expect("batch");
}
conn.execute_batch("PRAGMA user_version = 7;").expect("v7");
conn
}
fn add_note(conn: &Connection, id: &str, body: &str) {
conn.execute(
"INSERT INTO notes (id, body, created_at, updated_at)
VALUES (?1, ?2, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')",
params![id, body],
)
.expect("note");
}
fn add_item(conn: &Connection, note: &str, text: &str, checked: bool, pos: i64) {
conn.execute(
"INSERT INTO checklist_items (id, note_id, text, checked, position)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![format!("{note}-{pos}"), note, text, checked, pos],
)
.expect("item");
}
fn body_of(conn: &Connection, id: &str) -> String {
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))
.expect("body")
}
#[test]
fn v8_folds_items_into_the_body() {
let conn = v7_db();
add_note(&conn, "n1", "shopping");
add_item(&conn, "n1", "milk", false, 0);
add_item(&conn, "n1", "eggs", true, 1);
migrate(&conn).expect("migrate");
// Prose, blank line, list — the layout _note_markdown already exports, so an
// export taken before this migration and one taken after agree byte for byte.
assert_eq!(body_of(&conn, "n1"), "shopping\n\n- [ ] milk\n- [x] eggs");
}
#[test]
fn v8_keeps_a_list_only_note_whole() {
let conn = v7_db();
add_note(&conn, "n1", "");
add_item(&conn, "n1", "milk", false, 0);
migrate(&conn).expect("migrate");
assert_eq!(body_of(&conn, "n1"), "- [ ] milk");
}
#[test]
fn v8_leaves_timestamps_alone() {
// The whole reason this needs no sync: the server folds the same rows the same
// way, so both sides already agree. Marking notes dirty would push a body the
// server has, from every device at once.
let conn = v7_db();
add_note(&conn, "n1", "note");
add_item(&conn, "n1", "milk", false, 0);
migrate(&conn).expect("migrate");
let (updated, dirty): (String, i64) = conn
.query_row(
"SELECT updated_at, dirty FROM notes WHERE id = 'n1'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.expect("row");
assert_eq!(updated, "2026-01-01T00:00:00.000Z");
assert_eq!(dirty, 1); // as inserted, not raised by the migration
}
#[test]
fn v8_drops_the_table_and_is_idempotent() {
let conn = v7_db();
add_note(&conn, "n1", "note");
migrate(&conn).expect("migrate");
migrate(&conn).expect("again");
let exists: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='checklist_items'",
[],
|r| r.get(0),
)
.expect("count");
assert_eq!(exists, 0);
}
#[test]
fn a_fresh_database_reaches_the_latest_version() {
let conn = Connection::open_in_memory().expect("open");
migrate(&conn).expect("migrate");
let version: i64 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.expect("version");
assert_eq!(version, 9);
}
/// The column is gone, not merely unread. Asserted by asking SQLite rather than by
/// reading a row: a SELECT that omits `color` would pass either way.
#[test]
fn v9_drops_the_note_colour_column() {
let conn = Connection::open_in_memory().expect("open");
migrate(&conn).expect("migrate");
let mut stmt = conn.prepare("PRAGMA table_info(notes)").expect("pragma");
let columns: Vec<String> = stmt
.query_map([], |r| r.get::<_, String>(1))
.expect("query")
.collect::<rusqlite::Result<Vec<String>>>()
.expect("collect");
assert!(!columns.iter().any(|c| c == "color"));
// The one that survived. Getting this wrong would take every tag's colour with
// it, which is the whole thing M315 was keeping.
let mut stmt = conn.prepare("PRAGMA table_info(labels)").expect("pragma");
let label_columns: Vec<String> = stmt
.query_map([], |r| r.get::<_, String>(1))
.expect("query")
.collect::<rusqlite::Result<Vec<String>>>()
.expect("collect");
assert!(label_columns.iter().any(|c| c == "color"));
}
/// A stored view that filtered on colour loses that criterion and keeps the rest.
/// The alternative — leaving the key — is a lens that silently narrows on a field
/// the app no longer has and never says why it returned nothing.
#[test]
fn v9_sweeps_colour_out_of_saved_filters() {
let conn = Connection::open_in_memory().expect("open");
conn.execute_batch("PRAGMA foreign_keys = ON;").expect("fk");
for batch in [
SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4, SCHEMA_V5, SCHEMA_V6, SCHEMA_V7,
] {
conn.execute_batch(batch).expect("schema");
}
conn.execute_batch("PRAGMA user_version = 8;").expect("v8");
for (id, params) in [
("a", r#"{"color":"teal","q":"milk"}"#),
("b", r#"{"q":"eggs"}"#),
// Not JSON at all. It must come out UNCHANGED rather than NULL — a blob
// this migration cannot read is not a blob it gets to destroy.
("c", "not json"),
] {
conn.execute(
"INSERT INTO saved_filters (id, name, params, created_at)
VALUES (?1, ?1, ?2, '2026-08-28T00:00:00.000Z')",
params![id, params],
)
.expect("seed");
}
migrate(&conn).expect("migrate");
let read = |id: &str| -> String {
conn.query_row(
"SELECT params FROM saved_filters WHERE id = ?1",
[id],
|r| r.get(0),
)
.expect("read")
};
assert_eq!(read("a"), r#"{"q":"milk"}"#);
assert_eq!(read("b"), r#"{"q":"eggs"}"#);
assert_eq!(read("c"), "not json");
}
}
@@ -7,13 +7,14 @@
//! ("YYYY-MM-DDTHH:MM:SS.sssZ") so string ordering and date-range comparisons line
//! up with the values the frontend sends.
use chrono::{Duration, SecondsFormat, Utc};
use chrono::{DateTime, Duration, SecondsFormat, Utc};
use rusqlite::{params, params_from_iter, Connection, OptionalExtension};
use serde_json::Value;
use serde_json::{json, Value};
use uuid::Uuid;
use crate::local::derive;
use crate::local::models::*;
use crate::local::recur;
fn now() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
@@ -23,28 +24,25 @@ fn new_id() -> String {
Uuid::new_v4().to_string()
}
/// title if non-empty, else the first non-blank body line — always a string.
fn display_title(title: Option<&str>, body: &str) -> String {
if let Some(t) = title {
let t = t.trim();
if !t.is_empty() {
return t.to_string();
/// The note's NAME: the first line of its body that says anything.
///
/// Mirrors `derive_display_title` in the server's notes/helpers.py — one rule written
/// twice, and they have to agree or a synced note is called different things on either
/// side of the wire.
///
/// It no longer needs the items, because the items ARE lines of the body now (M304).
/// What it needs instead is to strip the task marker off: a list-only note is still
/// 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
/// naming the note "", which is what a half-typed list would otherwise do.
fn display_title(body: &str) -> String {
for line in body.lines() {
let text = derive::strip_marker(line.trim()).trim();
if !text.is_empty() {
return text.to_string();
}
}
body.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.unwrap_or("")
.to_string()
}
fn normalize_title(raw: &str) -> Option<String> {
let t = raw.trim();
if t.is_empty() {
None
} else {
Some(t.to_string())
}
String::new()
}
fn escape_like(s: &str) -> String {
@@ -72,19 +70,24 @@ fn load_labels(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<NoteLab
rows.collect()
}
fn load_items(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<ChecklistItem>> {
let mut stmt = conn.prepare(
"SELECT id, text, checked, position FROM checklist_items WHERE note_id = ?1 ORDER BY position ASC",
)?;
let rows = stmt.query_map([note_id], |r| {
Ok(ChecklistItem {
id: r.get(0)?,
text: r.get(1)?,
checked: r.get(2)?,
position: r.get(3)?,
/// 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
/// uuid — which is all it ever amounted to anyway, since `push.rs` sent text and
/// checked and never an id, and both sides replaced the whole list on every sync. It
/// 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> {
derive::extract_items(body)
.into_iter()
.enumerate()
.map(|(i, item)| ChecklistItem {
id: i.to_string(),
text: item.text,
checked: item.checked,
position: i as i64,
})
})?;
rows.collect()
.collect()
}
fn load_attachments(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<Attachment>> {
@@ -92,13 +95,28 @@ fn load_attachments(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<At
"SELECT id, url, filename, mime, size, sha256 FROM attachments WHERE note_id = ?1 ORDER BY position ASC",
)?;
let rows = stmt.query_map([note_id], |r| {
let server_url: String = r.get(1)?;
let mime: String = r.get(3)?;
let sha256: Option<String> = r.get(5)?;
Ok(Attachment {
id: r.get(0)?,
url: r.get(1)?,
// Point at the LOCAL bytes, not the server's route. The stored url is the
// server's relative path, which resolves against the app origin in the
// webview and 404s — and even absolute it would need a bearer token the
// webview never sends. Rewriting here rather than at each render site
// means NoteCard and NoteEditor stay untouched and can't drift.
//
// Without a hash there's nothing to address the blob by (an older server
// that predates the sha256 column), so the original url is left alone:
// still broken, but no more broken than it already was.
url: match sha256.as_deref() {
Some(hash) if !hash.is_empty() => crate::sync::blobs::url_for(hash, &mime),
_ => server_url,
},
filename: r.get(2)?,
mime: r.get(3)?,
mime,
size: r.get(4)?,
sha256: r.get(5)?,
sha256,
})
})?;
rows.collect()
@@ -123,39 +141,36 @@ fn load_previews(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<LinkP
fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
let mut note = conn.query_row(
"SELECT id, title, body, color, kind, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at
"SELECT id, body, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
FROM notes WHERE id = ?1",
[id],
|r| {
let title: Option<String> = r.get(1)?;
let body: String = r.get(2)?;
let dt = display_title(title.as_deref(), &body);
let body: String = r.get(1)?;
Ok(Note {
id: r.get(0)?,
title,
display_title: dt,
display_title: String::new(), // filled below — it may need a query
body,
color: r.get(3)?,
kind: r.get(4)?,
position: r.get(5)?,
pinned: r.get(6)?,
archived: r.get(7)?,
trashed: r.get(8)?,
remind_at: r.get(9)?,
recurrence: r.get(10)?,
position: r.get(2)?,
pinned: r.get(3)?,
archived: r.get(4)?,
trashed: r.get(5)?,
deleted_at: r.get(10)?,
remind_at: r.get(6)?,
recurrence: r.get(7)?,
labels: Vec::new(),
items: Vec::new(),
attachments: Vec::new(),
previews: Vec::new(),
created_at: r.get(11)?,
updated_at: r.get(12)?,
created_at: r.get(9)?,
updated_at: r.get(10)?,
})
},
)?;
note.labels = load_labels(conn, id)?;
note.items = load_items(conn, id)?;
note.items = items_of(&note.body);
note.attachments = load_attachments(conn, id)?;
note.previews = load_previews(conn, id)?;
note.display_title = display_title(&note.body);
Ok(note)
}
@@ -189,34 +204,89 @@ fn find_or_create_label(conn: &Connection, name: &str) -> rusqlite::Result<Strin
Ok(id)
}
/// Re-sync the note's `via_tag` labels to exactly the `#tags` in its body.
fn sync_tags(conn: &Connection, note_id: &str, body: &str) -> rusqlite::Result<()> {
let tags = derive::extract_tags(body);
let mut desired: Vec<String> = Vec::with_capacity(tags.len());
for t in &tags {
desired.push(find_or_create_label(conn, t)?);
/// Attach the note's tag labels, LIFT its standalone tags out of the body, and write
/// the shortened body back.
///
/// NAMED FOR THE MUTATION. It used to be `sync_tags` and only touched label rows; it
/// now rewrites `notes.body`, and every caller writes the body just before calling —
/// so this overwrites what they wrote, on purpose.
///
/// `display_title` needs no attention here, unlike on the server: the core derives it
/// on READ (see `display_title` above, called from `load_note`) rather than storing
/// 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`
/// means from here on — backed by text still in the body:
///
/// standalone lifted out, attached as an ORDINARY label. Nothing derives it any
/// more, and the way to remove it becomes the chip's ×.
/// inline left in place, attached via_tag = 1, still detached when its text
/// goes. Unchanged from before.
///
/// 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<()> {
let (standalone, inline, lifted) = derive::lift_standalone_tags(body);
let mut standalone_ids: Vec<String> = Vec::with_capacity(standalone.len());
for name in &standalone {
standalone_ids.push(find_or_create_label(conn, name)?);
}
let mut inline_ids: Vec<String> = Vec::with_capacity(inline.len());
for name in &inline {
inline_ids.push(find_or_create_label(conn, name)?);
}
let current: Vec<String> = {
let current: Vec<(String, bool)> = {
let mut stmt =
conn.prepare("SELECT label_id FROM note_labels WHERE note_id = ?1 AND via_tag = 1")?;
let rows = stmt.query_map([note_id], |r| r.get::<_, String>(0))?;
rows.collect::<rusqlite::Result<Vec<String>>>()?
conn.prepare("SELECT label_id, via_tag FROM note_labels WHERE note_id = ?1")?;
let rows = stmt.query_map([note_id], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, bool>(1)?))
})?;
rows.collect::<rusqlite::Result<Vec<(String, bool)>>>()?
};
for lid in &current {
if !desired.contains(lid) {
for (lid, via_tag) in &current {
if !*via_tag {
continue; // manual already: a #tag of the same name changes nothing
}
if standalone_ids.contains(lid) {
// It GRADUATED. The text backing it is about to go, so the row has to
// become the record instead — and BEFORE the delete below, or the same row
// is dropped for no longer being in the body. That is the bug a naive lift
// has, and it silently loses the tag.
conn.execute(
"UPDATE note_labels SET via_tag = 0 WHERE note_id = ?1 AND label_id = ?2",
params![note_id, lid],
)?;
} else if !inline_ids.contains(lid) {
conn.execute(
"DELETE FROM note_labels WHERE note_id = ?1 AND label_id = ?2 AND via_tag = 1",
params![note_id, lid],
)?;
}
}
for lid in &desired {
// OR IGNORE leaves a label already attached in ANY form alone, which is what keeps
// a manually-added label of the same name manual.
for lid in &standalone_ids {
conn.execute(
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 0)",
params![note_id, lid],
)?;
}
for lid in &inline_ids {
conn.execute(
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 1)",
params![note_id, lid],
)?;
}
if lifted != body {
conn.execute(
"UPDATE notes SET body = ?1 WHERE id = ?2",
params![lifted, note_id],
)?;
}
Ok(())
}
@@ -251,19 +321,11 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note
if let Some(f) = &q.facets {
if let Some(text) = f.q.as_deref().filter(|s| !s.is_empty()) {
sql.push_str(" AND (title LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')");
sql.push_str(" AND body LIKE ? ESCAPE '\\'");
let pat = format!("%{}%", escape_like(text));
binds.push(pat.clone());
binds.push(pat);
}
if let Some(c) = f.color.as_deref().filter(|s| !s.is_empty()) {
sql.push_str(" AND color = ?");
binds.push(c.to_string());
}
if let Some(k) = f.kind.as_deref().filter(|s| !s.is_empty()) {
sql.push_str(" AND kind = ?");
binds.push(k.to_string());
}
if f.has_reminder == Some(true) {
sql.push_str(" AND remind_at IS NOT NULL");
}
@@ -309,23 +371,31 @@ pub fn reminders(conn: &Connection) -> rusqlite::Result<Vec<Note>> {
}
pub fn titles(conn: &Connection) -> rusqlite::Result<Vec<TitleEntry>> {
let mut stmt = conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0")?;
let rows = stmt.query_map([], |r| {
let title: Option<String> = r.get(1)?;
let body: String = r.get(2)?;
Ok(TitleEntry {
id: r.get(0)?,
title: display_title(title.as_deref(), &body),
// Names come from `load_note` rather than from a bare row, because a note whose
// body is empty is named by its first checklist item — which a row here doesn't
// have. The command palette reads this; correctness beats one query per note at
// personal scale.
let ids: Vec<String> = {
let mut stmt = conn.prepare("SELECT id FROM notes WHERE trashed = 0")?;
let rows = stmt.query_map([], |r| r.get(0))?;
rows.collect::<rusqlite::Result<Vec<String>>>()?
};
ids.iter()
.map(|id| {
let note = load_note(conn, id)?;
Ok(TitleEntry {
id: note.id,
title: note.display_title,
})
})
})?;
rows.collect()
.collect()
}
pub fn search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<Note>> {
let pat = format!("%{}%", escape_like(q));
let ids: Vec<String> = {
let mut stmt = conn.prepare(
"SELECT id FROM notes WHERE trashed = 0 AND (title LIKE ?1 ESCAPE '\\' OR body LIKE ?1 ESCAPE '\\') ORDER BY updated_at DESC",
"SELECT id FROM notes WHERE trashed = 0 AND body LIKE ?1 ESCAPE '\\' ORDER BY updated_at DESC",
)?;
let rows = stmt.query_map([&pat], |r| r.get::<_, String>(0))?;
rows.collect::<rusqlite::Result<Vec<String>>>()?
@@ -333,109 +403,78 @@ pub fn search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<Note>> {
ids.iter().map(|id| load_note(conn, id)).collect()
}
pub fn backlinks(conn: &Connection, id: &str) -> rusqlite::Result<Vec<Backlink>> {
let target: String = {
let (t, b): (Option<String>, String) =
conn.query_row("SELECT title, body FROM notes WHERE id = ?1", [id], |r| {
Ok((r.get(0)?, r.get(1)?))
})?;
display_title(t.as_deref(), &b)
};
if target.is_empty() {
return Ok(Vec::new());
}
let mut stmt =
conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0 AND id != ?1")?;
let rows = stmt.query_map([id], |r| {
let nid: String = r.get(0)?;
let t: Option<String> = r.get(1)?;
let b: String = r.get(2)?;
Ok((nid, t, b))
})?;
let mut out = Vec::new();
for row in rows {
let (nid, t, b) = row?;
if derive::extract_links(&b)
.iter()
.any(|l| l.eq_ignore_ascii_case(&target))
{
out.push(Backlink {
id: nid,
title: display_title(t.as_deref(), &b),
});
}
}
Ok(out)
}
pub fn link_search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<TitleEntry>> {
let ql = q.trim().to_lowercase();
let mut stmt = conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0")?;
let rows = stmt.query_map([], |r| {
let id: String = r.get(0)?;
let t: Option<String> = r.get(1)?;
let b: String = r.get(2)?;
Ok((id, t, b))
})?;
let mut out = Vec::new();
for row in rows {
let (id, t, b) = row?;
let dt = display_title(t.as_deref(), &b);
if ql.is_empty() || dt.to_lowercase().contains(&ql) {
out.push(TitleEntry { id, title: dt });
}
}
Ok(out)
}
// ---- notes: write -----------------------------------------------------------
pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Result<Note> {
let id = new_id();
let ts = now();
let title = normalize_title(&input.title);
let kind = input.kind.clone().unwrap_or_else(|| "text".to_string());
let position: i64 = conn.query_row(
"SELECT COALESCE(MAX(position), 0) + 1 FROM notes",
[],
|r| r.get(0),
)?;
conn.execute(
"INSERT INTO notes (id, title, body, color, kind, position, created_at, updated_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, 1)",
params![id, title, input.body, input.color, kind, position, ts],
)?;
// Items fold into the body rather than into rows of their own. Callers still hand
// them over separately — the importer has a list, not a blob — but where they end
// up is one place.
let mut body = input.body.clone();
if let Some(items) = &input.items {
for (i, text) in items.iter().enumerate() {
conn.execute(
"INSERT INTO checklist_items (id, note_id, text, position) VALUES (?1, ?2, ?3, ?4)",
params![new_id(), id, text, i as i64],
)?;
for text in items {
body = derive::append_item(&body, text, false);
}
}
sync_tags(conn, &id, &input.body)?;
conn.execute(
"INSERT INTO notes (id, body, position, created_at, updated_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?4, 1)",
params![id, body, position, ts],
)?;
// The FOLDED body, not the input one: an item can carry a #tag too.
lift_and_sync_tags(conn, &id, &body)?;
load_note(conn, &id)
}
pub fn create_titled(conn: &Connection, title: &str) -> rusqlite::Result<Note> {
let input = NoteCreateInput {
title: title.to_string(),
body: String::new(),
color: "default".to_string(),
kind: None,
items: None,
};
create_note(conn, &input)
/// How long one editing session is assumed to last.
///
/// Inside this window a note's body may be written any number of times and only the
/// FIRST write snapshots. That is what makes an idle-debounced autosave affordable:
/// a write costs a write, not a write plus a revision.
const REVISION_WINDOW_MINUTES: i64 = 10;
/// Whether a body change earns a snapshot of the pre-edit body.
///
/// Two conditions. The body must actually differ — re-saving identical text is not a
/// version of anything. And the note must not already carry a revision from this
/// editing session.
///
/// The session rule is what keeps version history worth reading. Because
/// [`snapshot_revision`] stores the body as it was BEFORE the edit, the first write
/// of a session captures the note as you found it, and every write after it inside
/// the window adds nothing. One revision per sitting falls out of the window on its
/// own — no "commit" the client has to declare, and no protocol surface to carry it,
/// which matters because sync-apply takes this same path.
fn should_snapshot(conn: &Connection, id: &str, new_body: &str) -> rusqlite::Result<bool> {
let current: String =
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?;
if current == new_body {
return Ok(false);
}
// String comparison, not date maths: timestamps are RFC3339 UTC with a fixed
// millisecond field (see the module header), so lexical order IS chronological.
let cutoff = (Utc::now() - Duration::minutes(REVISION_WINDOW_MINUTES))
.to_rfc3339_opts(SecondsFormat::Millis, true);
let recent: i64 = conn.query_row(
"SELECT COUNT(*) FROM note_revisions WHERE note_id = ?1 AND created_at >= ?2",
params![id, cutoff],
|r| r.get(0),
)?;
Ok(recent == 0)
}
fn snapshot_revision(conn: &Connection, id: &str) -> rusqlite::Result<()> {
let (title, body): (Option<String>, String) =
conn.query_row("SELECT title, body FROM notes WHERE id = ?1", [id], |r| {
Ok((r.get(0)?, r.get(1)?))
})?;
let body: String =
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?;
conn.execute(
"INSERT INTO note_revisions (id, note_id, title, body, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params![new_id(), id, title, body, now()],
"INSERT INTO note_revisions (id, note_id, body, created_at) VALUES (?1, ?2, ?3, ?4)",
params![new_id(), id, body, now()],
)?;
Ok(())
}
@@ -446,37 +485,23 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
.as_object()
.ok_or_else(|| rusqlite::Error::InvalidParameterName("changes must be an object".into()))?;
// Snapshot the pre-edit title/body once if either is being changed (version history).
if obj.contains_key("title") || obj.contains_key("body") {
snapshot_revision(conn, id)?;
// Snapshot the pre-edit body before changing it (version history) — but only
// once per editing session, and only if it actually changed. See should_snapshot.
if let Some(body) = obj.get("body").and_then(|v| v.as_str()) {
if should_snapshot(conn, id, body)? {
snapshot_revision(conn, id)?;
}
}
for (k, v) in obj {
match k.as_str() {
"title" => {
let norm = v.as_str().and_then(normalize_title);
conn.execute(
"UPDATE notes SET title = ?1 WHERE id = ?2",
params![norm, id],
)?;
}
"body" => {
let body = v.as_str().unwrap_or("");
conn.execute(
"UPDATE notes SET body = ?1 WHERE id = ?2",
params![body, id],
)?;
sync_tags(conn, id, body)?;
}
"color" => {
if let Some(s) = v.as_str() {
conn.execute("UPDATE notes SET color = ?1 WHERE id = ?2", params![s, id])?;
}
}
"kind" => {
if let Some(s) = v.as_str() {
conn.execute("UPDATE notes SET kind = ?1 WHERE id = ?2", params![s, id])?;
}
lift_and_sync_tags(conn, id, body)?;
}
"pinned" => {
if let Some(b) = v.as_bool() {
@@ -513,9 +538,38 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
load_note(conn, id)
}
/// Mark a reminder handled.
///
/// A recurring reminder advances to its next occurrence; a one-off clears both
/// `remind_at` AND `recurrence`. Clearing the rule as well matters: without it a
/// note whose recurrence is a value we do not recognise would keep that value
/// forever, invisible in every UI (they only render known rules) and waiting to
/// mean something the day the vocabulary grows.
///
/// Same behaviour as the server's `POST /<id>/reminder/complete`, deliberately —
/// the same note can be completed from a browser or from a client, and a
/// disagreement here would move a reminder depending on which one you used.
pub fn complete_reminder(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
// Clear the reminder. (Recurrence advancement is a later refinement.)
conn.execute("UPDATE notes SET remind_at = NULL WHERE id = ?1", [id])?;
let note = load_note(conn, id)?;
let next = note
.remind_at
.as_deref()
.and_then(|at| DateTime::parse_from_rfc3339(at).ok())
.and_then(|at| {
let rule = recur::normalize(note.recurrence.as_deref())?;
recur::next_occurrence(at.with_timezone(&Utc), rule, Utc::now())
});
match next {
Some(at) => conn.execute(
"UPDATE notes SET remind_at = ?1 WHERE id = ?2",
params![at.to_rfc3339_opts(SecondsFormat::Millis, true), id],
)?,
None => conn.execute(
"UPDATE notes SET remind_at = NULL, recurrence = NULL WHERE id = ?1",
[id],
)?,
};
touch(conn, id)?;
load_note(conn, id)
}
@@ -546,18 +600,32 @@ pub fn set_labels(conn: &Connection, id: &str, label_ids: &[String]) -> rusqlite
load_note(conn, id)
}
// ---- checklist items: every one of these is a body edit ---------------------
//
// They keep their own names and signatures because the FFI, the Tauri commands and
// the REST shape all speak in items, and a checklist is still a thing a note HAS.
// What changed is where it is kept. Routing all three through `update_note` rather
// than writing the body directly is what gives them revision snapshotting, `#tag`
// re-derivation and the dirty/updated_at bookkeeping without any of it being
// written a second time here.
fn note_body(conn: &Connection, id: &str) -> rusqlite::Result<String> {
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))
}
/// An item's id is its ordinal (see [items_of]). Anything else is a stale id from a
/// UI that has not reloaded, and the right answer to those is to do nothing.
fn item_index(item_id: &str) -> Option<usize> {
item_id.parse::<usize>().ok()
}
fn set_body(conn: &Connection, id: &str, body: String) -> rusqlite::Result<Note> {
update_note(conn, id, &json!({ "body": body }))
}
pub fn add_item(conn: &Connection, id: &str, text: &str) -> rusqlite::Result<Note> {
let pos: i64 = conn.query_row(
"SELECT COALESCE(MAX(position), -1) + 1 FROM checklist_items WHERE note_id = ?1",
[id],
|r| r.get(0),
)?;
conn.execute(
"INSERT INTO checklist_items (id, note_id, text, position) VALUES (?1, ?2, ?3, ?4)",
params![new_id(), id, text, pos],
)?;
touch(conn, id)?;
load_note(conn, id)
let body = note_body(conn, id)?;
set_body(conn, id, derive::append_item(&body, text, false))
}
pub fn update_item(
@@ -566,29 +634,27 @@ pub fn update_item(
item_id: &str,
changes: &Value,
) -> rusqlite::Result<Note> {
let index = match item_index(item_id) {
Some(i) => i,
None => return load_note(conn, id),
};
let mut body = note_body(conn, id)?;
if let Some(text) = changes.get("text").and_then(Value::as_str) {
conn.execute(
"UPDATE checklist_items SET text = ?1 WHERE id = ?2 AND note_id = ?3",
params![text, item_id, id],
)?;
body = derive::set_item_text(&body, index, text);
}
if let Some(checked) = changes.get("checked").and_then(Value::as_bool) {
conn.execute(
"UPDATE checklist_items SET checked = ?1 WHERE id = ?2 AND note_id = ?3",
params![checked, item_id, id],
)?;
body = derive::set_item_checked(&body, index, checked);
}
touch(conn, id)?;
load_note(conn, id)
set_body(conn, id, body)
}
pub fn delete_item(conn: &Connection, id: &str, item_id: &str) -> rusqlite::Result<Note> {
conn.execute(
"DELETE FROM checklist_items WHERE id = ?1 AND note_id = ?2",
params![item_id, id],
)?;
touch(conn, id)?;
load_note(conn, id)
let index = match item_index(item_id) {
Some(i) => i,
None => return load_note(conn, id),
};
let body = note_body(conn, id)?;
set_body(conn, id, derive::remove_item(&body, index))
}
pub fn delete_attachment(conn: &Connection, id: &str, att_id: &str) -> rusqlite::Result<Note> {
@@ -621,48 +687,92 @@ pub fn reorder(conn: &Connection, ordered_ids: &[String]) -> rusqlite::Result<()
}
pub fn trash(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
conn.execute("UPDATE notes SET trashed = 1 WHERE id = ?1", [id])?;
// COALESCE, so trashing an already-trashed note doesn't restart its retention
// clock. The server keeps its `deleted_at` the same way — a note shouldn't earn
// another 30 days because something touched it twice.
conn.execute(
"UPDATE notes SET trashed = 1, trashed_at = COALESCE(trashed_at, ?1) WHERE id = ?2",
params![now(), id],
)?;
touch(conn, id)?;
load_note(conn, id)
}
pub fn restore(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
conn.execute("UPDATE notes SET trashed = 0 WHERE id = ?1", [id])?;
conn.execute(
"UPDATE notes SET trashed = 0, trashed_at = NULL WHERE id = ?1",
[id],
)?;
touch(conn, id)?;
load_note(conn, id)
}
pub fn delete_forever(conn: &Connection, id: &str) -> rusqlite::Result<()> {
record_pending_delete(conn, "note", id)?;
conn.execute("DELETE FROM notes WHERE id = ?1", [id])?;
Ok(())
}
/// Remember that a row was permanently deleted, so the sync engine can tell the
/// server. Without this the deleted row leaves no trace at all, and the next pull
/// would resurrect it — a delete that quietly undoes itself.
///
/// Harmless when the app is unlinked: the row is simply never read, and a later push
/// gets a `noop` for an id the server never had.
pub fn record_pending_delete(conn: &Connection, entity: &str, id: &str) -> rusqlite::Result<()> {
conn.execute(
"INSERT OR REPLACE INTO pending_deletes (entity, id, deleted_at) VALUES (?1, ?2, ?3)",
params![entity, id, now()],
)?;
Ok(())
}
// ---- device-local preferences (schema v5) -----------------------------------
/// A stored preference, or `None` if it was never set. Callers supply their own
/// default rather than one being invented here — the meaning of "unset" belongs
/// with the setting, not with the storage.
pub fn pref(conn: &Connection, key: &str) -> rusqlite::Result<Option<String>> {
conn.query_row("SELECT value FROM prefs WHERE key = ?1", [key], |r| {
r.get(0)
})
.optional()
}
pub fn set_pref(conn: &Connection, key: &str, value: &str) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO prefs (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![key, value],
)?;
Ok(())
}
pub fn revisions(conn: &Connection, id: &str) -> rusqlite::Result<Vec<NoteRevision>> {
let mut stmt = conn
.prepare("SELECT id, title, body, created_at FROM note_revisions WHERE note_id = ?1 ORDER BY created_at DESC")?;
.prepare("SELECT id, body, created_at FROM note_revisions WHERE note_id = ?1 ORDER BY created_at DESC")?;
let rows = stmt.query_map([id], |r| {
Ok(NoteRevision {
id: r.get(0)?,
title: r.get(1)?,
body: r.get(2)?,
created_at: r.get(3)?,
body: r.get(1)?,
created_at: r.get(2)?,
})
})?;
rows.collect()
}
pub fn restore_revision(conn: &Connection, id: &str, rev_id: &str) -> rusqlite::Result<Note> {
let (title, body): (Option<String>, String) = conn.query_row(
"SELECT title, body FROM note_revisions WHERE id = ?1 AND note_id = ?2",
let body: String = conn.query_row(
"SELECT body FROM note_revisions WHERE id = ?1 AND note_id = ?2",
params![rev_id, id],
|r| Ok((r.get(0)?, r.get(1)?)),
|r| r.get(0),
)?;
snapshot_revision(conn, id)?;
conn.execute(
"UPDATE notes SET title = ?1, body = ?2 WHERE id = ?3",
params![title, body, id],
"UPDATE notes SET body = ?1 WHERE id = ?2",
params![body, id],
)?;
sync_tags(conn, id, &body)?;
lift_and_sync_tags(conn, id, &body)?;
touch(conn, id)?;
load_note(conn, id)
}
@@ -727,6 +837,7 @@ pub fn set_label_color(conn: &Connection, id: &str, color: &str) -> rusqlite::Re
}
pub fn remove_label(conn: &Connection, id: &str) -> rusqlite::Result<()> {
record_pending_delete(conn, "label", id)?;
conn.execute("DELETE FROM labels WHERE id = ?1", [id])?;
Ok(())
}
@@ -741,6 +852,16 @@ pub fn merge_labels(
SELECT note_id, ?2, 0 FROM note_labels WHERE label_id = ?1",
params![source_id, target_id],
)?;
// The notes that carried the source now have a different label set, and that set
// only reaches the server via the note itself (push sends label_ids per note).
// Without this the merge would look done locally and never sync. Marked BEFORE
// the delete, which cascades the membership rows away.
conn.execute(
"UPDATE notes SET dirty = 1
WHERE id IN (SELECT note_id FROM note_labels WHERE label_id = ?1)",
[source_id],
)?;
record_pending_delete(conn, "label", source_id)?;
conn.execute("DELETE FROM labels WHERE id = ?1", [source_id])?;
load_label(conn, target_id)
}
+318
View File
@@ -0,0 +1,318 @@
//! Local storage for attachment bytes (M10.7d).
//!
//! Content-addressed: a blob is filed under its own sha256, so the same image
//! attached to five notes is stored once and re-downloading it is free. The hash is
//! also the integrity check — bytes that don't hash to what the server advertised
//! are refused rather than filed under a name that lies about them.
//!
//! Attachment METADATA rides the delta feed; only the bytes come through here
//! (docs/sync.md).
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// A sha256 in lowercase hex, and nothing else.
///
/// This is a **path-safety** check, not a formatting nicety: the hash is taken
/// straight from a server response and used as a filename. Without it, a hostile or
/// buggy server could send `../../…` and steer a write outside the blob directory.
fn is_hash(candidate: &str) -> bool {
candidate.len() == 64 && candidate.bytes().all(|b| b.is_ascii_hexdigit())
}
fn digest(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(bytes);
hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
pub struct BlobStore {
root: PathBuf,
}
impl BlobStore {
/// Open (creating if needed) the blob directory.
pub fn new(root: PathBuf) -> std::io::Result<Self> {
fs::create_dir_all(&root)?;
Ok(Self { root })
}
pub fn root(&self) -> &Path {
&self.root
}
/// Where a blob lives, or `None` if the hash isn't one.
pub fn path(&self, sha256: &str) -> Option<PathBuf> {
let lower = sha256.to_ascii_lowercase();
is_hash(&lower).then(|| self.root.join(lower))
}
/// Whether we already hold these bytes. Drives the "don't download it twice"
/// skip, which is the entire point of keying by content.
pub fn has(&self, sha256: &str) -> bool {
self.path(sha256).is_some_and(|p| p.is_file())
}
/// File bytes under `expected`, refusing them if they don't hash to it.
///
/// Verifying on the way IN rather than on the way out means a corrupted transfer
/// can never be served later as if it were genuine — and the next sync simply
/// tries again, because the blob still counts as missing.
pub fn store(&self, expected: &str, bytes: &[u8]) -> Result<PathBuf, String> {
let path = self
.path(expected)
.ok_or_else(|| format!("refusing an attachment with a malformed hash: {expected}"))?;
let actual = digest(bytes);
if actual != expected.to_ascii_lowercase() {
return Err(format!(
"attachment failed its integrity check (expected {expected}, got {actual})"
));
}
fs::write(&path, bytes).map_err(|e| format!("couldn't save an attachment: {e}"))?;
Ok(path)
}
pub fn read(&self, sha256: &str) -> Option<Vec<u8>> {
fs::read(self.path(sha256)?).ok()
}
}
// --- Serving blobs to the webview (M10.7f) -----------------------------------
//
// A synced note's attachment `url` is the SERVER's relative path
// (`/api/notes/<id>/attachments/<aid>`). In the desktop webview that resolves
// against the app origin and 404s, and swapping in the absolute server URL wouldn't
// help either — that route needs a bearer token the webview won't send, and it would
// make an offline app fetch over the network to show a file it already has on disk.
//
// So the bytes are served locally, over a custom URI scheme, straight out of this
// store. The webview then caches and range-requests them like any other resource,
// which a `data:` URI would have thrown away.
/// The scheme the webview fetches attachment bytes over.
pub const BLOB_SCHEME: &str = "tsblob";
/// The blob directory, published once the app has resolved its data dir.
///
/// A `OnceLock` rather than Tauri's managed state because the scheme handler is
/// registered on the BUILDER, before `setup` has computed that path — and because
/// reading it this way keeps the handler independent of which Tauri 2.x minor
/// changed the handler's context argument.
static SERVE_ROOT: OnceLock<PathBuf> = OnceLock::new();
pub fn publish_root(root: PathBuf) {
let _ = SERVE_ROOT.set(root);
}
/// The URL an `<img>`/`<audio>`/`<a href>` should point at for these bytes.
///
/// **The two forms are not interchangeable.** A custom scheme is reachable as
/// `scheme://localhost/<path>` on Linux and macOS, but Windows and Android map it
/// onto `http://scheme.localhost/<path>`. Getting this wrong breaks exactly one
/// platform, silently, and CI cannot catch it — the runner is headless.
pub fn url_for(sha256: &str, mime: &str) -> String {
let query = urlencode(mime);
if cfg!(any(windows, target_os = "android")) {
format!("http://{BLOB_SCHEME}.localhost/{sha256}?mime={query}")
} else {
format!("{BLOB_SCHEME}://localhost/{sha256}?mime={query}")
}
}
/// Percent-encode the few characters a mime type can contain that don't belong in a
/// query value. Hand-rolled rather than adding a dependency for `/` and `+`.
fn urlencode(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 8);
for b in value.bytes() {
match b {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
fn urldecode(value: &str) -> String {
let bytes = value.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("");
if let Ok(byte) = u8::from_str_radix(hex, 16) {
out.push(byte);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// The Content-Type to serve for a claimed mime.
///
/// The mime rides in the URL and this scheme is an origin of its own, so echoing an
/// arbitrary type would let an attachment claiming `text/html` run as a document
/// there. Echoing is safe only because of the FAMILY check: nothing starting with
/// `image/` can name a scriptable type. Everything else is served as an opaque
/// download — the right treatment for an arbitrary file regardless.
fn content_type_for(mime: &str) -> String {
const RENDERABLE: &[&str] = &["image/", "audio/", "video/"];
let familiar = RENDERABLE.iter().any(|p| mime.starts_with(p)) || mime == "application/pdf";
// A header value can't carry control characters, and a mime type has no business
// being long — both would only arrive from a malformed or hostile feed.
let printable = mime.len() <= 100 && mime.bytes().all(|b| b.is_ascii_graphic());
if familiar && printable {
mime.to_string()
} else {
"application/octet-stream".to_string()
}
}
/// Serve one request from the blob store. `path` is the URI path, `query` its query.
pub fn serve(path: &str, query: Option<&str>) -> (u16, String, Vec<u8>) {
let requested = path.trim_start_matches('/');
let Some(root) = SERVE_ROOT.get() else {
// A request before the store was published — nothing to serve yet.
return (503, "text/plain".into(), Vec::new());
};
let store = BlobStore { root: root.clone() };
// `read` goes through `path`, which rejects anything that isn't a bare sha256 —
// so this handler inherits the traversal guard rather than re-implementing it.
let Some(bytes) = store.read(requested) else {
return (404, "text/plain".into(), Vec::new());
};
let claimed = query
.and_then(|q| q.split('&').find_map(|p| p.strip_prefix("mime=")))
.map(urldecode)
.unwrap_or_default();
(200, content_type_for(&claimed), bytes)
}
#[cfg(test)]
mod tests {
use super::*;
/// A blob store in a throwaway directory. No tempfile dependency for one test
/// fixture — the process id keeps concurrent runs apart.
fn store(tag: &str) -> BlobStore {
let dir = std::env::temp_dir().join(format!("ts-blobs-{}-{tag}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
BlobStore::new(dir).expect("store")
}
/// sha256("hello") — a fixed vector, so a broken digest can't agree with itself.
const HELLO: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
#[test]
fn digest_matches_a_known_vector() {
assert_eq!(digest(b"hello"), HELLO);
}
#[test]
fn stores_and_reads_back() {
let store = store("roundtrip");
assert!(!store.has(HELLO));
store.store(HELLO, b"hello").expect("store");
assert!(store.has(HELLO));
assert_eq!(store.read(HELLO).as_deref(), Some(&b"hello"[..]));
}
#[test]
fn refuses_bytes_that_dont_match_the_hash() {
// A corrupted or substituted transfer must never be filed under a name that
// claims it's genuine.
let store = store("mismatch");
let err = store.store(HELLO, b"goodbye").expect_err("must reject");
assert!(err.contains("integrity"), "got {err}");
assert!(!store.has(HELLO), "nothing should have been written");
}
#[test]
fn rejects_a_hash_that_could_escape_the_directory() {
// The hash arrives from a server response and becomes a filename.
let store = store("traversal");
assert!(store.path("../../etc/passwd").is_none());
assert!(store.store("../../etc/passwd", b"x").is_err());
assert!(store.path("").is_none());
assert!(store.path("nothex!!").is_none());
}
#[test]
fn accepts_an_uppercase_hash() {
// The wire format isn't guaranteed to be lowercase; the filename is.
let store = store("case");
store
.store(&HELLO.to_ascii_uppercase(), b"hello")
.expect("store");
assert!(store.has(HELLO), "should be found under the lowercase name");
}
#[test]
fn the_blob_url_carries_the_hash_and_the_mime() {
let url = url_for(HELLO, "image/png");
assert!(url.contains(HELLO), "the hash addresses the bytes: {url}");
assert!(url.contains("mime=image%2Fpng"), "mime encoded: {url}");
// The platform split is the whole risk of this feature, and CI is headless,
// so at least pin that the right branch was taken for THIS build.
if cfg!(any(windows, target_os = "android")) {
assert!(url.starts_with("http://tsblob.localhost/"), "{url}");
} else {
assert!(url.starts_with("tsblob://localhost/"), "{url}");
}
}
#[test]
fn url_encoding_round_trips_a_mime() {
assert_eq!(urldecode(&urlencode("image/svg+xml")), "image/svg+xml");
assert_eq!(urldecode(&urlencode("audio/mpeg")), "audio/mpeg");
// A malformed escape is left alone rather than eaten — the value still has to
// survive intact enough for `content_type_for` to reject it.
assert_eq!(urldecode("not-an-escape%ZZ"), "not-an-escape%ZZ");
}
#[test]
fn media_types_are_echoed_back() {
assert_eq!(content_type_for("image/png"), "image/png");
assert_eq!(content_type_for("audio/mpeg"), "audio/mpeg");
assert_eq!(content_type_for("application/pdf"), "application/pdf");
}
#[test]
fn a_scriptable_type_is_served_as_a_download() {
// This scheme is an origin of its own. An attachment claiming to be HTML
// must not be handed back as a document that can run there.
let opaque = "application/octet-stream";
assert_eq!(content_type_for("text/html"), opaque);
assert_eq!(content_type_for("application/javascript"), opaque);
assert_eq!(content_type_for(""), opaque);
// A control character can't reach a header value even under a safe family.
assert_eq!(content_type_for("image/png\r\nX-Evil: 1"), opaque);
}
#[test]
fn serving_refuses_a_path_that_isnt_a_hash() {
// Delegated to `path`, so the traversal guard is the same one `store` uses.
publish_root(std::env::temp_dir().join("ts-blobs-serve-guard"));
let (status, _, body) = serve("/../../etc/passwd", None);
assert_eq!(status, 404);
assert!(body.is_empty());
}
#[test]
fn missing_blob_reads_as_none() {
let store = store("missing");
assert!(store.read(HELLO).is_none());
assert!(!store.has(HELLO));
}
}
+569
View File
@@ -0,0 +1,569 @@
//! HTTP transport to a ThoughtSync server.
//!
//! Covers the compatibility handshake (M10.6) and device-token auth (M10.7a). The
//! engine that moves notes — push, pull, cursor — grows on top of the same client,
//! which is why the timeout, identity headers and error vocabulary live here rather
//! than inline at each call site.
//!
//! Nothing here runs unless the user has linked a server; the app is local-first and
//! fully usable with no network at all.
use std::path::Path;
use std::time::Duration;
use reqwest::{RequestBuilder, StatusCode};
use serde::{Deserialize, Serialize};
use super::compat::{self, Compatibility, ServerInfo};
use super::wire;
/// Timeout for the short request/response calls in this module. Kept tight because a
/// user is watching a button while they run, and the most common mistake — a wrong
/// host on a LAN — fails by hanging rather than refusing, so an unbounded wait would
/// just look frozen. The sync engine's bulk transfers will need their own, longer one.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
/// Bulk transfers get much longer: a first full sync can be thousands of notes, and
/// failing one at ten seconds would make a large store impossible to ever pull.
const SYNC_TIMEOUT: Duration = Duration::from_secs(120);
/// Shared by every call that presents a token, so a revoked one reads the same way
/// wherever it surfaces.
const TOKEN_REJECTED: &str = "This server rejected the device token — it may have been \
revoked. Unlink and link again to issue a new one.";
/// What the link UI needs after a handshake: where we ended up (the normalized URL,
/// which may differ from what was typed), who answered, and whether we can work
/// with them.
#[derive(Debug, Serialize)]
pub struct ProbeResult {
pub base_url: String,
pub server: ServerInfo,
pub compatibility: Compatibility,
}
/// The account a device token belongs to. Surfaced after linking so the user can
/// confirm they linked the account they meant to — easy to get wrong on a server
/// hosting more than one.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Identity {
pub id: String,
pub email: String,
#[serde(default)]
pub display_name: String,
}
#[derive(Deserialize)]
struct DeviceLoginResponse {
token: String,
user: Identity,
}
/// What became of this device's token on the SERVER when unlinking.
///
/// Not a bool, and not an error: unlinking must never be blocked by the network —
/// wanting to stop syncing is a local decision — so the remote half reports back
/// instead of failing the call, and each outcome needs different advice.
///
/// Serialized tagged, like `Compatibility`, so the frontend can `switch` on `status`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum RevokeOutcome {
/// The server confirmed it: this token authenticates nothing now.
Revoked,
/// This server has no self-revoke route — it predates one. The token is still
/// live, and only the web app can retire it.
Unsupported,
/// We couldn't reach the server, or it refused. The token is still live.
Failed { reason: String },
/// Nothing to revoke; the app wasn't linked.
Skipped,
}
/// Retire the device token we authenticate with, server-side.
///
/// Identified by the token itself rather than a device id, because a token pasted
/// from the web app never carried one — a route keyed on the id would work for
/// exactly one of the two ways this app can be linked.
pub async fn revoke_self(base_url: &str, token: &str) -> RevokeOutcome {
let client = match http() {
Ok(client) => client,
Err(reason) => return RevokeOutcome::Failed { reason },
};
let request = prepare(client.delete(revoke_self_url(base_url)), Some(token));
let response = match request.send().await {
Ok(response) => response,
Err(e) => {
return RevokeOutcome::Failed {
reason: describe_transport_error(base_url, &e),
}
}
};
let status = response.status();
// 401 counts as revoked: the token already authenticates nothing — retired by
// another device, or purged server-side — which is the state we were asking for.
if status.is_success() || status == StatusCode::UNAUTHORIZED {
return RevokeOutcome::Revoked;
}
match status {
// No such route: a server older than self-revoke. Any other shape of 404
// (a proxy, a stale base URL) leaves the token live too, so the advice the
// user needs is the same either way.
StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED => RevokeOutcome::Unsupported,
other => RevokeOutcome::Failed {
reason: unexpected_status(base_url, other),
},
}
}
fn http_with(timeout: Duration) -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.timeout(timeout)
.build()
.map_err(|e| format!("Could not start the network client: {e}"))
}
fn http() -> Result<reqwest::Client, String> {
http_with(REQUEST_TIMEOUT)
}
/// Attach the client-identity headers every request carries, plus a bearer token
/// when we hold one.
fn prepare(builder: RequestBuilder, token: Option<&str>) -> RequestBuilder {
let mut builder = builder;
for (name, value) in compat::client_headers() {
builder = builder.header(name, value);
}
match token {
Some(t) => builder.bearer_auth(t),
None => builder,
}
}
fn unexpected_status(base_url: &str, status: StatusCode) -> String {
format!(
"{base_url} answered with HTTP {}. Check the address — a reverse proxy or a \
different site may be answering there.",
status.as_u16()
)
}
/// Ask a server who it is and whether we can sync with it.
///
/// `Err` means we never got a usable answer (bad address, unreachable, not a
/// ThoughtSync server). A server that answers but is *incompatible* comes back `Ok`
/// with a verdict — that distinction matters, because the two need very different
/// messages: one is "check what you typed", the other is "update something".
pub async fn probe(raw_url: &str) -> Result<ProbeResult, String> {
let base_url = compat::normalize_base_url(raw_url)
.ok_or("Enter a server address, like https://notes.example.com")?;
let request = prepare(http()?.get(config_url(&base_url)), None);
let response = request
.send()
.await
.map_err(|e| describe_transport_error(&base_url, &e))?;
let status = response.status();
if !status.is_success() {
return Err(unexpected_status(&base_url, status));
}
// Something answered 200 that isn't a ThoughtSync server (a router login page, a
// captive portal). Report the address, not the parse error, which would mean
// nothing to the person reading it.
let server: ServerInfo = response.json().await.map_err(|_| {
format!(
"{base_url} responded, but not with ThoughtSync's configuration. \
Is that the right address?"
)
})?;
let compatibility = compat::evaluate(&server);
Ok(ProbeResult {
base_url,
server,
compatibility,
})
}
/// Exchange email + password for a device bearer token.
///
/// The fresh-install path: it needs no existing session, which is what lets a brand
/// new desktop install link without visiting the web app first.
pub async fn device_login(
base_url: &str,
email: &str,
password: &str,
device_name: &str,
) -> Result<(String, Identity), String> {
let body = serde_json::json!({
"email": email,
"password": password,
"name": device_name,
});
let request = prepare(http()?.post(device_login_url(base_url)), None).json(&body);
let response = request
.send()
.await
.map_err(|e| describe_transport_error(base_url, &e))?;
let status = response.status();
if status == StatusCode::UNAUTHORIZED {
return Err("That email and password didn't match an account on this server.".to_string());
}
if !status.is_success() {
return Err(unexpected_status(base_url, status));
}
let parsed: DeviceLoginResponse = response
.json()
.await
.map_err(|_| format!("{base_url} signed us in but sent an unexpected reply."))?;
Ok((parsed.token, parsed.user))
}
/// Validate a token by asking whom it belongs to.
///
/// Used when the user pastes a token issued from the web app. Storing it unverified
/// would turn a copy/paste slip into a failure that only surfaces at the next sync,
/// far from the thing that caused it.
pub async fn fetch_identity(base_url: &str, token: &str) -> Result<Identity, String> {
let request = prepare(http()?.get(me_url(base_url)), Some(token));
let response = request
.send()
.await
.map_err(|e| describe_transport_error(base_url, &e))?;
let status = response.status();
if status == StatusCode::UNAUTHORIZED {
let message = "That token isn't valid on this server — it may have been revoked. \
Issue a new one from the web app under Account → Linked devices.";
return Err(message.to_string());
}
if !status.is_success() {
return Err(unexpected_status(base_url, status));
}
response
.json()
.await
.map_err(|_| format!("{base_url} accepted the token but sent an unexpected reply."))
}
/// Fetch one page of the change feed, starting after `since`.
///
/// The caller loops until `has_more` is false (see `pull::run`); paging lives there
/// rather than here so the transport stays a single request/response.
pub async fn fetch_changes(
base_url: &str,
token: &str,
since: i64,
) -> Result<wire::ChangesPage, String> {
let url = format!("{base_url}/api/sync/changes?since={since}");
let request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token));
let response = request
.send()
.await
.map_err(|e| describe_transport_error(base_url, &e))?;
let status = response.status();
if status == StatusCode::UNAUTHORIZED {
return Err(TOKEN_REJECTED.to_string());
}
if !status.is_success() {
return Err(unexpected_status(base_url, status));
}
response
.json()
.await
.map_err(|e| format!("Couldn't read the change feed from {base_url}: {e}"))
}
/// Download one attachment's bytes.
///
/// Metadata already arrived on the delta feed; this is only the payload, fetched
/// over the same route the web app uses (owner/shared scoped server-side).
pub async fn fetch_attachment(
base_url: &str,
token: &str,
note_id: &str,
attachment_id: &str,
) -> Result<Vec<u8>, String> {
let url = format!("{base_url}/api/notes/{note_id}/attachments/{attachment_id}");
let request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token));
let response = request
.send()
.await
.map_err(|e| describe_transport_error(base_url, &e))?;
let status = response.status();
if status == StatusCode::UNAUTHORIZED {
return Err(TOKEN_REJECTED.to_string());
}
if !status.is_success() {
return Err(unexpected_status(base_url, status));
}
response
.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| format!("Couldn't download an attachment from {base_url}: {e}"))
}
/// Send a batch of changes and hand back the raw reply.
///
/// Returns text rather than parsed results so this module stays pure transport —
/// `push::parse_results` owns the result shapes, and keeping them there is what lets
/// the parsing be unit-tested without a server.
pub async fn push_changes<T: Serialize>(
base_url: &str,
token: &str,
changes: &[T],
) -> Result<String, String> {
let body = serde_json::json!({ "changes": changes });
let url = format!("{base_url}/api/sync/push");
let request = prepare(http_with(SYNC_TIMEOUT)?.post(url), Some(token)).json(&body);
let response = request
.send()
.await
.map_err(|e| describe_transport_error(base_url, &e))?;
let status = response.status();
if status == StatusCode::UNAUTHORIZED {
return Err(TOKEN_REJECTED.to_string());
}
if !status.is_success() {
return Err(unexpected_status(base_url, status));
}
response
.text()
.await
.map_err(|e| format!("Couldn't read the push reply from {base_url}: {e}"))
}
/// The public, unauthenticated endpoint carrying the handshake.
fn config_url(base_url: &str) -> String {
format!("{base_url}/api/config")
}
fn device_login_url(base_url: &str) -> String {
format!("{base_url}/api/auth/device-login")
}
fn me_url(base_url: &str) -> String {
format!("{base_url}/api/auth/me")
}
/// `self` rather than a device id: see `revoke_self`.
fn revoke_self_url(base_url: &str) -> String {
format!("{base_url}/api/auth/devices/self")
}
/// Turn a transport failure into something a person can act on. reqwest's own
/// Display is accurate but reads like a stack trace.
fn describe_transport_error(base_url: &str, err: &reqwest::Error) -> String {
if err.is_timeout() {
// No specific duration here: these calls run under two different budgets
// (interactive vs bulk sync), and naming the wrong one is worse than naming
// none.
format!(
"{base_url} didn't respond in time. It may be offline, or unreachable \
from this network."
)
} else if err.is_connect() {
format!(
"Couldn't reach {base_url}. Check the address and that the server is \
running. If it uses plain HTTP, include http:// explicitly."
)
} else {
format!("Couldn't reach {base_url}: {err}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn urls_join_without_doubling_slashes() {
// normalize_base_url has already stripped any trailing slash, so plain
// concatenation is correct — this pins that assumption.
assert_eq!(
config_url("https://notes.example.com"),
"https://notes.example.com/api/config"
);
assert_eq!(
device_login_url("https://notes.example.com"),
"https://notes.example.com/api/auth/device-login"
);
assert_eq!(
me_url("https://notes.example.com"),
"https://notes.example.com/api/auth/me"
);
assert_eq!(
revoke_self_url("https://notes.example.com"),
"https://notes.example.com/api/auth/devices/self"
);
}
#[test]
fn revoke_outcome_serializes_tagged_for_the_frontend() {
// The UI decides between "signed out on the server" and "still valid, go
// revoke it" by reading this tag, so its shape is part of the contract.
let json = serde_json::to_string(&RevokeOutcome::Failed {
reason: "offline".into(),
})
.expect("outcome serializes");
assert!(json.contains("\"status\":\"failed\""), "got {json}");
let json = serde_json::to_string(&RevokeOutcome::Revoked).expect("outcome serializes");
assert!(json.contains("\"status\":\"revoked\""), "got {json}");
}
#[test]
fn urls_preserve_a_port_and_subpath() {
assert_eq!(
config_url("http://192.168.1.10:8000/thoughtsync"),
"http://192.168.1.10:8000/thoughtsync/api/config"
);
}
}
/// The Android client a linked server can hand out.
///
/// Mirrors `/api/client/android` (see the server's `client_dist.py`). Absent there
/// means the server has no client to offer, which is an ordinary state and not an
/// error — a self-hoster who never touches Android has one.
#[derive(Debug, Clone, Deserialize)]
pub struct ClientRelease {
pub version: String,
/// What decides "is this newer". The name is for people and sorts like a string.
pub version_code: i64,
pub size: i64,
pub sha256: String,
/// Path on the same server, not an absolute URL — the client joins it to the
/// base it is already linked to, so a compromised or misconfigured server
/// cannot redirect the download somewhere else.
pub url: String,
}
/// What Android client the linked server has, if any.
///
/// `Ok(None)` for a server that simply has none — that is the answer to the
/// question, not a failure to answer it.
pub async fn fetch_client_release(
base_url: &str,
token: &str,
) -> Result<Option<ClientRelease>, String> {
let url = format!("{base_url}/api/client/android");
let response = prepare(http()?.get(url), Some(token))
.send()
.await
.map_err(|e| describe_transport_error(base_url, &e))?;
let status = response.status();
if status == StatusCode::NOT_FOUND {
return Ok(None);
}
if status == StatusCode::UNAUTHORIZED {
return Err(TOKEN_REJECTED.to_string());
}
if !status.is_success() {
return Err(unexpected_status(base_url, status));
}
response
.json::<ClientRelease>()
.await
.map(Some)
.map_err(|e| {
format!("{base_url} described its Android client in a way this app could not read: {e}")
})
}
/// Download the client to `dest`, verifying it on the way in.
///
/// Streamed rather than buffered: the APK is ~55 MiB and holding that in memory on
/// a phone, on top of whatever the app is already using, is how an update gets
/// killed by the low-memory killer half way through.
///
/// Written to `dest.part` and renamed only once the digest matches, so an
/// interrupted download can never be mistaken for a finished one. The digest is
/// not a trust anchor — the APK signature is, and Android checks that at install —
/// but it catches a truncated or corrupted transfer before the installer is
/// bothered with it.
pub async fn download_client(
base_url: &str,
token: &str,
release: &ClientRelease,
dest: &Path,
) -> Result<(), String> {
use sha2::{Digest, Sha256};
use std::io::Write;
// The advertised path is joined to the base we are LINKED to. Taking an
// absolute URL from the response would let a server point the download at a
// host the user never agreed to.
let path = release.url.trim_start_matches('/');
let url = format!("{base_url}/{path}");
let mut response = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token))
.send()
.await
.map_err(|e| describe_transport_error(base_url, &e))?;
let status = response.status();
if status == StatusCode::UNAUTHORIZED {
return Err(TOKEN_REJECTED.to_string());
}
if !status.is_success() {
return Err(unexpected_status(base_url, status));
}
let partial = dest.with_extension("part");
if let Some(parent) = partial.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Couldn't prepare a place to download to: {e}"))?;
}
let mut file = std::fs::File::create(&partial)
.map_err(|e| format!("Couldn't open the download file: {e}"))?;
let mut hasher = Sha256::new();
let mut written: i64 = 0;
loop {
let chunk = response
.chunk()
.await
.map_err(|e| describe_transport_error(base_url, &e))?;
let Some(chunk) = chunk else { break };
hasher.update(&chunk);
written += chunk.len() as i64;
file.write_all(&chunk)
.map_err(|e| format!("Couldn't write the download: {e}"))?;
}
file.flush()
.map_err(|e| format!("Couldn't finish writing the download: {e}"))?;
drop(file);
let digest = format!("{:x}", hasher.finalize());
let mismatch = if written != release.size {
Some(format!("expected {} bytes, got {written}", release.size))
} else if !digest.eq_ignore_ascii_case(&release.sha256) {
Some("the contents did not match the checksum the server published".to_string())
} else {
None
};
if let Some(why) = mismatch {
// The half-file is removed rather than left: a later run finding it would
// have no way to tell it from a good one.
let _ = std::fs::remove_file(&partial);
return Err(format!("The download from {base_url} was damaged — {why}."));
}
std::fs::rename(&partial, dest)
.map_err(|e| format!("Couldn't put the downloaded update in place: {e}"))
}
+411
View File
@@ -0,0 +1,411 @@
//! Client<->server compatibility handshake (M10.6).
//!
//! The desktop app is local-first: it never *needs* a server. When the user links
//! one, this module decides whether the two can actually talk — before a single
//! note moves. The sync engine (M10.7) consults it on link and on every sync.
//!
//! The contract is two integers per side, versioning the WIRE PROTOCOL separately
//! from either program's release version:
//!
//! | | this client | the server advertises |
//! |---|---|---|
//! | speaks | `CLIENT_PROTOCOL_VERSION` | `sync_protocol_version` |
//! | accepts down to | `MIN_SERVER_PROTOCOL_VERSION` | `min_client_protocol_version` |
//!
//! Each side declaring its own floor is what avoids app<->server lockstep: either
//! side can mark a change breaking without the other needing to ship in step. See
//! `docs/sync.md` for the policy that governs when those numbers move.
use serde::{Deserialize, Serialize};
/// The sync wire protocol this client speaks.
///
/// v4 (M315): `color` left the note. NOT a floor raise on either side — see the note
/// on [`MIN_SERVER_PROTOCOL_VERSION`].
pub const CLIENT_PROTOCOL_VERSION: u32 = 4;
/// The oldest server protocol this client can drive — the symmetric half of the
/// server's `min_client_protocol_version`.
///
/// STAYS AT 3 ACROSS v4, and the v2 precedent is the reason to say why rather than
/// leave it looking like an oversight. v2 dropped `kind` and `title` and DID move both
/// floors, on the rule that "dropping a field a client sends and expects back is
/// breaking". `color` fails that test on the second half: a v3 client reading a v4
/// server gets `"default"` from serde's default and draws the colour it derives
/// locally, which is a board that looks exactly like the one it drew yesterday. A v3
/// client PUSHING `color` to a v4 server has the key ignored — the server reads its
/// payload key by key and never validates the shape. Neither direction errors, and
/// neither loses anything a person can see; `title` was the note's NAME, and this is a
/// field that no longer renders anywhere.
pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 3;
/// Capabilities without which syncing is meaningless, so their absence BLOCKS the
/// link rather than degrading it.
pub const REQUIRED_FEATURES: &[&str] = &["notes", "labels"];
/// Capabilities whose absence costs a feature but not the link. Listing these
/// explicitly (rather than diffing against whatever the server happens to send) is
/// what lets the UI name exactly what the user will be missing.
pub const OPTIONAL_FEATURES: &[&str] = &["attachments", "tombstones", "revisions"];
/// The handshake fields of `GET /api/config`.
///
/// Every protocol field is optional because a server predating M10.6 simply won't
/// send them. That case has to read as "this server is too old to sync", not as a
/// parse failure — which would look to the user like they mistyped the URL.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ServerInfo {
#[serde(default)]
pub site_name: Option<String>,
/// The server's release version, for display only — never gate on it.
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub sync_protocol_version: Option<u32>,
#[serde(default)]
pub min_client_protocol_version: Option<u32>,
#[serde(default)]
pub sync_features: Vec<String>,
/// How long the SERVER keeps a trashed note before purging it (0 = forever).
/// Once linked this is the window that actually applies, so the desktop's Trash
/// countdown has to come from here rather than from its own offline default.
#[serde(default)]
pub trash_retention_days: Option<u32>,
}
impl ServerInfo {
fn has_feature(&self, name: &str) -> bool {
self.sync_features.iter().any(|f| f.as_str() == name)
}
fn missing(&self, from: &[&str]) -> Vec<String> {
from.iter()
.copied()
.filter(|f| !self.has_feature(f))
.map(String::from)
.collect()
}
}
/// The verdict the link/settings UI renders and the sync engine obeys.
///
/// Serialized tagged so the frontend can `switch` on `status` directly.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum Compatibility {
/// Full parity — sync everything.
Ok,
/// Safe to sync, but these named capabilities aren't available here.
Degraded { unavailable: Vec<String> },
/// Do not sync. `client_must_update` points the user at the side that can fix
/// it, so the message can be actionable instead of just "incompatible".
Incompatible {
reason: String,
client_must_update: bool,
},
}
fn incompatible(reason: &str, client_must_update: bool) -> Compatibility {
Compatibility::Incompatible {
reason: reason.to_string(),
client_must_update,
}
}
/// Decide whether this client can sync with the described server.
///
/// Pure: the transport fetches `ServerInfo`, this decides what it means. Keeping
/// the decision free of I/O is what makes every branch below unit-testable, which
/// matters because there is no Postgres/live-server lane in CI.
pub fn evaluate(info: &ServerInfo) -> Compatibility {
// Ordered most-fundamental first, so the user sees the root problem rather than
// a downstream symptom of it.
let Some(server_proto) = info.sync_protocol_version else {
return incompatible(
"This server doesn't support device sync — it predates the sync protocol. \
Update the server, then link again.",
false,
);
};
if server_proto < MIN_SERVER_PROTOCOL_VERSION {
return incompatible(
&format!(
"This server speaks sync protocol v{server_proto}, but this app needs \
at least v{MIN_SERVER_PROTOCOL_VERSION}. Update the server."
),
false,
);
}
// The server's floor is what hard-blocks an old client. Absent => no floor: a
// server that advertises a protocol but no minimum accepts anything.
let floor = info.min_client_protocol_version.unwrap_or(0);
if CLIENT_PROTOCOL_VERSION < floor {
return incompatible(
&format!(
"This server requires client protocol v{floor} or newer; this app \
speaks v{CLIENT_PROTOCOL_VERSION}. Update ThoughtSync."
),
true,
);
}
// A version match still isn't enough: a server can speak the protocol with a
// core capability compiled out or disabled.
let missing_required = info.missing(REQUIRED_FEATURES);
if !missing_required.is_empty() {
return incompatible(
&format!(
"This server is missing sync capabilities this app requires: {}.",
missing_required.join(", ")
),
false,
);
}
let unavailable = info.missing(OPTIONAL_FEATURES);
if unavailable.is_empty() {
Compatibility::Ok
} else {
Compatibility::Degraded { unavailable }
}
}
/// Headers this client puts on every request to a linked server, so the server can
/// log or gate on client identity without a separate handshake round-trip.
pub fn client_headers() -> [(&'static str, String); 2] {
let agent = format!("thoughtsync-desktop/{}", env!("CARGO_PKG_VERSION"));
[
("X-ThoughtSync-Client", agent),
(
"X-ThoughtSync-Protocol",
CLIENT_PROTOCOL_VERSION.to_string(),
),
]
}
/// Turn what a user typed into a base URL we can build request paths on, or `None`
/// if there's nothing usable in it.
///
/// A bare host gets **`https://`**, never `http://`. Silently downgrading would put
/// a long-lived device token on the wire in cleartext because someone omitted five
/// characters. Plain HTTP on a trusted LAN stays fully supported — the user just
/// has to type `http://` and thereby choose it.
pub fn normalize_base_url(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
// Resolve the scheme BEFORE touching trailing slashes — stripping them first
// turns a bare "https://" into "https:", which then reads as a hostname.
let with_scheme = match trimmed.split_once("://") {
Some((scheme, rest)) => {
// Anything that isn't HTTP(S) (ftp://, file://, a stray "foo://") can't
// be a ThoughtSync server; reject rather than fail confusingly later.
let scheme = scheme.to_ascii_lowercase();
if scheme != "http" && scheme != "https" {
return None;
}
format!("{scheme}://{rest}")
}
None => format!("https://{trimmed}"),
};
let (scheme, rest) = with_scheme.split_once("://")?;
let rest = rest.trim_end_matches('/');
// Reject a scheme with no authority ("https://", "http:///path").
if rest.split(['/', '?', '#']).next().unwrap_or("").is_empty() {
return None;
}
Some(format!("{scheme}://{rest}"))
}
#[cfg(test)]
mod tests {
use super::*;
/// A server matching this client exactly, which each test then degrades.
fn current_server() -> ServerInfo {
ServerInfo {
site_name: Some("ThoughtSync".into()),
version: Some("0.1.0".into()),
sync_protocol_version: Some(CLIENT_PROTOCOL_VERSION),
min_client_protocol_version: Some(CLIENT_PROTOCOL_VERSION),
sync_features: REQUIRED_FEATURES
.iter()
.chain(OPTIONAL_FEATURES.iter())
.copied()
.map(String::from)
.collect(),
trash_retention_days: Some(30),
}
}
#[test]
fn current_server_is_fully_compatible() {
assert_eq!(evaluate(&current_server()), Compatibility::Ok);
}
#[test]
fn server_without_protocol_fields_is_too_old() {
// A pre-M10.6 server: /api/config parses, but carries no protocol block.
let info = ServerInfo {
site_name: Some("ThoughtSync".into()),
version: Some("0.0.9".into()),
..Default::default()
};
match evaluate(&info) {
Compatibility::Incompatible {
client_must_update, ..
} => assert!(!client_must_update, "the SERVER is the old side here"),
other => panic!("expected incompatible, got {other:?}"),
}
}
#[test]
fn client_older_than_the_servers_floor_must_update() {
let info = ServerInfo {
sync_protocol_version: Some(CLIENT_PROTOCOL_VERSION + 5),
min_client_protocol_version: Some(CLIENT_PROTOCOL_VERSION + 5),
..current_server()
};
match evaluate(&info) {
Compatibility::Incompatible {
client_must_update, ..
} => assert!(client_must_update),
other => panic!("expected incompatible, got {other:?}"),
}
}
#[test]
fn newer_server_within_our_floor_still_works() {
// The whole point of the two-number contract: a server can move ahead
// additively without locking out a client that predates the change.
let info = ServerInfo {
sync_protocol_version: Some(CLIENT_PROTOCOL_VERSION + 3),
min_client_protocol_version: Some(CLIENT_PROTOCOL_VERSION),
..current_server()
};
assert_eq!(evaluate(&info), Compatibility::Ok);
}
#[test]
fn server_with_no_declared_floor_accepts_us() {
let info = ServerInfo {
min_client_protocol_version: None,
..current_server()
};
assert_eq!(evaluate(&info), Compatibility::Ok);
}
#[test]
fn missing_optional_feature_degrades_rather_than_blocks() {
let info = ServerInfo {
sync_features: current_server()
.sync_features
.into_iter()
.filter(|f| f.as_str() != "attachments")
.collect(),
..current_server()
};
assert_eq!(
evaluate(&info),
Compatibility::Degraded {
unavailable: vec!["attachments".to_string()]
}
);
}
#[test]
fn missing_required_feature_blocks() {
let info = ServerInfo {
sync_features: vec!["labels".to_string()],
..current_server()
};
match evaluate(&info) {
Compatibility::Incompatible { reason, .. } => assert!(reason.contains("notes")),
other => panic!("expected incompatible, got {other:?}"),
}
}
#[test]
fn version_mismatch_outranks_a_missing_feature() {
// Both wrong → report the version, the root cause of the missing feature.
let info = ServerInfo {
sync_protocol_version: Some(CLIENT_PROTOCOL_VERSION + 2),
min_client_protocol_version: Some(CLIENT_PROTOCOL_VERSION + 2),
sync_features: vec![],
..current_server()
};
match evaluate(&info) {
Compatibility::Incompatible {
client_must_update, ..
} => assert!(client_must_update),
other => panic!("expected incompatible, got {other:?}"),
}
}
#[test]
fn verdict_serializes_tagged_for_the_frontend() {
let verdict = Compatibility::Degraded {
unavailable: vec!["attachments".into()],
};
let json = serde_json::to_string(&verdict).expect("verdict serializes");
assert!(json.contains("\"status\":\"degraded\""), "got {json}");
}
#[test]
fn server_info_tolerates_unknown_and_absent_fields() {
// Forward compatibility: a NEWER server sending fields we've never heard of
// must not break the handshake.
// Versions come from the constants, not literals: this test is about unknown
// FIELDS, and pinning the numbers made it fail the moment the protocol moved
// to v2 — for a reason that has nothing to do with what it checks.
let body = format!(
r#"{{"site_name":"S","sync_protocol_version":{v},
"min_client_protocol_version":{v},
"sync_features":["notes","labels","attachments","tombstones","revisions"],
"some_future_field":{{"nested":true}}}}"#,
v = CLIENT_PROTOCOL_VERSION,
);
let info: ServerInfo = serde_json::from_str(&body).expect("unknown fields are ignored");
assert_eq!(evaluate(&info), Compatibility::Ok);
}
#[test]
fn client_headers_identify_app_and_protocol() {
let headers = client_headers();
assert_eq!(headers[0].0, "X-ThoughtSync-Client");
assert!(headers[0].1.starts_with("thoughtsync-desktop/"));
assert_eq!(headers[1].1, CLIENT_PROTOCOL_VERSION.to_string());
}
#[test]
fn base_url_defaults_to_https_and_trims() {
assert_eq!(
normalize_base_url(" notes.example.com/ "),
Some("https://notes.example.com".to_string())
);
assert_eq!(
normalize_base_url("https://notes.example.com///"),
Some("https://notes.example.com".to_string())
);
}
#[test]
fn base_url_keeps_an_explicit_http_choice() {
// Plain HTTP on a LAN is supported — the user just has to ask for it.
assert_eq!(
normalize_base_url("http://192.168.1.10:8000"),
Some("http://192.168.1.10:8000".to_string())
);
}
#[test]
fn base_url_rejects_junk() {
assert_eq!(normalize_base_url(""), None);
assert_eq!(normalize_base_url(" "), None);
assert_eq!(normalize_base_url("https://"), None);
assert_eq!(normalize_base_url("ftp://files.example.com"), None);
}
}
+77
View File
@@ -0,0 +1,77 @@
//! The sync cycle (M10.7c).
//!
//! Deliberately the ONLY way the UI can sync. Push and pull are each usable on their
//! own inside this crate, but exposing them separately would let a caller pull
//! without pushing, which quietly overwrites unsent local edits.
use chrono::{SecondsFormat, Utc};
use serde::Serialize;
use super::blobs::BlobStore;
use super::pull;
use super::push;
use super::state;
use crate::local::Db;
#[derive(Debug, Serialize)]
pub struct SyncOutcome {
pub push: push::PushSummary,
pub pull: pull::PullSummary,
/// The state after the cycle, so the UI updates from one round-trip instead of
/// following every sync with a status call.
pub status: state::Status,
}
/// Push, then pull — in that order, always.
///
/// Pull writes the server's version straight over the local row, so anything not yet
/// sent would be lost to it. Pushing first is what puts the local edit in front of
/// the server's last-write-wins comparison, and it's the reason
/// `PullSummary::clobbered_dirty` should be zero on every healthy cycle.
///
/// A failed push aborts before the pull. Pulling anyway would take the exact rows we
/// just failed to save and overwrite them — turning a recoverable network error into
/// lost work.
pub async fn run_cycle(
db: &Db,
blobs: &BlobStore,
base_url: &str,
token: &str,
) -> Result<SyncOutcome, String> {
let push = push::run(db, base_url, token).await?;
let pull = pull::run(db, blobs, base_url, token).await?;
if pull.clobbered_dirty > 0 {
// Push ran first and reported success, so nothing should still have been
// dirty. Reaching here means something wrote to the store mid-cycle, or a
// change never got collected — worth a loud line either way.
log::warn!(
"sync cycle overwrote {} locally-edited note(s) despite pushing first",
pull.clobbered_dirty
);
}
// While we're already talking to this server, re-read what it says about itself.
// Today that's the trash-retention window the Trash view counts down against, and
// it can change under us whenever an admin edits the setting. Best-effort on
// purpose: a config blip must not fail a cycle whose actual work already
// succeeded, and the stored value simply stays as it was.
let retention = super::client::probe(base_url)
.await
.ok()
.and_then(|p| p.server.trash_retention_days);
let status = {
let conn = db.0.lock().map_err(|e| e.to_string())?;
if let Some(days) = retention {
state::set_server_retention(&conn, days as i64).map_err(|e| e.to_string())?;
}
// Stamped only here, after BOTH halves succeeded. A timestamp written after a
// partial cycle would tell the user they're up to date when they aren't.
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
state::mark_synced(&conn, &now).map_err(|e| e.to_string())?;
state::status(&conn).map_err(|e| e.to_string())?
};
Ok(SyncOutcome { push, pull, status })
}
+22
View File
@@ -0,0 +1,22 @@
//! Talking to a ThoughtSync server — entirely opt-in.
//!
//! The app is local-first: `local` is the source of truth and everything works
//! unlinked. Nothing in here runs until the user links a server.
//!
//! - `compat` — the version/capability handshake (M10.6): whether a given server can
//! be talked to at all. Pure decision logic, no I/O.
//! - `client` — HTTP transport: the handshake call and device-token auth.
//! - `state` — the persisted link record (server, token, change-feed cursor).
//! - `engine` — one full cycle: push local changes, then pull the server's.
//!
//! The UI surface that drives this lives in whichever client is wrapping the crate,
//! not here.
pub mod blobs;
pub mod client;
pub mod compat;
pub mod engine;
pub mod pull;
pub mod push;
pub mod state;
pub mod wire;
+781
View File
@@ -0,0 +1,781 @@
//! Pull: bring a server's changes into the local store (M10.7b).
//!
//! The feed is a single monotonic sequence shared by notes and labels, so one
//! integer cursor is a total-order watermark over both (docs/sync.md). We loop pages
//! until the server says there are no more, persisting the cursor **in the same
//! transaction** as the page it describes — a cursor committed ahead of its data
//! would silently skip those rows forever, which reads as a clean sync.
use chrono::{SecondsFormat, Utc};
use rusqlite::{params, Connection, OptionalExtension};
use serde::Serialize;
use super::blobs::BlobStore;
use super::client;
use super::state;
use super::wire;
use crate::local::Db;
/// Backstop against a server that never stops saying `has_more`. At the server's
/// 1000-row page cap this is 10M rows — far past any real store, so hitting it means
/// something is wrong, not that someone has a lot of notes.
const MAX_PAGES: usize = 10_000;
/// What a pull did — for the UI, and for the log when something looks off.
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct PullSummary {
pub pages: usize,
pub notes_applied: usize,
pub notes_deleted: usize,
pub labels_applied: usize,
pub labels_deleted: usize,
pub cursor: i64,
/// Rows that still held unpushed local edits when the server's version landed on
/// top. Should be 0 in the normal cycle, because push runs first; anything higher
/// means local work was overwritten, which is worth saying out loud.
pub clobbered_dirty: usize,
pub blobs_downloaded: usize,
/// Attachments whose bytes couldn't be fetched or failed verification. Counted
/// rather than fatal — see `download_missing_blobs`.
pub blobs_failed: usize,
}
impl PullSummary {
fn absorb(&mut self, other: PullSummary) {
self.pages += other.pages;
self.notes_applied += other.notes_applied;
self.notes_deleted += other.notes_deleted;
self.labels_applied += other.labels_applied;
self.labels_deleted += other.labels_deleted;
self.clobbered_dirty += other.clobbered_dirty;
self.blobs_downloaded += other.blobs_downloaded;
self.blobs_failed += other.blobs_failed;
self.cursor = other.cursor;
}
}
/// `(note_id, attachment_id, sha256)` for every attachment that advertises a hash.
/// The caller filters against the blob store — which blobs we hold isn't a SQL
/// question.
pub fn hashed_attachments(conn: &Connection) -> rusqlite::Result<Vec<(String, String, String)>> {
let mut stmt = conn.prepare(
"SELECT note_id, id, sha256 FROM attachments
WHERE sha256 IS NOT NULL AND sha256 <> ''",
)?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
rows.collect()
}
/// Fetch the bytes for any attachment we have metadata for but no blob.
///
/// A failed attachment NEVER fails the sync. Notes are the primary data and they've
/// already landed; an image that didn't arrive is retried on the next cycle simply
/// because its blob still counts as missing. Aborting here would mean one unreachable
/// file could block every future sync.
async fn download_missing_blobs(
db: &Db,
blobs: &BlobStore,
base_url: &str,
token: &str,
) -> Result<(usize, usize), String> {
let wanted = {
let conn = db.0.lock().map_err(|e| e.to_string())?;
hashed_attachments(&conn).map_err(|e| e.to_string())?
};
let mut downloaded = 0;
let mut failed = 0;
for (note_id, attachment_id, sha256) in wanted {
// Content-addressed, so this skips blobs we already hold — including the same
// image attached to a different note.
if blobs.has(&sha256) {
continue;
}
match client::fetch_attachment(base_url, token, &note_id, &attachment_id).await {
Ok(bytes) => match blobs.store(&sha256, &bytes) {
Ok(_) => downloaded += 1,
Err(e) => {
log::warn!("attachment {attachment_id}: {e}");
failed += 1;
}
},
Err(e) => {
log::warn!("attachment {attachment_id}: {e}");
failed += 1;
}
}
}
Ok((downloaded, failed))
}
fn now() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
}
/// Apply one page and advance the cursor, atomically.
///
/// Labels are applied before notes so a membership never references a label row that
/// doesn't exist yet.
pub fn apply_page(conn: &Connection, page: &wire::ChangesPage) -> rusqlite::Result<PullSummary> {
let tx = conn.unchecked_transaction()?;
let mut summary = PullSummary {
pages: 1,
cursor: page.cursor,
..Default::default()
};
for label in &page.labels {
if label.is_tombstone() {
tx.execute("DELETE FROM labels WHERE id = ?1", params![label.id])?;
summary.labels_deleted += 1;
} else {
upsert_label(&tx, label)?;
summary.labels_applied += 1;
}
}
for note in &page.notes {
if note.is_tombstone() {
// A purge tombstone carries no content — its only job is to say "delete
// your copy". Children go with it via ON DELETE CASCADE.
tx.execute("DELETE FROM notes WHERE id = ?1", params![note.id])?;
summary.notes_deleted += 1;
continue;
}
if is_dirty(&tx, &note.id)? {
summary.clobbered_dirty += 1;
}
upsert_note(&tx, note)?;
summary.notes_applied += 1;
}
state::set_cursor(&tx, page.cursor)?;
tx.commit()?;
Ok(summary)
}
fn is_dirty(conn: &Connection, note_id: &str) -> rusqlite::Result<bool> {
let dirty: Option<i64> = conn
.query_row(
"SELECT dirty FROM notes WHERE id = ?1",
params![note_id],
|r| r.get(0),
)
.optional()?;
Ok(dirty == Some(1))
}
fn upsert_label(conn: &Connection, label: &wire::Label) -> rusqlite::Result<()> {
// One label per name is enforced on both sides (locally a UNIQUE index on
// lower(name); on the server, per owner). A label created offline can therefore
// collide with one the server already had under a different id — "work" typed on
// this machine and "work" that already existed.
//
// The server's row wins, but its MEMBERSHIPS have to survive the swap. Just
// deleting the local duplicate would cascade its note_labels away, stripping the
// label off notes that this pull never even mentions — silent loss that no later
// page would repair. So: free the name, insert the server's row, re-point the
// memberships onto it, then drop the husk.
let duplicates: Vec<String> = {
let mut stmt =
conn.prepare("SELECT id FROM labels WHERE lower(name) = lower(?1) AND id <> ?2")?;
let rows = stmt.query_map(params![label.name, label.id], |r| r.get::<_, String>(0))?;
rows.collect::<rusqlite::Result<Vec<String>>>()?
};
// Renaming first is what makes the insert possible at all — the unique index
// would otherwise reject the server's row before anything could be merged.
for old in &duplicates {
conn.execute(
"UPDATE labels SET name = name || ' (superseded ' || id || ')' WHERE id = ?1",
params![old],
)?;
}
let created = label.created_at.clone().unwrap_or_else(now);
conn.execute(
"INSERT INTO labels (id, name, color, created_at, updated_at, sync_revision, dirty)
VALUES (?1, ?2, ?3, ?4, ?4, ?5, 0)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
color = excluded.color,
sync_revision = excluded.sync_revision,
dirty = 0",
params![
label.id,
label.name,
label.color,
created,
label.sync_revision
],
)?;
for old in &duplicates {
// OR IGNORE guards a (note_id, label_id) collision. Today the unique index on
// lower(name) makes that unreachable — two same-name labels can't coexist
// locally — so this is belt-and-braces against that index changing, not a
// case we've seen. Anything it skips cascades away with the husk below, which
// is correct: those are duplicates of a membership that now exists.
conn.execute(
"UPDATE OR IGNORE note_labels SET label_id = ?1 WHERE label_id = ?2",
params![label.id, old],
)?;
conn.execute("DELETE FROM labels WHERE id = ?1", params![old])?;
}
Ok(())
}
fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
let created = note.created_at.clone().unwrap_or_else(now);
let updated = note.updated_at.clone().unwrap_or_else(|| created.clone());
// The server's `deleted_at` is the authority on trash AGE. Taking it from the feed
// rather than stamping "now" locally is what keeps a note trashed three weeks ago
// from looking brand-new to a device that only just heard about it — otherwise
// every fresh install would silently reset the whole retention clock. Falls back
// to the note's updated_at only if an older server omits the field.
let trashed_at = if note.trashed {
note.deleted_at.clone().or_else(|| Some(updated.clone()))
} else {
None
};
// `created_at` is deliberately absent from the UPDATE clause: a note's birth time
// never changes, and the server's copy is the same value anyway.
conn.execute(
"INSERT INTO notes (id, body, position, pinned, archived,
trashed, remind_at, recurrence, created_at, updated_at,
sync_revision, trashed_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 0)
ON CONFLICT(id) DO UPDATE SET
body = excluded.body,
position = excluded.position,
pinned = excluded.pinned,
archived = excluded.archived,
trashed = excluded.trashed,
remind_at = excluded.remind_at,
recurrence = excluded.recurrence,
updated_at = excluded.updated_at,
sync_revision = excluded.sync_revision,
trashed_at = excluded.trashed_at,
dirty = 0",
params![
note.id,
note.body,
note.position,
note.pinned,
note.archived,
note.trashed,
note.remind_at,
note.recurrence,
created,
updated,
note.sync_revision,
trashed_at,
],
)?;
// Children are replaced wholesale: a delta carries the note's FULL current state,
// so "what the server sent" IS the complete set. Diffing would be more code and
// could leave behind a row the server no longer has.
replace_attachments(conn, note)?;
replace_previews(conn, note)?;
replace_labels(conn, note)?;
Ok(())
}
fn replace_attachments(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
conn.execute(
"DELETE FROM attachments WHERE note_id = ?1",
params![note.id],
)?;
for (index, att) in note.attachments.iter().enumerate() {
// The feed carries no explicit position for attachments — they arrive in
// creation order, so the index preserves it.
conn.execute(
"INSERT INTO attachments (id, note_id, url, filename, mime, size, sha256, position)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
att.id,
note.id,
att.url,
att.filename,
att.mime,
att.size,
att.sha256,
index as i64
],
)?;
}
Ok(())
}
fn replace_previews(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
conn.execute(
"DELETE FROM link_previews WHERE note_id = ?1",
params![note.id],
)?;
for (index, preview) in note.previews.iter().enumerate() {
conn.execute(
"INSERT INTO link_previews (id, note_id, url, title, description, image_url,
site_name, position)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
preview.id,
note.id,
preview.url,
preview.title,
preview.description,
preview.image_url,
preview.site_name,
index as i64
],
)?;
}
Ok(())
}
fn replace_labels(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
conn.execute(
"DELETE FROM note_labels WHERE note_id = ?1",
params![note.id],
)?;
for label in &note.labels {
ensure_label_stub(conn, label)?;
// `via_tag` is applied verbatim rather than re-derived from the body. The
// server already reconciled tags when it saved the note, and re-deriving here
// would call the local find-or-create path, which marks new labels dirty and
// would push them straight back — sync churn out of nothing.
conn.execute(
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag)
VALUES (?1, ?2, ?3)",
params![note.id, label.id, label.via_tag],
)?;
}
Ok(())
}
/// Materialize a label referenced by a note, if we don't have it yet.
///
/// Notes and labels page from one shared sequence, so a note can reference a label
/// whose own delta landed in an earlier page — or, right at a page boundary, hasn't
/// landed. The note carries enough of the label to create it, so a membership never
/// fails on a missing row. `OR IGNORE` because the label's real delta (later in this
/// page or a future one) is the authority on its name and color.
fn ensure_label_stub(conn: &Connection, label: &wire::NoteLabel) -> rusqlite::Result<()> {
let ts = now();
conn.execute(
"INSERT OR IGNORE INTO labels (id, name, color, created_at, updated_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?4, 0)",
params![label.id, label.name, label.color, ts],
)?;
Ok(())
}
/// Loop the feed to exhaustion, starting from the persisted cursor.
///
/// NOTE ON ORDERING: the full cycle is push-then-pull (docs/sync.md). Running this
/// against a store with unpushed edits lets the server's version land on top of them
/// — counted as `clobbered_dirty` and logged, rather than hidden.
pub async fn run(
db: &Db,
blobs: &BlobStore,
base_url: &str,
token: &str,
) -> Result<PullSummary, String> {
let mut total = PullSummary::default();
loop {
let since = {
let conn = db.0.lock().map_err(|e| e.to_string())?;
state::read(&conn).map_err(|e| e.to_string())?.last_cursor
};
let page = client::fetch_changes(base_url, token, since).await?;
// Trust the data over the flag: a server that claims more pages without
// advancing the cursor would spin this loop forever.
if page.has_more && page.cursor <= since {
return Err(format!(
"The server reported more changes but its cursor didn't advance past \
{since}. Stopping rather than looping forever."
));
}
let has_more = page.has_more;
let applied = {
let conn = db.0.lock().map_err(|e| e.to_string())?;
apply_page(&conn, &page).map_err(|e| e.to_string())?
};
total.absorb(applied);
if !has_more {
break;
}
if total.pages >= MAX_PAGES {
return Err(format!(
"Stopped after {MAX_PAGES} pages without reaching the end of the \
server's changes. Something is wrong with the feed."
));
}
}
// Notes first, bytes after: the metadata is what makes the attachments knowable,
// and knowing one is missing is what lets the next cycle retry it.
let (downloaded, failed) = download_missing_blobs(db, blobs, base_url, token).await?;
total.blobs_downloaded = downloaded;
total.blobs_failed = failed;
if total.clobbered_dirty > 0 {
log::warn!(
"pull overwrote {} note(s) that still had unpushed local edits",
total.clobbered_dirty
);
}
if total.blobs_failed > 0 {
log::warn!(
"pull: {} attachment(s) couldn't be downloaded; will retry next sync",
total.blobs_failed
);
}
log::info!(
"pull complete: {} page(s), {} note(s) applied, {} deleted, {} label(s) applied, cursor {}",
total.pages,
total.notes_applied,
total.notes_deleted,
total.labels_applied,
total.cursor
);
Ok(total)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::local::schema;
fn db() -> Connection {
let conn = Connection::open_in_memory().expect("in-memory db");
schema::migrate(&conn).expect("migrate");
conn
}
fn note(id: &str, revision: i64) -> wire::Note {
wire::Note {
id: id.to_string(),
body: "Body".into(),
position: 0,
pinned: false,
archived: false,
trashed: false,
deleted_at: None,
remind_at: None,
recurrence: None,
created_at: Some("2026-07-26T00:00:00.000Z".into()),
updated_at: Some("2026-07-26T00:00:00.000Z".into()),
sync_revision: revision,
purged_at: None,
labels: vec![],
attachments: vec![],
previews: vec![],
}
}
fn attachment(id: &str) -> wire::Attachment {
wire::Attachment {
id: id.to_string(),
url: "/blob/x".into(),
filename: None,
mime: "image/png".into(),
size: None,
sha256: None,
}
}
fn page(notes: Vec<wire::Note>, labels: Vec<wire::Label>, cursor: i64) -> wire::ChangesPage {
wire::ChangesPage {
notes,
labels,
cursor,
has_more: false,
}
}
fn count(conn: &Connection, sql: &str) -> i64 {
conn.query_row(sql, [], |r| r.get(0)).expect("count")
}
fn trash_stamp(conn: &Connection, id: &str) -> Option<String> {
let sql = "SELECT trashed_at FROM notes WHERE id = ?1";
conn.query_row(sql, [id], |r| r.get(0)).expect("stamp")
}
#[test]
fn applies_a_note_and_advances_the_cursor() {
let conn = db();
let summary = apply_page(&conn, &page(vec![note("n1", 7)], vec![], 7)).expect("apply");
assert_eq!(summary.notes_applied, 1);
assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 1);
assert_eq!(state::read(&conn).expect("state").last_cursor, 7);
}
#[test]
fn pulled_rows_are_not_dirty() {
// They came FROM the server, so pushing them back would be pure churn.
let conn = db();
apply_page(&conn, &page(vec![note("n1", 1)], vec![], 1)).expect("apply");
assert_eq!(count(&conn, "SELECT dirty FROM notes WHERE id = 'n1'"), 0);
}
#[test]
fn tombstone_deletes_the_local_note() {
let conn = db();
apply_page(&conn, &page(vec![note("n1", 1)], vec![], 1)).expect("apply");
let mut dead = note("n1", 2);
dead.purged_at = Some("2026-07-26T01:00:00.000Z".into());
let summary = apply_page(&conn, &page(vec![dead], vec![], 2)).expect("apply");
assert_eq!(summary.notes_deleted, 1);
assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 0);
}
#[test]
fn trashed_is_not_a_tombstone() {
// `trashed` is ordinary state that keeps syncing; only `purged_at` deletes.
let conn = db();
let mut trashed = note("n1", 1);
trashed.trashed = true;
apply_page(&conn, &page(vec![trashed], vec![], 1)).expect("apply");
assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 1);
assert_eq!(count(&conn, "SELECT trashed FROM notes WHERE id = 'n1'"), 1);
}
#[test]
fn trash_age_comes_from_the_server_not_from_now() {
// The retention countdown runs off this timestamp. Stamping it locally would
// hand every note a fresh 30 days on any device that syncs it for the first
// time — a note trashed last month would never expire anywhere.
let conn = db();
let mut trashed = note("n1", 1);
trashed.trashed = true;
trashed.deleted_at = Some("2026-06-01T09:30:00+00:00".into());
apply_page(&conn, &page(vec![trashed], vec![], 1)).expect("apply");
let stamped = trash_stamp(&conn, "n1");
assert_eq!(stamped.as_deref(), Some("2026-06-01T09:30:00+00:00"));
}
#[test]
fn restoring_a_note_server_side_clears_its_trash_stamp() {
let conn = db();
let mut trashed = note("n1", 1);
trashed.trashed = true;
trashed.deleted_at = Some("2026-06-01T09:30:00+00:00".into());
apply_page(&conn, &page(vec![trashed], vec![], 1)).expect("apply");
apply_page(&conn, &page(vec![note("n1", 2)], vec![], 2)).expect("apply");
let stamped = trash_stamp(&conn, "n1");
assert_eq!(stamped, None, "an untrashed note keeps no trash stamp");
}
#[test]
fn an_older_server_without_deleted_at_still_ages_the_trash() {
// Falls back to updated_at rather than leaving the stamp null, which would
// make the note un-expirable and its countdown blank.
let conn = db();
let mut trashed = note("n1", 1);
trashed.trashed = true;
trashed.deleted_at = None;
apply_page(&conn, &page(vec![trashed], vec![], 1)).expect("apply");
let stamped = trash_stamp(&conn, "n1");
assert_eq!(stamped.as_deref(), Some("2026-07-26T00:00:00.000Z"));
}
#[test]
fn children_are_replaced_not_merged() {
// Was written over checklist items; they are lines of the body now (M304), so
// attachments carry the point instead. It is the same property either way: a
// delta is the note's FULL current state, so a child the server dropped has to
// disappear locally rather than linger.
let conn = db();
let mut first = note("n1", 1);
first.attachments = vec![attachment("a1"), attachment("a2")];
apply_page(&conn, &page(vec![first], vec![], 1)).expect("apply");
assert_eq!(count(&conn, "SELECT COUNT(*) FROM attachments"), 2);
let mut second = note("n1", 2);
second.attachments = vec![attachment("a1")];
apply_page(&conn, &page(vec![second], vec![], 2)).expect("apply");
assert_eq!(count(&conn, "SELECT COUNT(*) FROM attachments"), 1);
}
#[test]
fn note_label_membership_materializes_a_missing_label() {
// The label's own delta may have landed in an earlier page, or not yet.
let conn = db();
let mut n = note("n1", 1);
n.labels = vec![wire::NoteLabel {
id: "l1".into(),
name: "work".into(),
color: "blue".into(),
via_tag: true,
}];
apply_page(&conn, &page(vec![n], vec![], 1)).expect("apply");
assert_eq!(count(&conn, "SELECT COUNT(*) FROM labels"), 1);
assert_eq!(
count(
&conn,
"SELECT via_tag FROM note_labels WHERE note_id = 'n1'"
),
1,
"via_tag is applied verbatim, not re-derived"
);
}
#[test]
fn server_label_replaces_a_local_duplicate_by_name() {
let conn = db();
conn.execute(
"INSERT INTO labels (id, name, color, created_at, updated_at, dirty)
VALUES ('local-id', 'Work', 'default', '2026-01-01', '2026-01-01', 1)",
[],
)
.expect("seed local label");
let server = wire::Label {
id: "server-id".into(),
name: "work".into(),
color: "blue".into(),
sync_revision: 5,
purged_at: None,
created_at: Some("2026-07-26T00:00:00.000Z".into()),
};
apply_page(&conn, &page(vec![], vec![server], 5)).expect("apply");
assert_eq!(count(&conn, "SELECT COUNT(*) FROM labels"), 1);
let id: String = conn
.query_row("SELECT id FROM labels", [], |r| r.get(0))
.expect("label");
assert_eq!(id, "server-id", "the server's row wins on pull");
}
#[test]
fn merging_a_duplicate_label_keeps_its_note_memberships() {
// The notes carrying the local label may not be in this page at all, so a
// plain delete would strip the label off them with nothing to repair it.
let conn = db();
apply_page(&conn, &page(vec![note("n1", 1)], vec![], 1)).expect("seed note");
conn.execute(
"INSERT INTO labels (id, name, color, created_at, updated_at, dirty)
VALUES ('local-id', 'Work', 'default', '2026-01-01', '2026-01-01', 1)",
[],
)
.expect("seed local label");
conn.execute(
"INSERT INTO note_labels (note_id, label_id, via_tag)
VALUES ('n1', 'local-id', 0)",
[],
)
.expect("seed membership");
let server = wire::Label {
id: "server-id".into(),
name: "work".into(),
color: "blue".into(),
sync_revision: 5,
purged_at: None,
created_at: None,
};
apply_page(&conn, &page(vec![], vec![server], 5)).expect("apply");
assert_eq!(count(&conn, "SELECT COUNT(*) FROM labels"), 1);
let label_id: String = conn
.query_row(
"SELECT label_id FROM note_labels WHERE note_id = 'n1'",
[],
|r| r.get(0),
)
.expect("membership survived");
assert_eq!(label_id, "server-id", "membership re-pointed, not dropped");
}
#[test]
fn label_tombstone_deletes_and_cascades_memberships() {
let conn = db();
let mut n = note("n1", 1);
n.labels = vec![wire::NoteLabel {
id: "l1".into(),
name: "work".into(),
color: "blue".into(),
via_tag: false,
}];
apply_page(&conn, &page(vec![n], vec![], 1)).expect("apply");
assert_eq!(count(&conn, "SELECT COUNT(*) FROM note_labels"), 1);
let dead = wire::Label {
id: "l1".into(),
name: "work".into(),
color: "blue".into(),
sync_revision: 2,
purged_at: Some("2026-07-26T01:00:00.000Z".into()),
created_at: None,
};
apply_page(&conn, &page(vec![], vec![dead], 2)).expect("apply");
assert_eq!(count(&conn, "SELECT COUNT(*) FROM labels"), 0);
assert_eq!(
count(&conn, "SELECT COUNT(*) FROM note_labels"),
0,
"membership should cascade with the label"
);
}
#[test]
fn overwriting_a_dirty_note_is_counted() {
let conn = db();
conn.execute(
"INSERT INTO notes (id, body, created_at, updated_at, dirty)
VALUES ('n1', 'local edit', '2026-01-01', '2026-01-01', 1)",
[],
)
.expect("seed dirty note");
let summary = apply_page(&conn, &page(vec![note("n1", 9)], vec![], 9)).expect("apply");
assert_eq!(summary.clobbered_dirty, 1);
}
#[test]
fn applying_a_fresh_note_reports_no_clobber() {
let conn = db();
let summary = apply_page(&conn, &page(vec![note("n1", 1)], vec![], 1)).expect("apply");
assert_eq!(summary.clobbered_dirty, 0);
}
#[test]
fn empty_page_still_advances_the_cursor() {
// The server can page past rows that were trimmed to the shared watermark.
let conn = db();
apply_page(&conn, &page(vec![], vec![], 42)).expect("apply");
assert_eq!(state::read(&conn).expect("state").last_cursor, 42);
}
#[test]
fn note_upsert_preserves_the_original_created_at() {
let conn = db();
apply_page(&conn, &page(vec![note("n1", 1)], vec![], 1)).expect("apply");
let mut later = note("n1", 2);
later.created_at = Some("2099-01-01T00:00:00.000Z".into());
apply_page(&conn, &page(vec![later], vec![], 2)).expect("apply");
let created: String = conn
.query_row("SELECT created_at FROM notes WHERE id = 'n1'", [], |r| {
r.get(0)
})
.expect("created_at");
assert_eq!(created, "2026-07-26T00:00:00.000Z");
}
#[test]
fn a_page_that_fails_leaves_the_cursor_untouched() {
// Atomicity is the whole resumability story: a cursor committed ahead of its
// data would skip those rows forever. Force a failure with a duplicate
// attachment id inside one page.
let conn = db();
let mut n = note("n1", 3);
n.attachments = vec![attachment("dup"), attachment("dup")];
assert!(apply_page(&conn, &page(vec![n], vec![], 3)).is_err());
assert_eq!(state::read(&conn).expect("state").last_cursor, 0);
assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 0);
}
}
+714
View File
@@ -0,0 +1,714 @@
//! Push: send local changes to the server and apply what it says (M10.7c).
//!
//! Two sources feed a push: rows flagged `dirty` (created or edited locally) and rows
//! in `pending_deletes` (permanently deleted locally — see `local::schema` v2 for why
//! a delete needs its own record).
//!
//! Sync is **whole-note**: an upsert carries the client's full current state, not a
//! patch (docs/sync.md). The server resolves conflicts last-write-wins by the client's
//! `edited_at`, snapshotting anything it overwrites into the note's version history.
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use super::client;
use super::state;
use crate::local::Db;
/// The server rejects a batch larger than this (`MAX_PUSH` in `sync.py`).
const BATCH: usize = 500;
/// Backstop: a batch whose results never clear `dirty` would loop forever.
const MAX_BATCHES: usize = 10_000;
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct PushSummary {
pub batches: usize,
pub sent: usize,
pub created: usize,
pub applied: usize,
/// The server had a newer edit and kept it. Not a failure — the local row stops
/// being dirty and the following pull adopts the server's version.
pub kept: usize,
pub noop: usize,
/// Still dirty, and surfaced: these need a human (a duplicate label name is the
/// realistic case). Silently retrying forever would be the wrong shape.
pub rejected: usize,
pub errors: Vec<String>,
}
impl PushSummary {
fn absorb(&mut self, other: PushSummary) {
self.batches += other.batches;
self.sent += other.sent;
self.created += other.created;
self.applied += other.applied;
self.kept += other.kept;
self.noop += other.noop;
self.rejected += other.rejected;
self.errors.extend(other.errors);
}
}
// --- outgoing shapes ---------------------------------------------------------
/// One entry in the `changes` array. Notes and labels share the envelope; serde skips
/// the fields that don't apply, so the server sees exactly the shape docs/sync.md
/// describes for each entity.
#[derive(Debug, Serialize)]
pub struct Change {
pub entity: &'static str,
pub id: String,
pub op: &'static str,
pub edited_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
/// A LABEL's colour. A note has none since M315, so a note change leaves this
/// `None` and the key never reaches the wire.
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pinned: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub archived: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trashed: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remind_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recurrence: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub position: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
impl Change {
fn delete(entity: &'static str, id: String, edited_at: String) -> Self {
Change {
entity,
id,
op: "delete",
edited_at,
body: None,
color: None,
pinned: None,
archived: None,
trashed: None,
remind_at: None,
recurrence: None,
position: None,
label_ids: None,
created_at: None,
name: None,
}
}
}
// --- incoming results --------------------------------------------------------
#[derive(Debug, Deserialize)]
struct PushResponse {
#[serde(default)]
results: Vec<PushResult>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PushResult {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub entity: Option<String>,
#[serde(default)]
pub status: String,
#[serde(default)]
pub sync_revision: Option<i64>,
#[serde(default)]
pub error: Option<String>,
}
// --- collecting --------------------------------------------------------------
/// Everything waiting to go up, oldest edit first so a truncated batch still makes
/// forward progress in a sensible order.
pub fn collect(conn: &Connection, limit: usize) -> rusqlite::Result<Vec<Change>> {
let mut out = Vec::new();
collect_deletes(conn, &mut out, limit)?;
if out.len() < limit {
collect_labels(conn, &mut out, limit)?;
}
if out.len() < limit {
collect_notes(conn, &mut out, limit)?;
}
Ok(out)
}
fn collect_deletes(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rusqlite::Result<()> {
let mut stmt = conn.prepare(
"SELECT entity, id, deleted_at FROM pending_deletes ORDER BY deleted_at LIMIT ?1",
)?;
let rows = stmt.query_map(params![limit as i64], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
))
})?;
for row in rows {
let (entity, id, deleted_at) = row?;
// Only 'note' and 'label' exist on the wire; anything else is a bug in a
// writer, and shipping it would earn a blanket rejection for the batch.
let entity: &'static str = match entity.as_str() {
"note" => "note",
"label" => "label",
_ => continue,
};
out.push(Change::delete(entity, id, deleted_at));
}
Ok(())
}
fn collect_labels(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rusqlite::Result<()> {
let remaining = limit.saturating_sub(out.len());
let mut stmt = conn.prepare(
"SELECT id, name, color, updated_at FROM labels
WHERE dirty = 1 ORDER BY updated_at LIMIT ?1",
)?;
let rows = stmt.query_map(params![remaining as i64], |r| {
Ok(Change {
entity: "label",
id: r.get(0)?,
op: "upsert",
name: Some(r.get(1)?),
color: Some(r.get(2)?),
edited_at: r.get(3)?,
body: None,
pinned: None,
archived: None,
trashed: None,
remind_at: None,
recurrence: None,
position: None,
label_ids: None,
created_at: None,
})
})?;
for row in rows {
out.push(row?);
}
Ok(())
}
fn collect_notes(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rusqlite::Result<()> {
let remaining = limit.saturating_sub(out.len());
let ids: Vec<String> = {
let mut stmt =
conn.prepare("SELECT id FROM notes WHERE dirty = 1 ORDER BY updated_at LIMIT ?1")?;
let rows = stmt.query_map(params![remaining as i64], |r| r.get::<_, String>(0))?;
rows.collect::<rusqlite::Result<Vec<String>>>()?
};
for id in ids {
out.push(note_change(conn, &id)?);
}
Ok(())
}
/// The note's own columns. A named struct rather than a twelve-wide tuple so the
/// field-to-column mapping stays readable at the call site.
struct NoteRow {
body: String,
position: i64,
pinned: bool,
archived: bool,
trashed: bool,
remind_at: Option<String>,
recurrence: Option<String>,
created_at: String,
updated_at: String,
}
fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
conn.query_row(
"SELECT body, position, pinned, archived, trashed,
remind_at, recurrence, created_at, updated_at
FROM notes WHERE id = ?1",
params![id],
|r| {
Ok(NoteRow {
body: r.get(0)?,
position: r.get(1)?,
pinned: r.get::<_, i64>(2)? != 0,
archived: r.get::<_, i64>(3)? != 0,
trashed: r.get::<_, i64>(4)? != 0,
remind_at: r.get(5)?,
recurrence: r.get(6)?,
created_at: r.get(7)?,
updated_at: r.get(8)?,
})
},
)
}
fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> {
let row = note_row(conn, id)?;
// MANUAL memberships only. Tag-sourced ones (`via_tag = 1`) are re-derived by the
// server from the body; sending them as label_ids would convert them into manual
// assignments that no longer disappear when the #tag is removed from the text.
let label_ids = {
let mut stmt =
conn.prepare("SELECT label_id FROM note_labels WHERE note_id = ?1 AND via_tag = 0")?;
let rows = stmt.query_map(params![id], |r| r.get::<_, String>(0))?;
rows.collect::<rusqlite::Result<Vec<String>>>()?
};
Ok(Change {
entity: "note",
id: id.to_string(),
op: "upsert",
// The local `updated_at` IS the client's edit time, which is what the
// server's last-write-wins comparison runs against.
edited_at: row.updated_at,
body: Some(row.body),
// A note has no colour to send. See the field on `Change`.
color: None,
pinned: Some(row.pinned),
archived: Some(row.archived),
trashed: Some(row.trashed),
remind_at: row.remind_at,
recurrence: row.recurrence,
position: Some(row.position),
label_ids: Some(label_ids),
created_at: Some(row.created_at),
name: None,
})
}
// --- applying results --------------------------------------------------------
/// Fold one batch's results back into the local store, atomically.
pub fn apply_results(
conn: &Connection,
sent: &[Change],
results: &[PushResult],
) -> rusqlite::Result<PushSummary> {
let tx = conn.unchecked_transaction()?;
let mut summary = PushSummary {
batches: 1,
sent: sent.len(),
..Default::default()
};
// The server answers positionally, one result per change. Zip rather than trust
// the echoed id: a rejected malformed entry may carry no id at all.
let mut lowest_kept: Option<i64> = None;
for (change, result) in sent.iter().zip(results.iter()) {
match result.status.as_str() {
"created" | "applied" => {
clear_dirty(&tx, change, result.sync_revision)?;
if result.status == "created" {
summary.created += 1;
} else {
summary.applied += 1;
}
if change.op == "delete" {
forget_pending_delete(&tx, change)?;
}
}
"noop" => {
// The server had nothing to do — typically a delete for a row it
// never saw (created and deleted while offline).
clear_dirty(&tx, change, result.sync_revision)?;
forget_pending_delete(&tx, change)?;
summary.noop += 1;
}
"kept" => {
// The server's version is newer. Stop being dirty — re-pushing would
// lose to the same comparison forever — and let the next pull bring
// the server's copy down.
clear_dirty(&tx, change, None)?;
if change.op == "delete" {
// Our delete lost to a newer server edit; the note lives on, and
// the pull will restore it locally. Drop the tombstone so we
// don't keep trying to delete a note the user has since edited.
forget_pending_delete(&tx, change)?;
}
if let Some(revision) = result.sync_revision {
lowest_kept = Some(lowest_kept.map_or(revision, |c: i64| c.min(revision)));
}
summary.kept += 1;
}
_ => {
// "rejected" and anything unrecognized: leave the row dirty so it is
// retried, and surface the reason. A duplicate label name is the
// realistic case and only a human can resolve it.
summary.rejected += 1;
let reason = result
.error
.clone()
.unwrap_or_else(|| result.status.clone());
summary
.errors
.push(format!("{} {}: {reason}", change.entity, change.id));
}
}
}
// A `kept` result means the server holds a version we have not seen. Normally its
// revision is above our cursor and the next pull fetches it anyway. If it is NOT
// — which happens when a skewed clock makes a genuinely later local edit look
// older — rewind so that note is re-fetched. Without this the local edit is
// dropped from sync and the stale copy stays on screen with nothing marking it.
if let Some(revision) = lowest_kept {
let current = state::read(&tx)?.last_cursor;
if revision <= current {
state::set_cursor(&tx, (revision - 1).max(0))?;
}
}
tx.commit()?;
Ok(summary)
}
fn clear_dirty(conn: &Connection, change: &Change, revision: Option<i64>) -> rusqlite::Result<()> {
// A delete has no local row left to update.
if change.op == "delete" {
return Ok(());
}
let table = match change.entity {
"label" => "labels",
_ => "notes",
};
match revision {
Some(rev) => conn.execute(
&format!("UPDATE {table} SET dirty = 0, sync_revision = ?2 WHERE id = ?1"),
params![change.id, rev],
)?,
None => conn.execute(
&format!("UPDATE {table} SET dirty = 0 WHERE id = ?1"),
params![change.id],
)?,
};
Ok(())
}
fn forget_pending_delete(conn: &Connection, change: &Change) -> rusqlite::Result<()> {
if change.op != "delete" {
return Ok(());
}
conn.execute(
"DELETE FROM pending_deletes WHERE entity = ?1 AND id = ?2",
params![change.entity, change.id],
)?;
Ok(())
}
/// True when anything is waiting to go up. Cheap enough to call before a cycle.
pub fn has_pending(conn: &Connection) -> rusqlite::Result<bool> {
let pending: Option<i64> = conn
.query_row(
"SELECT 1 FROM notes WHERE dirty = 1
UNION ALL SELECT 1 FROM labels WHERE dirty = 1
UNION ALL SELECT 1 FROM pending_deletes LIMIT 1",
[],
|r| r.get(0),
)
.optional()?;
Ok(pending.is_some())
}
/// Send everything pending, in batches, applying each batch's results before the
/// next is collected.
pub async fn run(db: &Db, base_url: &str, token: &str) -> Result<PushSummary, String> {
let mut total = PushSummary::default();
loop {
let batch = {
let conn = db.0.lock().map_err(|e| e.to_string())?;
collect(&conn, BATCH).map_err(|e| e.to_string())?
};
if batch.is_empty() {
break;
}
let raw = client::push_changes(base_url, token, &batch).await?;
let results = parse_results(&raw)?;
let applied = {
let conn = db.0.lock().map_err(|e| e.to_string())?;
apply_results(&conn, &batch, &results).map_err(|e| e.to_string())?
};
// Everything rejected clears nothing, so the same batch would be collected
// again forever. Stop and report instead.
let progressed = applied.rejected < applied.sent;
total.absorb(applied);
if !progressed {
break;
}
if total.batches >= MAX_BATCHES {
return Err(format!(
"Stopped after {MAX_BATCHES} push batches without draining the queue."
));
}
}
if total.rejected > 0 {
log::warn!(
"push: {} change(s) rejected by the server: {}",
total.rejected,
total.errors.join("; ")
);
}
log::info!(
"push complete: {} sent ({} created, {} applied, {} kept, {} noop, {} rejected)",
total.sent,
total.created,
total.applied,
total.kept,
total.noop,
total.rejected
);
Ok(total)
}
/// Parse the server's reply. Kept next to the shapes it produces.
pub fn parse_results(raw: &str) -> Result<Vec<PushResult>, String> {
let parsed: PushResponse =
serde_json::from_str(raw).map_err(|e| format!("Couldn't read the push response: {e}"))?;
Ok(parsed.results)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::local::schema;
use crate::local::store;
fn db() -> Connection {
let conn = Connection::open_in_memory().expect("in-memory db");
schema::migrate(&conn).expect("migrate");
conn
}
fn seed_note(conn: &Connection, id: &str, dirty: i64) {
conn.execute(
"INSERT INTO notes (id, body, position, pinned, archived,
trashed, created_at, updated_at, sync_revision, dirty)
VALUES (?1, 'B', 0, 0, 0, 0,
'2026-07-26T00:00:00.000Z', '2026-07-26T00:00:00.000Z', 3, ?2)",
params![id, dirty],
)
.expect("seed note");
}
fn ok(status: &str, revision: Option<i64>) -> PushResult {
PushResult {
id: None,
entity: None,
status: status.to_string(),
sync_revision: revision,
error: None,
}
}
fn dirty_count(conn: &Connection) -> i64 {
conn.query_row("SELECT COUNT(*) FROM notes WHERE dirty = 1", [], |r| {
r.get(0)
})
.expect("count")
}
#[test]
fn collects_only_dirty_notes() {
let conn = db();
seed_note(&conn, "clean", 0);
seed_note(&conn, "dirty", 1);
let batch = collect(&conn, 100).expect("collect");
assert_eq!(batch.len(), 1);
assert_eq!(batch[0].id, "dirty");
assert_eq!(batch[0].op, "upsert");
}
#[test]
fn sends_only_manual_label_memberships() {
// Tag-sourced labels are re-derived server-side. Sending them as label_ids
// would convert them to manual assignments that survive removing the #tag.
let conn = db();
seed_note(&conn, "n1", 1);
for (id, name, via_tag) in [("manual", "Manual", 0), ("tagged", "Tagged", 1)] {
conn.execute(
"INSERT INTO labels (id, name, color, created_at, updated_at, dirty)
VALUES (?1, ?2, 'default', '2026-01-01', '2026-01-01', 0)",
params![id, name],
)
.expect("seed label");
conn.execute(
"INSERT INTO note_labels (note_id, label_id, via_tag) VALUES ('n1', ?1, ?2)",
params![id, via_tag],
)
.expect("seed membership");
}
let batch = collect(&conn, 100).expect("collect");
let note = batch.iter().find(|c| c.entity == "note").expect("note");
assert_eq!(note.label_ids.as_deref(), Some(&["manual".to_string()][..]));
}
#[test]
fn a_local_delete_becomes_a_delete_change() {
let conn = db();
seed_note(&conn, "n1", 0);
store::delete_forever(&conn, "n1").expect("delete");
let batch = collect(&conn, 100).expect("collect");
assert_eq!(batch.len(), 1);
assert_eq!(batch[0].op, "delete");
assert_eq!(batch[0].entity, "note");
assert_eq!(batch[0].id, "n1");
}
#[test]
fn applied_clears_dirty_and_records_the_revision() {
let conn = db();
seed_note(&conn, "n1", 1);
let batch = collect(&conn, 100).expect("collect");
apply_results(&conn, &batch, &[ok("applied", Some(42))]).expect("apply");
assert_eq!(dirty_count(&conn), 0);
let rev: i64 = conn
.query_row("SELECT sync_revision FROM notes WHERE id = 'n1'", [], |r| {
r.get(0)
})
.expect("revision");
assert_eq!(rev, 42);
}
#[test]
fn kept_clears_dirty_so_it_is_not_pushed_forever() {
// The server has a newer edit. Re-pushing would lose the same comparison
// every time; the following pull adopts the server's version instead.
let conn = db();
seed_note(&conn, "n1", 1);
let batch = collect(&conn, 100).expect("collect");
let summary = apply_results(&conn, &batch, &[ok("kept", Some(99))]).expect("apply");
assert_eq!(summary.kept, 1);
assert_eq!(dirty_count(&conn), 0);
}
#[test]
fn kept_rewinds_the_cursor_when_the_server_version_is_already_behind_it() {
// Clock skew: a genuinely later local edit can look older, so the server
// keeps its copy at a revision we have ALREADY consumed. Without a rewind the
// next pull skips it and the stale local copy stays on screen silently.
let conn = db();
seed_note(&conn, "n1", 1);
state::set_cursor(&conn, 100).expect("cursor");
let batch = collect(&conn, 100).expect("collect");
apply_results(&conn, &batch, &[ok("kept", Some(40))]).expect("apply");
assert_eq!(state::read(&conn).expect("state").last_cursor, 39);
}
#[test]
fn kept_leaves_the_cursor_alone_when_the_server_version_is_ahead() {
let conn = db();
seed_note(&conn, "n1", 1);
state::set_cursor(&conn, 10).expect("cursor");
let batch = collect(&conn, 100).expect("collect");
apply_results(&conn, &batch, &[ok("kept", Some(40))]).expect("apply");
assert_eq!(
state::read(&conn).expect("state").last_cursor,
10,
"the pending pull already covers it"
);
}
#[test]
fn rejected_stays_dirty_and_is_reported() {
let conn = db();
seed_note(&conn, "n1", 1);
let batch = collect(&conn, 100).expect("collect");
let mut bad = ok("rejected", None);
bad.error = Some("name in use".into());
let summary = apply_results(&conn, &batch, &[bad]).expect("apply");
assert_eq!(summary.rejected, 1);
assert_eq!(dirty_count(&conn), 1, "a rejected change must be retried");
assert!(summary.errors[0].contains("name in use"));
}
#[test]
fn an_acknowledged_delete_drops_its_tombstone() {
let conn = db();
seed_note(&conn, "n1", 0);
store::delete_forever(&conn, "n1").expect("delete");
let batch = collect(&conn, 100).expect("collect");
apply_results(&conn, &batch, &[ok("applied", Some(7))]).expect("apply");
assert!(!has_pending(&conn).expect("pending"));
}
#[test]
fn a_noop_delete_also_drops_its_tombstone() {
// Created and deleted entirely offline: the server never saw it.
let conn = db();
seed_note(&conn, "n1", 1);
store::delete_forever(&conn, "n1").expect("delete");
let batch = collect(&conn, 100).expect("collect");
apply_results(&conn, &batch, &[ok("noop", None)]).expect("apply");
assert!(!has_pending(&conn).expect("pending"));
}
#[test]
fn merging_labels_marks_the_affected_notes_dirty() {
// The membership change only reaches the server through the note itself.
let conn = db();
seed_note(&conn, "n1", 0);
for (id, name) in [("src", "Source"), ("dst", "Target")] {
conn.execute(
"INSERT INTO labels (id, name, color, created_at, updated_at, dirty)
VALUES (?1, ?2, 'default', '2026-01-01', '2026-01-01', 0)",
params![id, name],
)
.expect("seed label");
}
conn.execute(
"INSERT INTO note_labels (note_id, label_id, via_tag) VALUES ('n1', 'src', 0)",
[],
)
.expect("seed membership");
store::merge_labels(&conn, "src", "dst").expect("merge");
assert_eq!(dirty_count(&conn), 1, "the note's label set changed");
}
#[test]
fn has_pending_is_false_on_a_clean_store() {
let conn = db();
seed_note(&conn, "n1", 0);
assert!(!has_pending(&conn).expect("pending"));
}
#[test]
fn parse_results_reads_the_documented_shape() {
let results = parse_results(
r#"{"results":[{"id":"a","entity":"note","status":"created","sync_revision":44},
{"id":"b","entity":"label","status":"rejected","error":"name in use"}]}"#,
)
.expect("parse");
assert_eq!(results.len(), 2);
assert_eq!(results[0].status, "created");
assert_eq!(results[1].error.as_deref(), Some("name in use"));
}
#[test]
fn a_delete_change_serializes_without_note_fields() {
let change = Change::delete("note", "n1".into(), "2026-07-26T00:00:00.000Z".into());
let json = serde_json::to_string(&change).expect("serialize");
assert!(json.contains("\"op\":\"delete\""), "got {json}");
assert!(
!json.contains("body"),
"a delete carries no content: {json}"
);
}
}

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