2 Commits
Author SHA1 Message Date
bvandeusen 1b3e29e4f6 0.2.0 — a notebook in your pocket, ready to be hosted (#3)
Android / Kotlin + Rust (APK) (push) Successful in 7m45s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Successful in 15s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m18s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m18s
Desktop (Tauri) / Update manifest (push) Successful in 5s
2026-08-23 16:38:00 -04:00
bvandeusen 867405fae2 M12 — the Android client, end to end (#2)
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m47s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m8s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m13s
CI & Build / Python tests (push) Successful in 11s
86 commits from dev. Native Kotlin/Compose Android client over the shared Rust
core, the server-served distribution path, signing, and self-update.

Known and recorded rather than fixed: #2810 (release APK carries a debug-profile
.so), allowBackup still true now that the store holds a device token, and
cleartext HTTP enabled app-wide for self-hosted LAN servers.
2026-08-21 08:53:57 -04:00
252 changed files with 4067 additions and 13367 deletions
+5 -24
View File
@@ -1,4 +1,4 @@
# Inkwell production settings. Copy to `.env` and edit:
# ThoughtSync production settings. Copy to `.env` and edit:
#
# cp .env.example .env
#
@@ -29,15 +29,15 @@ POSTGRES_PASSWORD=
#
# NOTE: `main` can sit well behind `dev`. If a feature you expect is missing,
# check which branch it actually landed on before assuming a bug.
#INKWELL_TAG=latest
#THOUGHTSYNC_TAG=latest
# The host port the app is published on.
#INKWELL_PORT=5000
#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.
#INKWELL_BIND=0.0.0.0
#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
@@ -47,31 +47,12 @@ POSTGRES_PASSWORD=
# How much the app says. Credential events (sign-ins, failures, throttles, new
# accounts, device tokens issued) are logged at INFO and read with
# `docker compose logs app`.
#INKWELL_LOG_LEVEL=INFO
#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=inkwell
#POSTGRES_DB=inkwell
# --- Upgrading from ThoughtSync ---------------------------------------------
#
# Inkwell was called ThoughtSync, and a deployment installed under that name has
# its data in volumes and a database named for it. Point at them instead of
# renaming anything. Leaving these unset on such a deployment starts Inkwell
# against EMPTY volumes; the old data is untouched, but you would not see it.
#
# Use the full names `docker volume ls` prints — compose prefixes them with the
# project name, so they usually look like `thoughtsync_thoughtsync-db`:
#INKWELL_DB_VOLUME=thoughtsync_thoughtsync-db
#INKWELL_DATA_VOLUME=thoughtsync_thoughtsync-data
#
# And the database identity the first start created, which a volume keeps:
#POSTGRES_USER=thoughtsync
#POSTGRES_DB=thoughtsync
#
# Rename any THOUGHTSYNC_* lines already in your .env to INKWELL_* — the old
# names are no longer read.
# --- a note on HTTPS --------------------------------------------------------
#
+46 -98
View File
@@ -4,7 +4,7 @@ name: Android
#
# Replaces the Tauri-mobile lane deleted in step 2. What changed is what this
# builds, not that Android has a lane: the UI is Compose, and the store and sync
# engine are `inkwell-core` cross-compiled by cargo-ndk and loaded through
# 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
@@ -19,9 +19,15 @@ name: Android
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]
paths:
- "android/**"
# The Rust the .so is built from. A core change reaches the phone exactly
# as it reaches the desktop, so this lane has to rebuild on it.
- "core/**"
- "Cargo.toml"
- "Cargo.lock"
- ".forgejo/workflows/android.yml"
workflow_dispatch:
concurrency:
@@ -36,46 +42,8 @@ env:
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
@@ -95,10 +63,6 @@ jobs:
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
@@ -121,20 +85,16 @@ jobs:
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)"
version="$(sh ../desktop/packaging/build-version.sh)"
echo "name=$version" >> $GITHUB_OUTPUT
echo "code=$code" >> $GITHUB_OUTPUT
# versionCode must RISE for Android to accept an update, and the run
# number is the same monotonic counter the desktop's version scheme
# already uses — no state carried between runs, and immune to the
# shallow checkout that makes a commit count useless here.
echo "code=$GITHUB_RUN_NUMBER" >> $GITHUB_OUTPUT
if [ -n "${ANDROID_KEYSTORE_BASE64:-}" ]; then
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/inkwell-release.jks
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
@@ -143,9 +103,9 @@ jobs:
# outright (run 4077). Unpicking that is worth doing and is not worth
# blocking signed builds on.
echo "profile=debug" >> $GITHUB_OUTPUT
echo "keystore=/tmp/inkwell-release.jks" >> $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)"
echo "Signed release build — $version (versionCode $GITHUB_RUN_NUMBER)"
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
@@ -167,7 +127,7 @@ jobs:
# from the built .so. Run as its own step so a Rust failure is legible as a
# Rust failure instead of arriving inside a Gradle stack trace.
- name: Build the native library and bindings
run: ./gradlew generateUniffiBindings -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }}
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
@@ -193,7 +153,7 @@ jobs:
# not exist (run 4082). It costs one extra Kotlin compile and buys the
# type-check on the debug variant, which is the one an emulator build
# would use.
run: ./gradlew testDebugUnitTest -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }}
run: ./gradlew testDebugUnitTest -PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }}
- name: Assemble the APK
env:
@@ -203,9 +163,9 @@ jobs:
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
run: |
./gradlew assemble${{ steps.build.outputs.variant }} \
-PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }} \
-PINKWELL_VERSION_NAME=${{ steps.build.outputs.name }} \
-PINKWELL_VERSION_CODE=${{ steps.build.outputs.code }}
-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
@@ -226,10 +186,10 @@ jobs:
if: steps.build.outputs.keystore != ''
run: |
mkdir -p dist
cp "app/build/outputs/apk/release/app-release.apk" dist/inkwell.apk
size="$(wc -c < dist/inkwell.apk | tr -d ' ')"
sha="$(sha256sum dist/inkwell.apk | cut -d' ' -f1)"
cat > dist/inkwell-android.json <<JSON
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 }},
@@ -237,46 +197,34 @@ jobs:
"sha256": "$sha"
}
JSON
cat dist/inkwell-android.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 != ''
# The rolling dev channel, same fixed-tag release 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.
- name: Publish to the dev channel
if: github.ref == 'refs/heads/dev' && steps.build.outputs.keystore != ''
working-directory: .
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
case "$GITHUB_REF_NAME" in
main) channel=stable; RELEASE_PRERELEASE=false ;;
*) channel=dev; RELEASE_PRERELEASE=true ;;
esac
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
# (packaging/channel-tag.sh). A tag named `dev` shadowed the branch.
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing the APK to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh
RELEASE_TAG: dev
RELEASE_PRERELEASE: "true"
run: bash desktop/packaging/publish-release.sh
- name: Upload the APK
# Stock action: it works on this forge since the runner moved to
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
# out of the action bundle (Scribe snippet #2271). Never @v3 — it reports
# success while Gitea serves artifacts back only through the v4 API, so the
# upload is stored and invisible (Scribe 2270).
uses: actions/upload-artifact@v7
# 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: inkwell-android-${{ steps.build.outputs.label }}-${{ github.sha }}
name: thoughtsync-android-${{ steps.build.outputs.label }}-${{ github.sha }}
path: ${{ steps.build.outputs.apk }}
if-no-files-found: error
+86 -103
View File
@@ -1,21 +1,12 @@
# CI runs first; build only proceeds if lint + typecheck pass.
#
# Push to dev: typecheck + lint + test + build :dev
# Push to main: typecheck + lint + test + build :latest + :<sha>
# 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>
#
# 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.
# 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.
#
# Required secret (repo -> Settings -> Secrets -> Actions):
# REGISTRY_TOKEN -- Forgejo PAT with write:packages scope
@@ -25,36 +16,34 @@ 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.
# Cancel older runs on the same branch when a newer push lands. Tag runs get their
# own group implicitly and are never cancelled.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
permissions:
contents: read
env:
REGISTRY: git.fabledsword.com
IMAGE: git.fabledsword.com/bvandeusen/inkwell
IMAGE: git.fabledsword.com/bvandeusen/thoughtsync
jobs:
# Should this push build an image now, or is the Android lane about to publish a
@@ -75,7 +64,7 @@ jobs:
# 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'
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -100,6 +89,17 @@ jobs:
exit 0
fi
# A tag. The Android lane does not run on tags, so nothing would ever
# call back — standing down here would mean a release tag that never
# produces an image at all.
case "${{ github.ref }}" in
refs/tags/*)
echo "Tag build — the Android lane does not run on tags. Building."
echo "build=true" >> $GITHUB_OUTPUT
exit 0
;;
esac
# 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
@@ -125,12 +125,7 @@ jobs:
echo "Changed in this push:"
echo "$changed" | sed 's/^/ /'
# MUST match android's file set in packaging/version.sh. `packaging/` was
# missing here after step 4 added it there — so a packaging-only push had
# the Android lane rebuild and dispatch while this gate ALSO let the image
# build, producing two images for one commit and, on main, a second push of
# the same :<sha> with different bytes. Rule 145's exact prohibition.
if echo "$changed" | grep -qE '^(android/|core/|packaging/|Cargo\.toml$|Cargo\.lock$|\.forgejo/workflows/android\.yml$)'; then
if echo "$changed" | grep -qE '^(android/|core/|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,"
@@ -145,7 +140,7 @@ jobs:
typecheck:
name: TypeScript typecheck
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -162,7 +157,7 @@ jobs:
lint:
name: Python lint
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -175,7 +170,7 @@ jobs:
test:
name: Python tests
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -198,14 +193,15 @@ jobs:
# 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`.
# Runs for visibility and does NOT gate the build, matching the `test` lane and
# FabledScribe's equivalent job.
#
# 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'
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -215,11 +211,11 @@ jobs:
# Postgres it will actually meet.
image: postgres:16-alpine
env:
POSTGRES_USER: inkwell
POSTGRES_USER: thoughtsync
POSTGRES_PASSWORD: ci_integration
POSTGRES_DB: inkwell_test
POSTGRES_DB: thoughtsync_test
options: >-
--health-cmd "pg_isready -U inkwell"
--health-cmd "pg_isready -U thoughtsync"
--health-interval 10s
--health-timeout 5s
--health-retries 10
@@ -243,7 +239,7 @@ jobs:
test -n "$PG"
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
test -n "$PG_IP"
export INKWELL_DATABASE_URL="postgresql+asyncpg://inkwell:ci_integration@${PG_IP}:5432/inkwell_test"
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'
@@ -265,16 +261,10 @@ jobs:
build:
name: Build & push image
# Every lane gates the build. This once stopped at lint + typecheck, on the
# reasoning that DB-backed testing happened manually against the dev image
# rather than on every push — true until 6f21db8 added the integration lane,
# and false since.
#
# What that gap cost: run 4293 failed `test` and published :dev and :<sha>
# anyway, so the deployed server ran a build whose test lane was red. An image
# tag is the rollback substrate (family rule 46); one that can be published
# from a failing run is not a substrate you can roll back TO.
needs: [gate, typecheck, lint, test, integration]
# 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: [gate, typecheck, lint]
if: needs.gate.outputs.build == 'true'
runs-on: python-ci
container:
@@ -284,35 +274,27 @@ 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: |
# 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)"
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
BUILD_VERSION="dev"
case "${{ github.ref }}" in
refs/heads/dev)
TAGS="${{ env.IMAGE }}:dev"
TAGS="$TAGS,${{ env.IMAGE }}:dev"
;;
refs/heads/main)
TAGS="${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.sha }}"
# 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)"
;;
*)
echo "::error::This lane builds images for dev and main only."
exit 1
refs/tags/*)
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
BUILD_VERSION="${{ github.ref_name }}"
;;
esac
echo "value=$TAGS" >> $GITHUB_OUTPUT
@@ -323,41 +305,42 @@ jobs:
docker system prune -af || true
docker builder prune --keep-storage 5g -f || true
# Bake EVERY client in, on every image build, so a self-hoster gets a working
# app for their machine from the server holding their notes — without an
# account on this forge, which is private (issue 2091) and is why serving them
# from a release page was never an option for anybody but the operator.
# 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.
#
# ~104 MB on top of the ~85 MB image, almost all of it the AppImage. That is
# the price of the product being complete (rule 23), and the AppImage is not
# optional within it: it is the ONLY bundle that can replace itself in place,
# so a server without one cannot serve in-app updates to anyone.
# 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 releases are private, and a
# 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 — see the script. A platform with nothing published
# means the server advertises nothing for it and the UI hides that download,
# which is a supported state and the only one available before that platform's
# first build has ever published.
- name: Fetch the clients to bake in
# 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 }}
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves dev clients;
# :latest serves stable ones. This read `download/dev` unconditionally
# until M314 step 3, on every branch — so every stable server shipped a
# dev-channel APK to anyone who downloaded the client from it. Not a
# versioning gap; a plain defect, and the reason the channel is chosen here
# rather than inside the script: the caller is what knows which image it is
# building.
case "${{ github.ref_name }}" in
main) channel=stable ;;
*) channel=dev ;;
esac
sh packaging/fetch-clients.sh "$channel" client
mkdir -p client
base="${{ github.server_url }}/${{ github.repository }}/releases/download/dev"
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
+96 -197
View File
@@ -16,20 +16,33 @@ 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 shared client core (store + sync engine) the desktop wraps. Its own
# crate since the Android client binds the same code, so a change there is a
# change to this app even though nothing under desktop/ moved.
- "core/**"
# The Android uniffi shim. It builds no desktop artifact, but it is a
# workspace member, so this lane's `cargo clippy --all-targets` is what
# compiles and lints it — and until the Android lane exists (M12 step 5),
# it is the ONLY thing that does.
- "android/**"
# The workspace manifest and lockfile, which now live at the repo root.
- "Cargo.toml"
- "Cargo.lock"
# The whole frontend, not just the adapter/bridge seam: it is compiled INTO
# the desktop binary, so any part of it changing means the shipped app is out
# of date. Config and lockfile included — a dependency bump changes the bundle
# as surely as a component does.
- "frontend/**"
- ".forgejo/workflows/desktop.yml"
workflow_dispatch:
concurrency:
group: desktop-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
permissions:
# write (not read) so the tag build can publish a Release with the bundles
@@ -38,47 +51,9 @@ 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)
needs: [decide]
if: needs.decide.outputs.build == 'true'
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
@@ -89,14 +64,6 @@ 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
@@ -156,20 +123,8 @@ jobs:
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"
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
# A different value for a different audience: this is the one a person
# quotes in a bug report, the key above is the one only a comparator
# sees. Exported rather than passed as a flag because the macro that
# reads it is in Rust source, not in Tauri's config.
INKWELL_DISPLAY_VERSION="$(sh ../../packaging/version.sh display desktop)"
export INKWELL_DISPLAY_VERSION
echo "Baking display version $INKWELL_DISPLAY_VERSION"
version="$(sh ../packaging/build-version.sh)"
echo "Building version $version"
cargo tauri build \
--config '{"build":{"beforeBuildCommand":""}}' \
--config "{\"version\":\"$version\"}" \
@@ -230,17 +185,18 @@ jobs:
run: bash desktop/packaging/arch/package-prebuilt.sh
# Make the built .deb + .AppImage downloadable from the run (for hand-testing).
# Stock action: it works on this forge since the runner moved to
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
# out of the action bundle (Scribe snippet #2271). Never @v3 — it uploads
# 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
uses: actions/upload-artifact@v7
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
with:
name: inkwell-linux
name: thoughtsync-linux
path: |
target/release/bundle/appimage/*.AppImage
target/release/bundle/deb/*.deb
@@ -249,43 +205,37 @@ jobs:
# failure, not as a green run with an empty artifact.
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.
# 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')
env:
GITHUB_TOKEN: ${{ github.token }}
run: bash desktop/packaging/publish-release.sh
# The rolling DEVELOPMENT channel (M10.9): a release whose tag never moves, so
# the updater has a permanent URL to read — Forgejo has no
# /releases/latest/download/<asset> route, so "newest" can't be named in a URL.
#
# 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'
- name: Publish to the dev channel
if: github.ref == 'refs/heads/dev'
env:
GITHUB_TOKEN: ${{ github.token }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
RELEASE_TAG: dev
RELEASE_PRERELEASE: "true"
run: |
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the dev 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) channel=stable; RELEASE_PRERELEASE=false ;;
*) channel=dev; RELEASE_PRERELEASE=true ;;
esac
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
# (packaging/channel-tag.sh). A tag named `dev` shadowed the branch.
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh
# Windows installer, CROSS-COMPILED from Linux — there is no Windows build host.
@@ -303,21 +253,12 @@ jobs:
# 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'
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
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.
@@ -351,20 +292,8 @@ jobs:
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"
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
# A different value for a different audience: this is the one a person
# quotes in a bug report, the key above is the one only a comparator
# sees. Exported rather than passed as a flag because the macro that
# reads it is in Rust source, not in Tauri's config.
INKWELL_DISPLAY_VERSION="$(sh ../../packaging/version.sh display desktop)"
export INKWELL_DISPLAY_VERSION
echo "Baking display version $INKWELL_DISPLAY_VERSION"
version="$(sh ../packaging/build-version.sh)"
echo "Building version $version"
updater='{}'
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
updater='{"bundle":{"createUpdaterArtifacts":true}}'
@@ -378,51 +307,45 @@ jobs:
--config "$updater"
working-directory: desktop/src-tauri
# Stock action — see the Linux job's Upload bundles step for why.
# 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: actions/upload-artifact@v7
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
with:
name: inkwell-windows
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.
# Publishes to the SAME release as the Linux job. Safe to run twice: the
# script reuses an existing release (409) and nullglob means each job uploads
# only the bundles present in its own workspace.
- name: Publish release
if: startsWith(github.ref, 'refs/tags/v')
env:
GITHUB_TOKEN: ${{ github.token }}
run: bash desktop/packaging/publish-release.sh
# The rolling DEVELOPMENT channel (M10.9): a release whose tag never moves, so
# the updater has a permanent URL to read — Forgejo has no
# /releases/latest/download/<asset> route, so "newest" can't be named in a URL.
#
# 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'
- name: Publish to the dev channel
if: github.ref == 'refs/heads/dev'
env:
GITHUB_TOKEN: ${{ github.token }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
RELEASE_TAG: dev
RELEASE_PRERELEASE: "true"
run: |
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the dev 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) channel=stable; RELEASE_PRERELEASE=false ;;
*) channel=dev; RELEASE_PRERELEASE=true ;;
esac
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
# (packaging/channel-tag.sh). A tag named `dev` shadowed the branch.
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh
# The updater manifest, written AFTER both bundle jobs — they run in separate
@@ -436,20 +359,12 @@ jobs:
manifest:
name: Update manifest
needs: [build, windows]
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
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:
@@ -461,39 +376,23 @@ jobs:
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)"
# The version a PERSON reads, published beside the manifest as
# `inkwell-desktop.json`. The image build reads it to describe the
# bundles it bakes in (packaging/fetch-clients.sh) without re-deriving
# anything from its own checkout — which would be a different commit
# whenever the desktop did not rebuild.
display="$(sh packaging/version.sh display desktop)"
# Both channels are rolling: the manifest lands on the same release that
# holds the bundles, and the previous build's bundles are dropped once it
# points at this one. Nothing can reach them, and they are ~100 MB a push.
#
# 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) RELEASE_TAG="$(sh packaging/channel-tag.sh stable)"
export RELEASE_NOTES="Stable build from ${GITHUB_SHA}" ;;
*) RELEASE_TAG="$(sh packaging/channel-tag.sh dev)"
# TEMPORARY one-shot bridge (Scribe #2184). Desktop apps installed
# before the rename have .../download/dev/latest.json compiled in,
# so the manifest is also written to the old `dev` release; its
# URLs name dev-rolling assets, so those apps update once into a
# build that reads the new tag. Delete this line together with the
# old `dev` release and tag.
export BRIDGE_TAG=dev
export RELEASE_NOTES="Development build from ${GITHUB_SHA}" ;;
esac
# Assigned bare above so a failing channel-tag.sh fails this step;
# `export X="$(...)"` would swallow its exit status.
export RELEASE_TAG
export PRUNE_OLD_ASSETS=true
APP_VERSION="$version" DISPLAY_VERSION="$display" \
bash desktop/packaging/write-manifest.sh
# The SAME helper 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.
version="$(sh desktop/packaging/build-version.sh)"
if [ "${GITHUB_REF_NAME}" = "dev" ]; then
export RELEASE_TAG=dev
export RELEASE_NOTES="Development build from ${GITHUB_SHA}"
# Rolling channel: drop the previous build's bundles once the manifest
# points at this one. Nothing can reach them, and they're ~100 MB a push.
export PRUNE_OLD_ASSETS=true
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
else
export RELEASE_TAG="${GITHUB_REF_NAME}"
export RELEASE_NOTES="ThoughtSync ${GITHUB_REF_NAME}"
# Twice: once onto the versioned release itself, and once onto the
# permanent `stable` pointer the app actually reads. Same manifest both
# times — its URLs point at the versioned assets either way.
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
APP_VERSION="$version" MANIFEST_TAG=stable bash desktop/packaging/write-manifest.sh
fi
-68
View File
@@ -1,68 +0,0 @@
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
+2 -2
View File
@@ -209,5 +209,5 @@ android/local.properties
# The Android client CI bakes into the server image. Fetched fresh on every image
# build, so it is never worth 55 MiB of git history. client/.keep IS tracked, so
# the Dockerfile's COPY always has a directory to copy.
client/inkwell.apk
client/inkwell-android.json
client/thoughtsync.apk
client/thoughtsync-android.json
Generated
+48 -115
View File
@@ -1286,16 +1286,6 @@ dependencies = [
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix",
"windows-link 0.2.1",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -1415,24 +1405,6 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "global-hotkey"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e"
dependencies = [
"crossbeam-channel",
"keyboard-types",
"objc2",
"objc2-app-kit",
"once_cell",
"serde",
"thiserror 2.0.20",
"windows-sys 0.59.0",
"x11rb",
"xkeysym",
]
[[package]]
name = "gobject-sys"
version = "0.18.0"
@@ -1854,55 +1826,6 @@ dependencies = [
"cfb",
]
[[package]]
name = "inkwell-core"
version = "0.1.0"
dependencies = [
"chrono",
"log",
"openssl-sys",
"reqwest 0.12.28",
"rusqlite",
"serde",
"serde_json",
"sha2",
"uuid",
]
[[package]]
name = "inkwell-desktop"
version = "0.2.0"
dependencies = [
"inkwell-core",
"log",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-global-shortcut",
"tauri-plugin-log",
"tauri-plugin-updater",
]
[[package]]
name = "inkwell-ffi"
version = "0.1.0"
dependencies = [
"inkwell-core",
"log",
"serde_json",
"thiserror 2.0.20",
"tokio",
"uniffi",
]
[[package]]
name = "inkwell-uniffi-bindgen"
version = "0.1.0"
dependencies = [
"uniffi",
]
[[package]]
name = "ipnet"
version = "2.12.1"
@@ -4024,21 +3947,6 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-global-shortcut"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b"
dependencies = [
"global-hotkey",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.20",
]
[[package]]
name = "tauri-plugin-log"
version = "2.9.0"
@@ -4264,6 +4172,54 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "thoughtsync-core"
version = "0.1.0"
dependencies = [
"chrono",
"log",
"openssl-sys",
"reqwest 0.12.28",
"rusqlite",
"serde",
"serde_json",
"sha2",
"uuid",
]
[[package]]
name = "thoughtsync-desktop"
version = "0.2.0"
dependencies = [
"log",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-log",
"tauri-plugin-updater",
"thoughtsync-core",
]
[[package]]
name = "thoughtsync-ffi"
version = "0.1.0"
dependencies = [
"log",
"serde_json",
"thiserror 2.0.20",
"thoughtsync-core",
"tokio",
"uniffi",
]
[[package]]
name = "thoughtsync-uniffi-bindgen"
version = "0.1.0"
dependencies = [
"uniffi",
]
[[package]]
name = "time"
version = "0.3.55"
@@ -5621,23 +5577,6 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xattr"
version = "1.6.1"
@@ -5648,12 +5587,6 @@ dependencies = [
"rustix",
]
[[package]]
name = "xkeysym"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "yoke"
version = "0.8.3"
+10 -16
View File
@@ -20,28 +20,22 @@ RUN --mount=type=cache,target=/root/.cache/pip \
# Bake the built SPA into the package's static dir (served by app.py). PYTHONPATH
# points at /app/src so the runtime imports this source tree (with static/ present),
# not the pip-installed copy.
COPY --from=build-frontend /build/dist/ src/inkwell/static/
COPY --from=build-frontend /build/dist/ src/thoughtsync/static/
COPY alembic.ini .
COPY alembic/ alembic/
# The clients this server hands out — the APK and all four desktop bundles. CI
# fetches the newest published build of each into ./client immediately before this
# runs (packaging/fetch-clients.sh), so both image tags ship a full set and a
# `docker compose pull` delivers new ones with no file copying by hand.
# 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.
#
# ~104 MB of this image is that set, almost all of it the AppImage.
#
# Fetched by the JOB rather than here on purpose: the releases are private, and a
# 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.
#
# LAST of the COPYs, deliberately: this directory changes on every build, so
# putting it above the `pip install` layer would invalidate that layer every time.
#
# The directory is tracked (client/.keep) so this COPY cannot fail on a tree where
# that step never ran. An image with no clients — or with some and not others — is
# a supported state: the server advertises what it has and the web UI hides the
# rest (client_dist.py).
COPY client/ src/inkwell/client/
# 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
@@ -52,4 +46,4 @@ EXPOSE 5000
# Wait for the database, run migrations, then serve. The DB wait keeps a briefly
# slow/unready database from crash-looping the container. Family convention
# (rule 82): schema is built by real migrations, never metadata.create_all.
CMD ["sh", "-c", "python -m inkwell.dbwait && alembic upgrade head && hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
CMD ["sh", "-c", "python -m thoughtsync.dbwait && alembic upgrade head && hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
+18 -20
View File
@@ -1,4 +1,4 @@
# Inkwell
# ThoughtSync
Self-hosted personal thought-capture web app in the **FabledSword** family — a
Google-Keep-style **masonry post-it board** for capturing disparate thoughts in
@@ -13,7 +13,7 @@ under a second, designed to grow into a lightweight second brain (labels, search
## Layout
```
src/inkwell/ Quart app (app factory, auth, models, ACL, config, db)
src/thoughtsync/ Quart app (app factory, auth, models, ACL, config, db)
alembic/ async migrations (schema built via `alembic upgrade head`)
tests/ DB-free unit tests (pytest)
frontend/ Vue 3 + Vite + TypeScript + Tailwind SPA
@@ -23,12 +23,12 @@ docker-compose.yml local app + Postgres stack
## Development
Backend (needs a Postgres reachable at `INKWELL_DATABASE_URL`):
Backend (needs a Postgres reachable at `THOUGHTSYNC_DATABASE_URL`):
```sh
pip install -e ".[dev]"
alembic upgrade head
hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000
hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000
```
Frontend (proxies `/api` to `:5000`):
@@ -64,51 +64,49 @@ services:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: inkwell
POSTGRES_USER: thoughtsync
POSTGRES_PASSWORD: CHANGE_ME # change this
POSTGRES_DB: inkwell
POSTGRES_DB: thoughtsync
volumes:
- inkwell-db:/var/lib/postgresql/data
- thoughtsync-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U inkwell"]
test: ["CMD-SHELL", "pg_isready -U thoughtsync"]
interval: 5s
timeout: 5s
retries: 10
app:
image: git.fabledsword.com/bvandeusen/inkwell:latest # :dev for the current dev build
image: git.fabledsword.com/bvandeusen/thoughtsync:latest # :dev for the current dev build
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
INKWELL_DATABASE_URL: postgresql+asyncpg://inkwell:CHANGE_ME@db:5432/inkwell
THOUGHTSYNC_DATABASE_URL: postgresql+asyncpg://thoughtsync:CHANGE_ME@db:5432/thoughtsync
volumes:
- inkwell-data:/var/inkwell # uploaded images; omit if you don't use attachments
- thoughtsync-data:/var/thoughtsync # uploaded images; omit if you don't use attachments
ports:
- "5000:5000"
volumes:
inkwell-db:
inkwell-data:
thoughtsync-db:
thoughtsync-data:
```
Then open `http://<host>:5000` and register — **the first account becomes the admin**.
- **Only `INKWELL_DATABASE_URL` is required.** `INKWELL_SECRET_KEY` is optional; if
- **Only `THOUGHTSYNC_DATABASE_URL` is required.** `THOUGHTSYNC_SECRET_KEY` is optional; if
unset, a signing key is generated and persisted in the database (sessions survive restarts).
- Uploaded images live under the `inkwell-data` volume at `/var/inkwell`.
- 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>` 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`.
- **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev` build) ·
`:<git-sha>` (immutable, for pinning / rollback).
- **Putting it on the public internet:** there are four things to do first — close
registration, terminate TLS and forward `X-Forwarded-Proto`, stop publishing the app
port, and back up the attachment volume as well as the database. See
[docs/public-hosting.md](docs/public-hosting.md), which also lists what the app
hardens on its own and what it deliberately doesn't.
- **Install as an app (PWA):** Inkwell is installable ("Add to Home Screen" / the
- **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
it via `localhost`) — plain `http://<host>:5000` won't show the install prompt.
+3 -3
View File
@@ -1,12 +1,12 @@
# Alembic single-database async configuration for Inkwell.
# Alembic single-database async configuration for ThoughtSync.
[alembic]
script_location = %(here)s/alembic
prepend_sys_path = . src
path_separator = os
# Local-dev default; overridden at runtime by INKWELL_DATABASE_URL (see env.py).
sqlalchemy.url = postgresql+asyncpg://inkwell:inkwell@localhost:5432/inkwell
# Local-dev default; overridden at runtime by THOUGHTSYNC_DATABASE_URL (see env.py).
sqlalchemy.url = postgresql+asyncpg://thoughtsync:thoughtsync@localhost:5432/thoughtsync
[loggers]
+3 -3
View File
@@ -8,8 +8,8 @@ from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from inkwell.models import Base
import inkwell.models.all # noqa: F401 — registers every model on Base.metadata
from thoughtsync.models import Base
import thoughtsync.models.all # noqa: F401 — registers every model on Base.metadata
config = context.config
@@ -18,7 +18,7 @@ if config.config_file_name is not None:
config.set_main_option(
"sqlalchemy.url",
os.environ.get("INKWELL_DATABASE_URL", config.get_main_option("sqlalchemy.url")),
os.environ.get("THOUGHTSYNC_DATABASE_URL", config.get_main_option("sqlalchemy.url")),
)
target_metadata = Base.metadata
@@ -1,128 +0,0 @@
"""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"])
@@ -1,168 +0,0 @@
"""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
@@ -1,93 +0,0 @@
"""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"),
)
@@ -1,51 +0,0 @@
"""a stored site_name of the old default follows the rename to Inkwell
Revision ID: 0030
Revises: 0029
Create Date: 2026-10-06
The product is renamed ThoughtSync → Inkwell (Scribe note 5071), and the `site_name`
registry default moves with it. On its own that only reaches servers whose settings
table has no `site_name` row — and most servers have one, because the Settings page
saves EVERY key on Save, not just the one that changed. An admin who opened Settings
to flip registration off has persisted `"ThoughtSync"` without ever choosing it.
So the row is rewritten, but only when it holds exactly the old default. A name the
admin actually typed is theirs and is left alone; the old default is the one value we
can be sure nobody chose.
The match is on the stored JSON text (`settings.value` is `json.dumps(value)`), so
`'"ThoughtSync"'` is the whole of what it looks for.
## Downgrade
Puts the old default back on a row holding exactly the new one. A server where the
admin typed "Inkwell" themselves between the two cannot be told apart from one this
migration rewrote; on downgrade both read "ThoughtSync", which is what the old code
would have shown for either.
"""
from alembic import op
import sqlalchemy as sa
revision = "0030"
down_revision = "0029"
branch_labels = None
depends_on = None
_OLD = '"ThoughtSync"'
_NEW = '"Inkwell"'
def _swap(old: str, new: str) -> None:
op.get_bind().execute(
sa.text("UPDATE settings SET value = :new WHERE key = 'site_name' AND value = :old"),
{"old": old, "new": new},
)
def upgrade() -> None:
_swap(_OLD, _NEW)
def downgrade() -> None:
_swap(_NEW, _OLD)
+11 -14
View File
@@ -16,7 +16,7 @@ val workspaceRoot: Directory = layout.projectDirectory.dir("../..")
val androidAbis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
/**
* Cross-compile `inkwell-ffi` for each Android ABI and drop the resulting
* 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
@@ -49,7 +49,7 @@ abstract class CargoNdkBuild : DefaultTask() {
args += "-t"
args += abi
}
args += listOf("-o", jniLibsDir.get().asFile.absolutePath, "build", "-p", "inkwell-ffi")
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"
@@ -94,7 +94,7 @@ abstract class UniffiBindgen : DefaultTask() {
"run",
"--locked",
"-p",
"inkwell-uniffi-bindgen",
"thoughtsync-uniffi-bindgen",
"--",
"generate",
"--library",
@@ -141,7 +141,7 @@ val rustInputs =
* build, and neither is worth holding signed APKs up for. Scribe #2810.
*/
val rustProfile =
(project.findProperty("INKWELL_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() }
(project.findProperty("THOUGHTSYNC_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() }
?: "debug"
val jniLibsOut = layout.buildDirectory.dir("rustJniLibs")
@@ -149,7 +149,7 @@ val bindingsOut = layout.buildDirectory.dir("generated/uniffi")
val cargoNdk =
tasks.register<CargoNdkBuild>("cargoNdk") {
description = "Cross-compile inkwell-ffi for the Android ABIs."
description = "Cross-compile thoughtsync-ffi for the Android ABIs."
rustSources.from(rustInputs)
abis.set(androidAbis)
cargoProfile.set(rustProfile)
@@ -163,17 +163,17 @@ val generateBindings =
dependsOn(cargoNdk)
// arm64 is arbitrary — every ABI carries the same uniffi metadata, and
// reading one is cheaper than reading four.
libraryFile.set(jniLibsOut.map { it.file("arm64-v8a/libinkwell_ffi.so") })
libraryFile.set(jniLibsOut.map { it.file("arm64-v8a/libthoughtsync_ffi.so") })
workspaceDir.set(workspaceRoot)
outputDir.set(bindingsOut)
}
android {
namespace = "com.fabledsword.inkwell"
namespace = "com.fabledsword.thoughtsync"
compileSdk = 36
defaultConfig {
applicationId = "com.fabledsword.inkwell"
applicationId = "com.fabledsword.thoughtsync"
// 26 (Android 8, 2017) matches Minstrel and clears the NDK's floor with
// room to spare.
minSdk = 26
@@ -181,9 +181,9 @@ android {
// Injected by CI from the git tag + commit count for a release; "dev"
// locally so the About screen reads honestly rather than claiming 1.0.
val nameOverride =
(project.findProperty("INKWELL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
(project.findProperty("THOUGHTSYNC_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
val codeOverride =
(project.findProperty("INKWELL_VERSION_CODE") as String?)?.toIntOrNull()
(project.findProperty("THOUGHTSYNC_VERSION_CODE") as String?)?.toIntOrNull()
versionCode = codeOverride ?: 1
versionName = nameOverride ?: "dev"
@@ -215,10 +215,7 @@ android {
// Hardcoded, and NOT a secret: the alias is fixed for the life of
// this app and is written into the certificate every install
// already carries. Hiding it would buy nothing and stop this file
// describing its own signing setup. It still says `thoughtsync`
// after the rename to Inkwell, because it names the key inside the
// existing keystore, and renaming it would only stop that key being
// found. Same key, so the signing certificate did not change either.
// 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
+1 -1
View File
@@ -4,4 +4,4 @@
# is the worst possible time to learn it.
-keep class com.sun.jna.** { *; }
-keepclassmembers class * extends com.sun.jna.** { public *; }
-keep class com.fabledsword.inkwell.core.** { *; }
-keep class com.fabledsword.thoughtsync.core.** { *; }
+3 -44
View File
@@ -7,9 +7,6 @@
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
@@ -91,61 +88,23 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:name=".InkwellApplication"
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.Inkwell"
android:theme="@style/Theme.ThoughtSync"
android:usesCleartextTraffic="true">
<!--
launchMode="singleTop" exists for the SHARE filters below.
The reminder notification adds FLAG_ACTIVITY_SINGLE_TOP to its own
intent, so onNewIntent already worked for that one. A share intent is
built by the OTHER app — Chrome, a reader, the text-selection toolbar —
and nothing here can add a flag to it. Without singleTop declared on the
activity itself, every share while the app is running would stack a
second MainActivity on top of the first: a second view model, a second
board, and a back press that lands on a stale copy of the same app.
-->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Inkwell">
android:theme="@style/Theme.ThoughtSync">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!--
Capture without opening the app first: Share → Inkwell from
anywhere, and the selection toolbar in any text field.
text/plain ONLY, and image/* deliberately absent. Nothing in this
app can create an attachment — the core has `delete_attachment` and
no counterpart, and the FFI exposes neither. Claiming images in the
share sheet would put this app in front of people for a job it
cannot do and fail after they had chosen it.
-->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<!--
The label is what appears in the text-selection menu beside Copy and
Share, where "Inkwell" would say who rather than what.
-->
<intent-filter android:label="@string/capture_process_text">
<action android:name="android.intent.action.PROCESS_TEXT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<!--
@@ -1,21 +0,0 @@
package com.fabledsword.inkwell
import android.content.Context
/**
* The `versionName` of the INSTALLED package, or null when it cannot be read.
*
* From the package manager rather than from `BuildConfig`: this reports what is
* actually on the phone, which is the question both callers are asking — a bug
* report reading the foot of Sync, and a server log reading the client header. It
* also needs no `buildFeatures.buildConfig`, which this module does not enable.
*
* Returns null rather than a fallback string, because the two callers want
* different ones: the UI wants a localized "unknown" from string resources, the
* client header wants the literal the core recognizes. Note 3127 §5 governs both —
* with no version tags, the artifact's self-report is the only answer to "which
* build is this?", so a missing name must read as missing and never as a plausible
* default that nothing can contradict.
*/
fun Context.installedVersionName(): String? =
runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull()
@@ -1,264 +0,0 @@
package com.fabledsword.inkwell.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.inkwell.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),
)
}
}
@@ -1,102 +0,0 @@
package com.fabledsword.inkwell.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())
}
@@ -1,189 +0,0 @@
package com.fabledsword.inkwell.ui
import androidx.compose.runtime.saveable.Saver
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.fabledsword.inkwell.core.checklistItems
import com.fabledsword.inkwell.core.checklistRender
/**
* One piece of a note body, as the editor DRAWS it.
*
* The note is still one markdown string underneath (M304) — this is a rendering and
* 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)))
}
@@ -1,398 +0,0 @@
package com.fabledsword.inkwell.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.inkwell.R
import com.fabledsword.inkwell.core.Note
/**
* The editor's action bar, along the top of the surface.
*
* It sits exactly where the capture sheet's drag handle used to. The handle cost
* this strip of screen and did nothing that a back gesture does not already do, so
* the strip carries the actions instead.
*
* Top rather than bottom, now that this one surface is used for WRITING as well as
* editing: the keyboard owns the bottom of the display for most of a note's life,
* so a bar down there spends its time riding on the IME. That is the right place
* for a send button and the wrong one for a colour picker, which is reached for
* between thoughts rather than at the end of them. The cost is honest — the top of
* a phone is further from a thumb than the bottom — and it buys a bar that does not
* move while you type.
*
* 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
@@ -1,121 +0,0 @@
package com.fabledsword.inkwell.ui
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.fabledsword.inkwell.core.LinkPreview
import com.fabledsword.inkwell.core.Note
private val PREVIEW_RADIUS = 8.dp
/**
* A URL that is the WHOLE body, whitespace either side allowed.
*
* Mirrors `LONE_URL_RE` in `NoteCard.vue` deliberately — the two surfaces have to
* agree on what counts as "this note is a link", or the same note reads as a card
* on one and a paragraph on the other. Someone pasting a link rarely trims it,
* which is why the surrounding whitespace is tolerated rather than rejected.
*/
private val LONE_URL = Regex("""^\s*(https?://[^\s<>"'\]\)]+)\s*$""")
/**
* The preview for a note that is nothing but a URL, or null.
*
* Null covers three different situations that all render the same way — the body
* is not a lone URL, the server has not unfurled it yet, or it never could. The
* card falls back to showing the URL as text in every one of them, so it is never
* blank and the link is never unreachable.
*
* A note written on the phone and not yet synced is permanently in the middle
* case: the unfurl happens server-side (`unfurl_queue.py`) and arrives on a later
* pull. That is the honest behaviour and it has to look deliberate, which showing
* the URL does.
*/
fun loneUrlPreview(note: Note): LinkPreview? {
if (!LONE_URL.matches(note.body)) return null
val url = note.body.trim()
return note.previews.firstOrNull { it.url == url }
}
/** True when the body is a lone URL, whether or not a preview has arrived for it. */
fun isLoneUrl(note: Note): Boolean = LONE_URL.matches(note.body)
/**
* A fetched link preview, in one of two sizes.
*
* [compact] is a single row — one line of title and the site — for a URL mentioned
* *inside* a note that has its own words. The note is the thing; the link is a
* footnote to it. Full size is for a note that IS a URL, where the link is the
* note and a compact strip would be a card with nothing on it.
*
* No image, unlike the web's `LinkPreview.vue`. `image_url` is a REMOTE
* third-party address, so drawing it would have this app fetch from whatever host
* a link happens to point at — on a phone, on possibly metered data, and as the
* first image loading anywhere in this client. That is a decision about privacy
* and data use rather than a rendering detail, so the text card ships and the
* image is left to be asked for (Scribe #3307).
*/
@Composable
fun LinkPreviewCard(
preview: LinkPreview,
compact: Boolean,
modifier: Modifier = Modifier,
) {
// Every element of the Modifier chain stays on ONE line, which is why the shape
// and the two paddings are named first. `standard:chain-method-continuation`
// wants a `.` that follows a MULTILINE element glued to its closing paren —
// `).padding(…)` — which is unreadable, so the multiline element is avoided
// instead (Scribe #3110).
val shape = RoundedCornerShape(PREVIEW_RADIUS)
val padH = if (compact) 8.dp else 10.dp
val padV = if (compact) 6.dp else 8.dp
Column(
modifier =
modifier
.fillMaxWidth()
.clip(shape)
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
.padding(horizontal = padH, vertical = padV),
) {
preview.siteName?.takeIf { it.isNotBlank() }?.let { site ->
Text(
text = site.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
// The URL stands in for a missing title so the row always says SOMETHING
// about where it goes.
text = preview.title?.takeIf { it.isNotBlank() } ?: preview.url,
style = if (compact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// First thing to go when there is no room — the compact row is a footnote and
// a description would make it the loudest part of the card.
if (!compact) {
preview.description?.takeIf { it.isNotBlank() }?.let { body ->
Text(
text = body,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@@ -1,530 +0,0 @@
package com.fabledsword.inkwell.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.inkwell.R
import com.fabledsword.inkwell.core.BodyItem
import com.fabledsword.inkwell.core.Note
import com.fabledsword.inkwell.core.NoteLabel
import com.fabledsword.inkwell.core.bodyTags
import com.fabledsword.inkwell.core.checklistItems
@Composable
fun NoteCard(
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))
}
// A note that is NOTHING but a URL renders as its preview and nothing
// else — printing the raw address under a card that already says where it
// goes is saying the same thing twice, badly. Until the unfurl lands, or
// if it never does, `preview` is null and the body falls through to
// NoteBody, which shows the URL. Never a blank card.
val lonePreview = remember(note.body, note.previews) { loneUrlPreview(note) }
// Body then checklist, in order — a note can carry both (M13 step 2). The
// first line of the body IS the note's name, at the same weight as the rest of
// it (M13 steps 3 and 4).
if (lonePreview != null) {
LinkPreviewCard(preview = lonePreview, compact = false)
} else if (note.body.isNotBlank()) {
NoteBody(note = note, onToggleItem = onToggleItem)
}
// Links mentioned INSIDE a note: a compact strip at the foot of the card,
// under the note's own words rather than stacked on top of them. Putting
// them above would set a stranger's headline where the note's first line
// should be — the web learned that in M13 and moved them down.
if (!isLoneUrl(note) && note.previews.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
note.previews.forEach { preview ->
LinkPreviewCard(preview = preview, compact = true)
Spacer(Modifier.height(4.dp))
}
}
// A note with nothing in it still has to occupy the board legibly — otherwise
// it reads as a rendering bug.
if (note.body.isBlank() && note.previews.isEmpty()) {
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
@@ -1,314 +0,0 @@
package com.fabledsword.inkwell.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.inkwell.core.Label
import com.fabledsword.inkwell.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
@@ -1,313 +0,0 @@
package com.fabledsword.inkwell.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")
@@ -1,584 +0,0 @@
package com.fabledsword.inkwell.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.core.Label
/** The swatch shown beside a tag, and tapped to change its colour. */
private val SWATCH = 22.dp
/** What the row's overflow menu is currently asking about. */
private sealed interface TagDialog {
data class Rename(
val tag: Label,
) : TagDialog
/** A rename whose new name another tag already holds — see [RenameDialog]. */
data class ConfirmMerge(
val tag: Label,
val into: Label,
val name: String,
) : TagDialog
data class Merge(
val tag: Label,
) : TagDialog
data class Delete(
val tag: Label,
) : TagDialog
data class Colour(
val tag: Label,
) : TagDialog
}
/**
* Tag management: list, create, rename, recolour, delete, merge.
*
* A destination you go to, not a modal. The web's `LabelsModal.vue` is a modal
* because a desktop has room to float one over the board; on a phone this is a
* place you visit to tidy up, and a full screen is what that is.
*
* It is also, since the per-note colour picker was removed, the ONLY colour
* control in the product. That is why the swatch is a first-class tap target on
* every row rather than something behind the overflow menu.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TagsScreen(
state: TagsState,
onClose: () -> Unit,
onCreate: (String) -> Unit,
onRename: (String, String) -> Unit,
onColour: (String, String) -> Unit,
onDelete: (String) -> Unit,
onMerge: (String, String) -> Unit,
onDismissError: () -> Unit,
) {
var dialog by remember { mutableStateOf<TagDialog?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.tags_title)) },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.tags_back),
)
}
},
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.imePadding(),
) {
// An indeterminate bar rather than blocking the list: a tag write is a
// local SQLite call and usually finishes before this is seen at all.
if (state.busy) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
// The same banner the board and the editor use. A third way of saying
// "that did not work" would be a third thing to keep consistent.
state.error?.let { message ->
ErrorBanner(message = message, onDismiss = onDismissError)
}
NewTagField(
enabled = !state.busy,
onCreate = onCreate,
)
if (!state.loading && state.tags.isEmpty()) {
EmptyTags()
}
// weight, NOT fillMaxSize: this has siblings above it, and filling the
// whole height would measure the list against space the field and the
// banner have already taken — pushing the end of the list off-screen.
LazyColumn(modifier = Modifier.weight(1f)) {
items(state.tags, key = { it.id }) { tag ->
TagRow(
tag = tag,
enabled = !state.busy,
onColour = { dialog = TagDialog.Colour(tag) },
onRename = { dialog = TagDialog.Rename(tag) },
onMerge = { dialog = TagDialog.Merge(tag) },
onDelete = { dialog = TagDialog.Delete(tag) },
)
}
}
}
}
when (val open = dialog) {
null -> Unit
is TagDialog.Rename ->
RenameDialog(
tag = open.tag,
others = state.tags,
onDismiss = { dialog = null },
onRename = { name ->
dialog = null
onRename(open.tag.id, name)
},
// Renaming onto a name another tag holds MERGES the two, and that
// cannot be undone by repeating it, so the confirmation replaces
// this dialog rather than the rename just happening.
onWouldMerge = { into, name -> dialog = TagDialog.ConfirmMerge(open.tag, into, name) },
)
is TagDialog.ConfirmMerge ->
ConfirmDialog(
title = stringResource(R.string.tags_rename_merges_title, hash(open.into.name)),
body = stringResource(R.string.tags_rename_merges_body, hash(open.into.name)),
confirm = stringResource(R.string.tags_rename_merges_confirm),
onDismiss = { dialog = null },
onConfirm = {
dialog = null
onRename(open.tag.id, open.name)
},
)
is TagDialog.Merge ->
MergeDialog(
tag = open.tag,
others = state.tags.filter { it.id != open.tag.id },
onDismiss = { dialog = null },
onMerge = { target ->
dialog = null
onMerge(open.tag.id, target.id)
},
)
is TagDialog.Delete ->
ConfirmDialog(
title = stringResource(R.string.tags_delete_title, hash(open.tag.name)),
// The count is the part that makes the consequence real — "it is on
// 40 notes" is a different decision from "delete this tag?". It comes
// from the LIST, the only call the core populates a count on.
body =
open.tag.count
?.takeIf { it > 0 }
?.let { stringResource(R.string.tags_delete_body_counted, it) }
?: stringResource(R.string.tags_delete_body),
footnote = stringResource(R.string.tags_delete_from_text),
confirm = stringResource(R.string.tags_delete_confirm),
onDismiss = { dialog = null },
onConfirm = {
dialog = null
onDelete(open.tag.id)
},
)
is TagDialog.Colour ->
ColourDialog(
tag = open.tag,
onDismiss = { dialog = null },
onPick = { key ->
dialog = null
onColour(open.tag.id, key)
},
)
}
}
/**
* `#` on the name, everywhere it is spoken about.
*
* The chips already wear it (`NoteCard.kt`, `EditorChrome.kt`) and it is the
* reason these are called tags at all — a dialog that said "Delete grocery?" would
* be talking about something else.
*/
private fun hash(name: String): String = "#$name"
@Composable
private fun NewTagField(
enabled: Boolean,
onCreate: (String) -> Unit,
) {
var text by remember { mutableStateOf("") }
val submit = {
if (text.isNotBlank()) {
onCreate(text)
text = ""
}
}
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
PlainTextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.weight(1f),
hint = R.string.tags_new_hint,
enabled = enabled,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { submit() }),
)
TextButton(onClick = submit, enabled = enabled && text.isNotBlank()) {
Text(stringResource(R.string.tags_create))
}
}
}
/**
* Said out loud rather than left as a blank screen — and it names the `#` route,
* because the operator did not know `#tag` extraction existed at all (Scribe
* #2949) and this is the natural place to say so.
*/
@Composable
private fun EmptyTags() {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = stringResource(R.string.tags_empty_title),
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringResource(R.string.tags_empty_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun TagRow(
tag: Label,
enabled: Boolean,
onColour: () -> Unit,
onRename: () -> Unit,
onMerge: () -> Unit,
onDelete: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = labelTintFor(tag.name, tag.color)
var menuOpen by remember { mutableStateOf(false) }
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier =
Modifier
.size(SWATCH)
.clip(CircleShape)
.background(tint.chipBackground(dark))
.border(1.dp, tint.chipBorder(dark), CircleShape)
.clickable(enabled = enabled, onClick = onColour),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = hash(tag.name),
style = MaterialTheme.typography.bodyLarge,
color = tint.tagInk(dark),
)
Text(
text = countLabel(tag.count),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Column {
IconButton(onClick = { menuOpen = true }, enabled = enabled) {
Icon(
Icons.Filled.MoreVert,
contentDescription = stringResource(R.string.tags_actions),
)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
// Panel.kt's MenuItem, not a bare DropdownMenuItem: every one of
// these raises a dialog, and it closes the menu BEFORE acting so the
// dialog cannot open underneath a menu still hanging over it.
val close = { menuOpen = false }
MenuItem(R.string.tags_rename, close, onRename)
MenuItem(R.string.tags_merge, close, onMerge)
MenuItem(R.string.tags_delete, close, onDelete)
}
}
}
}
/**
* Zero is its own sentence, not "0 notes".
*
* A count of null means the core did not populate one — only `list_labels` does —
* which is a different thing from a tag with no notes, so it reads as unknown
* rather than as empty.
*/
@Composable
private fun countLabel(count: Long?): String =
when {
count == null -> ""
count <= 0L -> stringResource(R.string.tags_count_none)
count == 1L -> stringResource(R.string.tags_count_one)
else -> stringResource(R.string.tags_count, count.toInt())
}
/**
* Rename, with the merge caught before it happens.
*
* The collision is detected HERE, against the list, rather than from what the
* core returns: the merge survivor is whichever tag is older, so it may well be
* the one being renamed, and an unchanged id afterwards would prove nothing.
* Matching is case-insensitive because the core's is.
*/
@Composable
private fun RenameDialog(
tag: Label,
others: List<Label>,
onDismiss: () -> Unit,
onRename: (String) -> Unit,
onWouldMerge: (Label, String) -> Unit,
) {
var text by remember(tag.id) { mutableStateOf(tag.name) }
val trimmed = text.trim()
val clash =
others.firstOrNull { it.id != tag.id && it.name.equals(trimmed, ignoreCase = true) }
val submit = {
when {
trimmed.isEmpty() -> Unit
clash != null -> onWouldMerge(clash, trimmed)
else -> onRename(trimmed)
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.tags_rename_title, hash(tag.name))) },
text = {
PlainTextField(
value = text,
onValueChange = { text = it },
hint = R.string.tags_new_hint,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { submit() }),
)
},
confirmButton = {
TextButton(onClick = submit, enabled = trimmed.isNotEmpty()) {
Text(stringResource(R.string.tags_rename_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
},
)
}
/**
* Merge, with the direction stated and the survivor named.
*
* Unlike a rename — where the OLDER tag survives so that the outcome cannot
* depend on which way round it was typed — this one is deliberate, so the
* direction the person chooses IS the intent and is honoured. The price of that
* is that the direction has to be unmissable, which is why the body names the tag
* that stops existing and every row here is the one that survives.
*/
@Composable
private fun MergeDialog(
tag: Label,
others: List<Label>,
onDismiss: () -> Unit,
onMerge: (Label) -> Unit,
) {
val dark = isSystemInDarkTheme()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.tags_merge_title, hash(tag.name))) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(R.string.tags_merge_body, hash(tag.name)),
style = MaterialTheme.typography.bodyMedium,
)
if (others.isEmpty()) {
Text(
text = stringResource(R.string.tags_merge_none),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
others.forEach { other ->
val tint = labelTintFor(other.name, other.color)
Text(
text = hash(other.name),
style = MaterialTheme.typography.bodyLarge,
color = tint.tagInk(dark),
modifier =
Modifier
.fillMaxWidth()
.clickable { onMerge(other) }
.padding(vertical = 8.dp),
)
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
},
)
}
/**
* The palette, and since the per-note picker was removed this is the only place in
* the product a colour is chosen.
*
* Every key from [NOTE_TINTS], `default` included: a tag whose colour is
* `default` gets a hue derived from its name (`DerivedTint.kt`), so "default" here
* means "let it pick" rather than "grey", and taking it away would leave no way
* back to that.
*/
@Composable
private fun ColourDialog(
tag: Label,
onDismiss: () -> Unit,
onPick: (String) -> Unit,
) {
val dark = isSystemInDarkTheme()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.tags_colour_of, hash(tag.name))) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
NOTE_TINTS.forEach { (key, tint) ->
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { onPick(key) }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier =
Modifier
.size(SWATCH)
.clip(CircleShape)
.background(tint.chipBackground(dark))
.border(1.dp, tint.chipBorder(dark), CircleShape),
)
Text(
text = tint.label,
style = MaterialTheme.typography.bodyMedium,
color =
if (key == tag.color) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
)
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
},
)
}
/** A destructive confirmation: what it is, what it costs, and one way out. */
@Composable
private fun ConfirmDialog(
title: String,
body: String,
confirm: String,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
footnote: String? = null,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = body, style = MaterialTheme.typography.bodyMedium)
footnote?.let {
Text(
text = it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
confirmButton = {
TextButton(onClick = onConfirm) { Text(confirm) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
},
)
}
@@ -1,181 +0,0 @@
package com.fabledsword.inkwell.ui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.inkwell.core.Label
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Everything the Tags screen renders from.
*
* [tags] comes from `list_labels`, which is the only call that populates a
* `count` — the single-tag returns leave it null by design. So the counts a
* confirmation dialog quotes are always the LIST's, never an operation's result.
*/
data class TagsState(
val loading: Boolean = true,
val tags: List<Label> = emptyList(),
/** An in-flight write, for disabling the controls that would race it. */
val busy: Boolean = false,
val error: String? = null,
)
/**
* Create, rename, recolour, delete and merge tags.
*
* A peer of the web's `LabelsModal.vue`, not a reduced companion — the same six
* operations over the same core the desktop uses.
*
* ## Why every write re-lists
*
* A tag operation changes more than the row it names. A merge deletes one tag and
* moves its notes; a delete changes nothing else's count but removes a drawer
* lens; a rename can MERGE (see below) and so can make a different row vanish.
* Re-listing after each write costs one cheap local SQLite read and removes a
* whole class of "the screen thinks there are still two" bugs. Patching the list
* in place would mean re-deriving, in Kotlin, rules the core already owns.
*
* ## Renaming can merge
*
* `rename_label` folds two tags together when the new name is one another tag
* already holds, and the OLDER row survives (Scribe #3324). So it can return a
* tag whose id is not the one passed in, and it can make another tag stop
* existing. The screen asks first; this view model does not, because a
* confirmation belongs to the surface with a person in front of it.
*
* ## Threading
*
* All of these are ordinary blocking FFI into SQLite — no async, no network — so
* they take [Dispatchers.IO], exactly like the board's calls. Sync happens later:
* the core marks the rows dirty and the next sync carries them.
*/
class TagsViewModel(
private val core: Inkwell,
/**
* Called after any write that landed.
*
* The board holds its own snapshot of the tag list for the drawer, and its
* current destination may BE one of these tags — deleting or merging that one
* leaves it looking at a lens that no longer exists. Wiring the two together
* explicitly is less magic than a shared event bus and makes the dependency
* visible at the construction site, the same way [SyncViewModel] does it.
*/
private val onStoreChanged: () -> Unit,
) : ViewModel() {
var state by mutableStateOf(TagsState())
private set
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
state =
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
.fold(
onSuccess = { state.copy(tags = it, loading = false, error = null) },
// Unlike the drawer, this screen cannot fail quietly: it is
// the only thing on the display, and an empty list here
// would read as "you have no tags" rather than "I couldn't
// look".
onFailure = { state.copy(loading = false, error = it.describeTagFailure()) },
)
}
}
fun create(name: String) {
val trimmed = name.trim()
if (trimmed.isEmpty()) return
// Find-or-create in the core: typing a name that exists in another case
// attaches the existing tag rather than minting a near-duplicate.
write { it.createLabel(trimmed) }
}
fun rename(
id: String,
name: String,
) {
val trimmed = name.trim()
if (trimmed.isEmpty()) return
write { it.renameLabel(id, trimmed) }
}
fun setColour(
id: String,
colour: String,
) = write { it.setLabelColor(id, colour) }
fun remove(id: String) = write { it.removeLabel(id) }
/**
* Fold [sourceId] into [targetId]. The source stops existing.
*
* Directional and not undone by repeating it — the caller has to have said
* which one survives before this runs, because afterwards there is nothing
* left to read the direction from.
*/
fun merge(
sourceId: String,
targetId: String,
) {
if (sourceId == targetId) return
write { it.mergeLabels(sourceId, targetId) }
}
fun dismissError() {
state = state.copy(error = null)
}
/**
* Run one store write, then re-list and tell the board.
*
* `busy` is cleared in the same assignment that stores the result, so no path
* out of here can leave the screen stuck with its controls disabled.
*/
private fun write(block: (Inkwell) -> Unit) {
if (state.busy) return
state = state.copy(busy = true, error = null)
viewModelScope.launch {
val failure =
runCatching { withContext(Dispatchers.IO) { block(core) } }
.exceptionOrNull()
val tags =
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
.getOrDefault(state.tags)
state =
state.copy(
tags = tags,
busy = false,
error = failure?.describeTagFailure(),
)
// Even a FAILED write can have changed the store — a merge that threw
// partway still moved rows — so the board is told either way.
onStoreChanged()
}
}
companion object {
fun factory(
core: Inkwell,
onStoreChanged: () -> Unit,
): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = TagsViewModel(core, onStoreChanged) as T
}
}
}
/**
* The core reports problems as one error type carrying a message meant to be
* shown, so the message is used when there is one.
*/
private fun Throwable.describeTagFailure(): String = message ?: "Something went wrong."
@@ -1,232 +0,0 @@
package com.fabledsword.inkwell.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.inkwell.AppUpdate
import com.fabledsword.inkwell.UpdateOutcome
import com.fabledsword.inkwell.core.ClientUpdate
import com.fabledsword.inkwell.core.Inkwell
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** 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: Inkwell,
/**
* 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: Inkwell,
context: Context,
): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
UpdateViewModel(core, context.applicationContext) as T
}
}
}
private const val FALLBACK = "The update couldn't be checked."
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
@@ -1,11 +1,9 @@
package com.fabledsword.inkwell
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
@@ -42,7 +40,7 @@ import java.io.File
* [UpdateReceiver], which is why a failure can be shown rather than guessed at.
*/
object AppUpdate {
private const val TAG = "InkwellUpdate"
private const val TAG = "ThoughtSyncUpdate"
/** This build's versionCode — what the server's is compared against. */
fun installedVersionCode(context: Context): Long =
@@ -64,30 +62,6 @@ object AppUpdate {
.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")
@@ -173,5 +147,5 @@ object AppUpdate {
.intentSender
}
private const val WRITE_NAME = "inkwell-update"
private const val WRITE_NAME = "thoughtsync-update"
}
@@ -1,4 +1,4 @@
package com.fabledsword.inkwell
package com.fabledsword.thoughtsync
import android.Manifest
import android.content.Intent
@@ -22,22 +22,20 @@ 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.inkwell.core.Inkwell
import com.fabledsword.inkwell.ui.BoardScreen
import com.fabledsword.inkwell.ui.BoardSync
import com.fabledsword.inkwell.ui.BoardUpdate
import com.fabledsword.inkwell.ui.BoardViewModel
import com.fabledsword.inkwell.ui.ForegroundTransitions
import com.fabledsword.inkwell.ui.InkwellTheme
import com.fabledsword.inkwell.ui.NoteEditorScreen
import com.fabledsword.inkwell.ui.StoreUnavailableScreen
import com.fabledsword.inkwell.ui.SyncScreen
import com.fabledsword.inkwell.ui.SyncState
import com.fabledsword.inkwell.ui.SyncViewModel
import com.fabledsword.inkwell.ui.TagsScreen
import com.fabledsword.inkwell.ui.TagsViewModel
import com.fabledsword.inkwell.ui.UpdateViewModel
import com.fabledsword.inkwell.ui.olderThan
import com.fabledsword.thoughtsync.core.ThoughtSync
import com.fabledsword.thoughtsync.ui.BoardScreen
import com.fabledsword.thoughtsync.ui.BoardSync
import com.fabledsword.thoughtsync.ui.BoardViewModel
import com.fabledsword.thoughtsync.ui.ComposeSheet
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
@@ -53,27 +51,15 @@ class MainActivity : ComponentActivity() {
*/
private val requestedNote = mutableStateOf<String?>(null)
/**
* Text shared into the app from elsewhere, waiting to become a note.
*
* Same shape and same reason as [requestedNote]: a share that arrives while
* the app is already running lands in [onNewIntent], long after the
* composition was built, so a piece of state it is already reading is the only
* way in. The activity is `singleTop` in the manifest precisely so that this
* path exists for an intent another app built.
*/
private val sharedText = mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val app = application as InkwellApplication
val app = application as ThoughtSyncApplication
requestedNote.value = takeRequestedNote(intent)
sharedText.value = takeSharedText(intent)
setContent {
InkwellTheme {
ThoughtSyncTheme {
val core = app.core
if (core == null) {
// The store never opened. There is no board to show and no
@@ -81,7 +67,7 @@ class MainActivity : ComponentActivity() {
// than render an empty board that looks like data loss.
StoreUnavailableScreen(reason = app.openFailure)
} else {
App(core, requestedNote, sharedText)
App(core, requestedNote)
}
}
}
@@ -91,7 +77,6 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent)
setIntent(intent)
requestedNote.value = takeRequestedNote(intent)
sharedText.value = takeSharedText(intent)
}
/**
@@ -107,58 +92,10 @@ class MainActivity : ComponentActivity() {
intent.removeExtra(Reminders.EXTRA_NOTE_ID)
return id
}
/**
* Read the text a share or a text selection brought in, and CONSUME it.
*
* Consumed for the same reason [takeRequestedNote] is: the activity keeps the
* intent it was launched with, so without removing the extras a rotation would
* replay the share and mint the same note again, with nothing on screen to
* explain where the duplicates were coming from.
*/
private fun takeSharedText(intent: Intent?): String? {
val shared =
when (intent?.action) {
Intent.ACTION_SEND -> intent.takeSendText()
Intent.ACTION_PROCESS_TEXT -> intent.takeProcessText()
else -> null
}
return shared?.takeIf { it.isNotBlank() }
}
}
/**
* The shared text, with a subject line above it when the sender gave one.
*
* Sharing a page from a browser sends EXTRA_SUBJECT as the page title and
* EXTRA_TEXT as the URL. Keeping both makes the note read as its title, because
* the core names a note by its first line — so this is not decoration, it is what
* turns a board of identical-looking links into a board you can scan.
*
* `distinct` because plenty of apps put the same string in both, and a note that
* says the URL twice is worse than one that says it once.
*/
private fun Intent.takeSendText(): String? {
val body = getStringExtra(Intent.EXTRA_TEXT)
val subject = getStringExtra(Intent.EXTRA_SUBJECT)
removeExtra(Intent.EXTRA_TEXT)
removeExtra(Intent.EXTRA_SUBJECT)
return listOfNotNull(subject, body)
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
.joinToString("\n")
}
/** The selection from another app's text field, via the selection toolbar. */
private fun Intent.takeProcessText(): String? {
val text = getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)?.toString()
removeExtra(Intent.EXTRA_PROCESS_TEXT)
return text
}
/** Which screen is up. Exactly one at a time. */
private enum class Screen { BOARD, EDITOR, SYNC, TAGS }
private enum class Screen { BOARD, EDITOR, SYNC }
/**
* The whole app, once the store is open.
@@ -167,16 +104,15 @@ private enum class Screen { BOARD, EDITOR, SYNC, TAGS }
* both cover the display completely, so keeping the board's two-column grid
* measuring and recomposing underneath one would be pure waste.
*
* Still no navigation library. Four destinations, each entered from exactly one
* 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: Inkwell,
core: ThoughtSync,
requestedNote: MutableState<String?>,
sharedText: MutableState<String?>,
) {
val context = LocalContext.current
val board: BoardViewModel =
@@ -199,51 +135,32 @@ private fun App(
}
}
// Cleared the same way and for the same reason: without it every later
// recomposition would capture the shared text again as a new note.
LaunchedEffect(sharedText.value) {
sharedText.value?.let {
board.captureShared(it)
sharedText.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.
// Sheet and screen visibility are view STATE, not view-model state: they are
// about what is on the display, and nothing in the store cares.
//
// 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.
// Saveable, though: `remember` alone meant rotating the phone closed whatever
// was open and took the half-written note in the capture sheet with it. The
// editor never had that problem because the note it is on lives in a view
// model; these two are the only screen state that did not.
var composing by rememberSaveable { mutableStateOf(false) }
var showingSync by rememberSaveable { mutableStateOf(false) }
var showingTags by rememberSaveable { mutableStateOf(false) }
// Tag writes reach the board two ways at once: the drawer lists tags, and the
// board may be LOOKING at one that a delete or a merge just removed. Both are
// `refreshLabels`, which also leaves a lens whose tag stopped existing.
val tags: TagsViewModel =
viewModel(factory = TagsViewModel.factory(core, onStoreChanged = board::refreshLabels))
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
// Above the editor: tags are reached only from the board's drawer, so
// there is never an open note underneath one to go back to.
showingTags -> Screen.TAGS
editing != null -> Screen.EDITOR
else -> Screen.BOARD
}
@@ -271,23 +188,10 @@ private fun App(
onInstallOutcome = update::consumeInstallOutcome,
)
Screen.TAGS ->
TagsScreen(
state = tags.state,
onClose = { showingTags = false },
onCreate = tags::create,
onRename = tags::rename,
onColour = tags::setColour,
onDelete = tags::remove,
onMerge = tags::merge,
onDismissError = tags::dismissError,
)
Screen.EDITOR ->
NoteEditorScreen(
// Non-null by construction: `screen` is EDITOR only when it is.
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,
@@ -313,37 +217,27 @@ private fun App(
onDismissError = sync::dismissSyncError,
),
onOpenSync = { showingSync = true },
onManageTags = { showingTags = 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,
)
},
onCompose = { composing = true },
onDismissError = board::dismissError,
)
if (composing) {
ComposeSheet(
saving = board.state.saving,
onDismiss = { composing = false },
onSave = { content ->
board.create(content)
composing = false
},
)
}
}
}
// The sync screen has no back handler of its own, so one lives here. The
// editor keeps its own, because it has to save the open note before leaving.
BackHandler(enabled = showingSync) { showingSync = false }
BackHandler(enabled = showingTags) { showingTags = false }
}
/**
@@ -360,7 +254,7 @@ private fun App(
* notifications this app would send — is spending it on nothing.
*/
@Composable
private fun ReminderAlarms(core: Inkwell) {
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
@@ -388,36 +282,6 @@ private fun ReminderAlarms(core: Inkwell) {
}
}
/**
* Looking for an app update without being asked.
*
* Until this existed, `check()` had exactly one caller: a button on the sync screen.
* So a new build was found only by someone who went looking for one, and the operator
* had to remember to go looking — which is the same as not being told.
*
* 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.
*
@@ -1,4 +1,4 @@
package com.fabledsword.inkwell
package com.fabledsword.thoughtsync
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -8,7 +8,7 @@ import android.content.Intent
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.fabledsword.inkwell.core.Note
import com.fabledsword.thoughtsync.core.Note
/**
* What a due reminder looks like in the shade.
@@ -117,5 +117,5 @@ internal object ReminderNotification {
)
private const val CHANNEL = "reminders"
private const val TAG = "InkwellReminders"
private const val TAG = "ThoughtSyncReminders"
}
@@ -1,4 +1,4 @@
package com.fabledsword.inkwell
package com.fabledsword.thoughtsync
import android.content.BroadcastReceiver
import android.content.Context
@@ -36,7 +36,7 @@ class ReminderReceiver : BroadcastReceiver() {
context: Context,
intent: Intent,
) {
val core = (context.applicationContext as? InkwellApplication)?.core ?: return
val core = (context.applicationContext as? ThoughtSyncApplication)?.core ?: return
val action = intent.action
val noteId = intent.getStringExtra(Reminders.EXTRA_NOTE_ID)
val app = context.applicationContext
@@ -64,9 +64,9 @@ class ReminderReceiver : BroadcastReceiver() {
}
companion object {
const val ACTION_DUE = "com.fabledsword.inkwell.REMINDER_DUE"
const val ACTION_DONE = "com.fabledsword.inkwell.REMINDER_DONE"
const val ACTION_SNOOZE = "com.fabledsword.inkwell.REMINDER_SNOOZE"
private const val TAG = "InkwellReminders"
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"
}
}
@@ -1,4 +1,4 @@
package com.fabledsword.inkwell
package com.fabledsword.thoughtsync
import android.app.AlarmManager
import android.app.PendingIntent
@@ -7,8 +7,8 @@ import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.inkwell.core.Note
import com.fabledsword.thoughtsync.core.Note
import com.fabledsword.thoughtsync.core.ThoughtSync
import java.time.OffsetDateTime
/**
@@ -52,7 +52,7 @@ object Reminders {
private const val MISSED_WINDOW_MS = 24L * 60 * 60 * 1000
private const val SNOOZE_MINUTES = 60L
private const val TAG = "InkwellReminders"
private const val TAG = "ThoughtSyncReminders"
/**
* Announce what is due, then schedule the next one.
@@ -63,7 +63,7 @@ object Reminders {
*/
fun refresh(
context: Context,
core: Inkwell,
core: ThoughtSync,
) {
ReminderNotification.ensureChannel(context)
val notes =
@@ -111,7 +111,7 @@ object Reminders {
*/
fun promptToNotifyDue(
context: Context,
core: Inkwell,
core: ThoughtSync,
): Boolean =
!Announced(context).askedToNotify &&
!NotificationManagerCompat.from(context).areNotificationsEnabled() &&
@@ -123,7 +123,7 @@ object Reminders {
/** Clear the reminder, as the notification's Done action. */
fun complete(
context: Context,
core: Inkwell,
core: ThoughtSync,
noteId: String,
) {
runCatching { core.completeReminder(noteId) }
@@ -134,7 +134,7 @@ object Reminders {
/** Push the reminder an hour out, as the notification's Snooze action. */
fun snooze(
context: Context,
core: Inkwell,
core: ThoughtSync,
noteId: String,
) {
runCatching { core.snoozeReminder(noteId, SNOOZE_MINUTES) }
@@ -237,7 +237,7 @@ private class Announced(
fun markAsked() = prefs.edit().putBoolean(KEY_ASKED, true).apply()
private companion object {
const val FILE = "inkwell-reminders"
const val FILE = "thoughtsync-reminders"
const val KEY_SEEN = "announced"
const val KEY_PRIMED = "primed"
const val KEY_ASKED = "asked_to_notify"
@@ -1,4 +1,4 @@
package com.fabledsword.inkwell
package com.fabledsword.thoughtsync
import android.content.Context
import androidx.work.BackoffPolicy
@@ -85,8 +85,8 @@ object SyncSchedule {
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
private const val PERIODIC = "inkwell-periodic-sync"
private const val PUSH = "inkwell-push-pending"
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
@@ -1,4 +1,4 @@
package com.fabledsword.inkwell
package com.fabledsword.thoughtsync
import android.content.Context
@@ -38,7 +38,7 @@ class SyncSettings(
}
private companion object {
const val FILE = "inkwell-sync"
const val FILE = "thoughtsync-sync"
const val KEY_AUTOMATIC = "automatic"
}
}
@@ -1,4 +1,4 @@
package com.fabledsword.inkwell
package com.fabledsword.thoughtsync
import android.content.Context
import android.util.Log
@@ -8,7 +8,7 @@ import androidx.work.WorkerParameters
/**
* One sync cycle, run by the system rather than by a person.
*
* WorkManager may start the process to run this, which means [InkwellApplication.onCreate]
* 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.
*
@@ -30,7 +30,7 @@ class SyncWorker(
params: WorkerParameters,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val core = (applicationContext as? InkwellApplication)?.core
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:
@@ -67,6 +67,6 @@ class SyncWorker(
}
private companion object {
const val TAG = "InkwellWorker"
const val TAG = "ThoughtSyncWorker"
}
}
@@ -1,9 +1,8 @@
package com.fabledsword.inkwell
package com.fabledsword.thoughtsync
import android.app.Application
import android.util.Log
import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.inkwell.core.setClientAgent
import com.fabledsword.thoughtsync.core.ThoughtSync
/**
* Opens the shared Rust core once, for the process lifetime.
@@ -16,14 +15,14 @@ import com.fabledsword.inkwell.core.setClientAgent
* 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 InkwellApplication : Application() {
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: Inkwell? = null
var core: ThoughtSync? = null
private set
var openFailure: String? = null
@@ -31,16 +30,8 @@ class InkwellApplication : Application() {
override fun onCreate() {
super.onCreate()
// Introduce this app to any server it links to, before anything can sync.
// The core cannot name us — the same crate is compiled into the desktop app,
// and it used to announce every phone as `inkwell-desktop` carrying the
// core crate's own version. "unknown" rather than a guess when the package
// manager will not say (note 3127 §5).
setClientAgent("inkwell-android", installedVersionName() ?: "unknown")
try {
val handle = Inkwell(filesDir.absolutePath)
val handle = ThoughtSync(filesDir.absolutePath)
core = handle
Log.i(TAG, "local store ready — ${handle.summary()}")
} catch (e: Exception) {
@@ -53,6 +44,6 @@ class InkwellApplication : Application() {
}
private companion object {
const val TAG = "Inkwell"
const val TAG = "ThoughtSync"
}
}
@@ -1,4 +1,4 @@
package com.fabledsword.inkwell
package com.fabledsword.thoughtsync
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -1,4 +1,4 @@
package com.fabledsword.inkwell
package com.fabledsword.thoughtsync
import android.content.BroadcastReceiver
import android.content.Context
@@ -71,7 +71,7 @@ class UpdateReceiver : BroadcastReceiver() {
}
companion object {
const val ACTION_INSTALLED = "com.fabledsword.inkwell.UPDATE_INSTALLED"
private const val TAG = "InkwellUpdate"
const val ACTION_INSTALLED = "com.fabledsword.thoughtsync.UPDATE_INSTALLED"
private const val TAG = "ThoughtSyncUpdate"
}
}
@@ -1,4 +1,4 @@
package com.fabledsword.inkwell.ui
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -6,14 +6,11 @@ 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
@@ -26,7 +23,6 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CircularProgressIndicator
@@ -41,11 +37,6 @@ 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
@@ -53,19 +44,15 @@ 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.inkwell.R
import com.fabledsword.inkwell.core.Label
import com.fabledsword.inkwell.core.Note
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Label
import com.fabledsword.thoughtsync.core.Note
import kotlinx.coroutines.launch
@Composable
@@ -75,55 +62,12 @@ fun BoardScreen(
onOpenNote: (Note) -> Unit,
sync: BoardSync,
onOpenSync: () -> Unit,
onManageTags: () -> 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,
@@ -140,30 +84,10 @@ fun BoardScreen(
onOpenSync()
scope.launch { drawerState.close() }
},
onManageTags = {
onManageTags()
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.
@@ -193,17 +117,6 @@ fun BoardScreen(
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()
@@ -227,14 +140,7 @@ fun BoardScreen(
when {
state.loading -> LoadingBoard()
state.notes.isEmpty() -> EmptyBoard(state)
else ->
NoteBoard(
notes = state.notes,
onOpenNote = onOpenNote,
onToggleItem = onToggleItem,
onNoteAction = onCardAction,
onConfirmDelete = { confirmingDelete = it },
)
else -> NoteBoard(notes = state.notes, onOpenNote = onOpenNote)
}
// `PullToRefreshBox` would be less code, but it takes no
// `enabled`, so the modifier and the indicator are wired by
@@ -247,16 +153,6 @@ fun BoardScreen(
}
}
}
confirmingDelete?.let { note ->
ConfirmDeleteDialog(
onConfirm = {
confirmingDelete = null
onNoteAction(note, EditorAction.DeleteForever)
},
onDismiss = { confirmingDelete = null },
)
}
}
}
@@ -285,25 +181,6 @@ data class BoardSync(
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.
@@ -373,7 +250,6 @@ private fun NavigationDrawer(
syncSummary: String?,
onOpen: (Destination) -> Unit,
onOpenSync: () -> Unit,
onManageTags: () -> Unit,
) {
ModalDrawerSheet {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
@@ -387,36 +263,18 @@ private fun NavigationDrawer(
DrawerRow(destination, current, onOpen)
}
// The header renders even with no tags, unlike the rows below it: the
// manage screen is where you go to MAKE the first one, and hiding the
// way in until one exists would be a door that appears only once you
// are already inside. It is an action ON the section rather than a row
// in it, so it cannot be mistaken for one more lens.
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 28.dp, end = 16.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
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.weight(1f),
modifier = Modifier.padding(start = 28.dp, bottom = 4.dp),
)
IconButton(onClick = onManageTags) {
Icon(
Icons.Filled.Edit,
contentDescription = stringResource(R.string.tags_manage),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
labels.forEach { label ->
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
}
}
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 ->
@@ -465,9 +323,6 @@ private fun DrawerRow(
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),
@@ -481,13 +336,7 @@ private fun NoteBoard(
// 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) },
)
NoteCard(note = note, onOpen = { onOpenNote(note) })
}
}
}
@@ -1,4 +1,4 @@
package com.fabledsword.inkwell.ui
package com.fabledsword.thoughtsync.ui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -6,12 +6,12 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.inkwell.core.Label
import com.fabledsword.inkwell.core.Note
import com.fabledsword.inkwell.core.NoteDraft
import com.fabledsword.inkwell.core.NoteEdit
import com.fabledsword.inkwell.core.NoteQuery
import 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
@@ -68,16 +68,6 @@ data class BoardState(
* 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()
@@ -99,7 +89,7 @@ data class BoardState(
*/
@Suppress("TooManyFunctions")
class BoardViewModel(
private val core: Inkwell,
private val core: ThoughtSync,
/**
* Called after any write that could have moved a reminder.
*
@@ -123,7 +113,7 @@ class BoardViewModel(
init {
refresh()
refreshLabels()
loadLabels()
}
fun open(destination: Destination) {
@@ -162,34 +152,13 @@ class BoardViewModel(
is Destination.WithLabel -> core.listNotes(query(VIEW_NOTES, labelId = destination.id))
}
/**
* Reload the drawer's tags, and leave a lens whose tag no longer exists.
*
* Public because the Tags screen owns operations this board cannot see: a
* delete or a merge removes a tag, and the board may be LOOKING at that tag —
* `Destination.WithLabel` holds an id, and a query for a deleted one returns
* nothing forever. Without the fallback, tidying up tags could strand the board
* on a permanently empty lens whose only escape is the drawer.
*
* A rename needs no fallback: the id survives, and re-listing gives the drawer
* the new name. A rename that MERGED is a delete of one of the two, which this
* catches by id like any other.
*/
fun refreshLabels() {
private fun loadLabels() {
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
.onSuccess { labels ->
state = state.copy(labels = labels)
val lens = state.destination
if (lens is Destination.WithLabel && labels.none { it.id == lens.id }) {
open(Destination.Notes)
}
}
.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. The lens is left
// alone in this case on purpose: "I could not read the tags" is not
// evidence that this one is gone.
// beats an error banner over working content.
.onFailure { state = state.copy(labels = emptyList()) }
}
}
@@ -219,6 +188,44 @@ class BoardViewModel(
}
}
/**
* Save a new note or list.
*
* Blank input is ignored rather than rejected: an empty save is a slip, not a
* mistake worth interrupting someone over.
*/
fun create(content: String) {
val cleanContent = content.trim()
if (cleanContent.isEmpty()) 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
}
// A capture sheet can carry a reminder in its text one day;
// more to the point, this is a store write and the rule here is
// that every store write re-derives the alarm rather than each
// call site deciding whether its particular write could matter.
withContext(Dispatchers.IO) { onRemindersChanged() }
state.copy(notes = notes, saving = false, error = null)
} catch (e: Exception) {
state.copy(saving = false, error = e.message ?: FALLBACK_ERROR)
}
}
}
// ─────────────────────────────── the editor ──────────────────────────────
/**
@@ -231,137 +238,12 @@ class BoardViewModel(
fun openNoteById(id: String) {
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { core.getNote(id) } }
.onSuccess { state = state.copy(editing = it, editingSession = state.editingSession + 1) }
.onSuccess { state = state.copy(editing = it) }
}
}
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)
}
/**
* Capture text shared into the app from somewhere else, and open it.
*
* The note is CREATED here rather than opened as a pre-filled draft, and that
* is the whole design of this path. The editor only flushes when its text
* differs from the note it was handed (`NoteEditorScreen`'s `flush`), so a
* draft arriving already full of the shared text is a draft with nothing to
* save — share a link, press back without typing, and it would be gone. A
* share has already said "keep this"; making the row first is what honours it.
*
* Opening the editor afterwards is then free of that risk: the note exists,
* back leaves it alone, and adding a line of context is optional rather than
* load-bearing.
*/
fun captureShared(text: String) {
val content = text.trim()
if (content.isEmpty()) return
// A share is a new sitting even if the editor was already open on
// something, so the field must be re-keyed onto what arrives. `createFrom
// Draft` deliberately does not bump this — it is written for the autosave
// case, where re-keying mid-typing would be the bug.
draftDismissed = false
state = state.copy(editingSession = state.editingSession + 1)
createFromDraft(content)
}
/**
* 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)
}
state = state.copy(editing = note)
}
/**
@@ -386,21 +268,18 @@ class BoardViewModel(
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.
// Saved on close rather than per keystroke, so a session of typing
// costs one write and one revision snapshot.
is EditorAction.SaveText ->
mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) }
is EditorAction.SetColor -> edit(id, NoteEdit.Color(action.color))
// 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.
@@ -423,6 +302,20 @@ class BoardViewModel(
null
}
// An empty first item: the checklist editor appears the moment the note
// has one, and an empty row is what someone can type straight into.
EditorAction.AddChecklist -> mutate { it.addItem(id, "") }
is EditorAction.AddItem ->
action.text.trim().takeIf { it.isNotEmpty() }?.let { text ->
mutate { it.addItem(id, text) }
}
is EditorAction.SetItemChecked ->
mutate { it.setItemChecked(id, action.itemId, action.checked) }
is EditorAction.SetItemText ->
mutate { it.setItemText(id, action.itemId, action.text) }
is EditorAction.DeleteItem -> mutate { it.deleteItem(id, action.itemId) }
is EditorAction.SetLabels -> mutate { it.setNoteLabels(id, action.labelIds) }
is EditorAction.CreateLabel ->
@@ -434,7 +327,7 @@ class BoardViewModel(
}
// The drawer lists labels with their note counts, and both
// just changed.
refreshLabels()
loadLabels()
}
is EditorAction.SetReminder -> edit(id, NoteEdit.RemindAt(action.at))
@@ -458,10 +351,9 @@ class BoardViewModel(
/**
* 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:
* Each core mutation returns the reloaded note, which goes straight into
* [BoardState.editing] so an open editor shows its own change without a
* re-query. 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.
@@ -472,17 +364,13 @@ class BoardViewModel(
* correct-until-a-moment-ago content, and flashing it empty would be a worse
* lie than showing it one frame stale.
*
* While a search is running the QUERY is re-run rather than the board's
* destination — running `load` here would replace the hits with the whole
* board, which is why this branch exists at all. It used to keep the existing
* list instead, and that was right for a note whose place in the pile changed
* and wrong for one that left it: trashing a hit left the card sitting there,
* with a snackbar saying it was gone, until the query happened to re-run
* (#3111). Re-asking is still the answer to the query, just a current one.
* 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: (Inkwell) -> Note?,
block: (ThoughtSync) -> Note?,
) {
viewModelScope.launch {
state = state.copy(saving = true)
@@ -490,8 +378,10 @@ class BoardViewModel(
try {
val updated = withContext(Dispatchers.IO) { block(core) }
val notes =
withContext(Dispatchers.IO) {
if (state.searching) core.searchNotes(state.query) else load(state.destination)
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
@@ -499,12 +389,7 @@ class BoardViewModel(
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 },
editing = if (closeEditor) null else updated ?: state.editing,
saving = false,
error = null,
)
@@ -517,21 +402,6 @@ class BoardViewModel(
}
}
/**
* 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)
}
@@ -547,7 +417,7 @@ class BoardViewModel(
private const val VIEW_TRASH = "trash"
fun factory(
core: Inkwell,
core: ThoughtSync,
onRemindersChanged: () -> Unit,
): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@@ -558,6 +428,9 @@ class BoardViewModel(
}
}
/** The palette key a note starts on, matching the web and the desktop. */
private const val DEFAULT_COLOR = "default"
// ── pure builders ───────────────────────────────────────────────────────────
//
// Neither of these reads or writes view-model state; they only shape a core input
@@ -573,33 +446,4 @@ 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,
)
NoteDraft(body = content, color = DEFAULT_COLOR, items = null)
@@ -0,0 +1,130 @@
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.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
/**
* The new-note surface, opened by the + button.
*
* A bottom sheet rather than a full screen: capture should feel like a quick aside
* from the board, not a place you navigate to and have to come back from. The
* board stays visible behind it, so the note lands somewhere you can already see.
*
* It asks note-or-list up front rather than making that a mode you discover later,
* because on a phone the two are genuinely different typing tasks and switching
* halfway is worse than choosing at the start.
*
* ## Leaving keeps what you wrote
*
* Every way out of this sheet except Discard SAVES: the save button, tapping the
* board behind it, swiping down, back, and the app being backgrounded. A sheet
* that throws away a typed thought because you touched outside it is a sheet that
* teaches people not to trust the app with a thought — and capture is the one
* place this product cannot afford that.
*
* The same shape the editor settled on, for the same reason, with one difference:
* capture also has to be abandonable, because tapping + and changing your mind is
* a normal thing to do. That is what Discard is, and it is the only path that
* loses anything. An empty draft needs neither — it is simply dropped, since a
* blank note nobody asked for is worse than no note at all.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ComposeSheet(
saving: Boolean,
onDismiss: () -> Unit,
onSave: (String) -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
// Saveable, not just remembered: a rotation mid-sentence is the same lost
// thought as a discarded one, and it was losing it before this.
var content by rememberSaveable { mutableStateOf("") }
val contentFocus = remember { FocusRequester() }
val written = content.isNotBlank()
val leave = { if (written) onSave(content) else onDismiss() }
// Straight into the one field there is. A capture is a thought, and every field
// someone has to tab past is the difference between "under a second" and not —
// which is why the title field is gone rather than merely skipped (M13 step 3).
LaunchedEffect(Unit) { contentFocus.requestFocus() }
// Backgrounding PERSISTS but does not close an empty sheet. Someone who tapped
// + and then got distracted should find the composer where they left it; the
// only reason to act here is that there is something to lose.
FlushOnStop { if (written) onSave(content) }
ModalBottomSheet(onDismissRequest = leave, sheetState = sheetState) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.imePadding()
.navigationBarsPadding(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// No note/list switch any more: there is one thing to capture. A
// checklist is added to a note in the editor, once there is a note.
PlainTextField(
value = content,
onValueChange = { content = it },
modifier = Modifier.focusRequester(contentFocus),
hint = R.string.compose_body_hint,
minLines = MIN_CONTENT_LINES,
)
SheetActions(
canSave = !saving && written,
onDiscard = onDismiss,
onSave = { onSave(content) },
)
}
}
}
@Composable
private fun SheetActions(
canSave: Boolean,
onDiscard: () -> Unit,
onSave: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
horizontalArrangement = Arrangement.End,
) {
// "Discard", not "Cancel". Cancel means "undo what I am doing", which is
// precisely what leaving no longer does — the word would now describe the
// one button it is NOT attached to.
TextButton(onClick = onDiscard) { Text(stringResource(R.string.compose_discard)) }
Button(onClick = onSave, enabled = canSave) {
Text(stringResource(R.string.compose_save))
}
}
}
private const val MIN_CONTENT_LINES = 4
@@ -1,4 +1,4 @@
package com.fabledsword.inkwell.ui
package com.fabledsword.thoughtsync.ui
/**
* Everything the editor can ask for, as one type.
@@ -25,6 +25,19 @@ sealed interface EditorAction {
val body: String,
) : EditorAction
data class SetColor(
val color: String,
) : EditorAction
/**
* Give this note a checklist.
*
* Not a conversion — a note HAS a checklist rather than BEING one (M13 step 2),
* so nothing moves and nothing is swapped: the body stays exactly where it is and
* the note gains a first, empty item for someone to type into.
*/
data object AddChecklist : EditorAction
data class SetPinned(
val pinned: Boolean,
) : EditorAction
@@ -39,12 +52,23 @@ sealed interface EditorAction {
data object DeleteForever : EditorAction
// No checklist actions at all any more (M304). An item is a `- [ ] ` line of the
// body, so adding, renaming, ticking or deleting one is editing text — which the
// editor already does, through SaveText, with the same autosave and the same
// revision window as any other edit. Routing them through the store would have
// meant the store handing back a note whose body disagreed with the field the
// person was typing in.
data class AddItem(
val text: String,
) : EditorAction
data class SetItemChecked(
val itemId: String,
val checked: Boolean,
) : EditorAction
data class SetItemText(
val itemId: String,
val text: String,
) : EditorAction
data class DeleteItem(
val itemId: String,
) : EditorAction
/**
* The note's MANUAL labels, replacing whatever was there.
@@ -0,0 +1,144 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
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.MaterialTheme
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.focus.onFocusChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.ChecklistItem
import com.fabledsword.thoughtsync.core.Note
/**
* The checklist, with real checkboxes this time.
*
* The card renders glyphs because it is a preview; here every row is live. This is
* the other half of the answer to how a list gets typed on a phone: the capture
* sheet takes a whole list at once, one item per line, because at capture time the
* list is already in your head and a tap per row would be the slow part. The
* editor is where a list is REVISED, and revising is item-at-a-time — so this is
* where the per-row control lives.
*
* No empty state: a checklist with no items already shows the add row with its
* hint, which says the same thing an empty state would and can be typed into.
*/
@Composable
fun ChecklistEditor(
note: Note,
readOnly: Boolean,
onAction: (EditorAction) -> Unit,
) {
Column {
note.items.forEach { item ->
ChecklistRow(item = item, readOnly = readOnly, onAction = onAction)
}
if (!readOnly) {
AddItemRow(onAdd = { onAction(EditorAction.AddItem(it)) })
}
}
}
/**
* One row: a live checkbox, editable text, and a remove button.
*
* The text commits on FOCUS LOSS rather than per keystroke. Every commit is a
* store write that reloads the note, so per-keystroke saving would both hammer
* SQLite and race the reload against the next character.
*/
@Composable
private fun ChecklistRow(
item: ChecklistItem,
readOnly: Boolean,
onAction: (EditorAction) -> Unit,
) {
// Keyed by item id, so a reload after some OTHER row's edit doesn't reset the
// text being typed here.
var text by remember(item.id) { mutableStateOf(item.text) }
val commit = { if (text != item.text) onAction(EditorAction.SetItemText(item.id, text)) }
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = item.checked,
onCheckedChange = { onAction(EditorAction.SetItemChecked(item.id, it)) },
enabled = !readOnly,
)
PlainTextField(
value = text,
onValueChange = { text = it },
modifier =
Modifier
.weight(1f)
.onFocusChanged { if (!it.isFocused) commit() },
enabled = !readOnly,
singleLine = true,
textStyle =
MaterialTheme.typography.bodyLarge.copy(
// Struck through when done, matching the card and the web.
textDecoration = if (item.checked) TextDecoration.LineThrough else null,
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { commit() }),
)
if (!readOnly) {
IconButton(onClick = { onAction(EditorAction.DeleteItem(item.id)) }) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.editor_remove_item),
)
}
}
}
}
/**
* The always-present row at the bottom for adding an item.
*
* It clears but keeps focus after a submit, so a list can be typed straight
* through — "milk ⏎ eggs ⏎ bread" — rather than costing a tap between each. That
* is the same speed the capture sheet's one-item-per-line field buys, carried into
* the editor so refining a list never feels slower than making one.
*/
@Composable
private fun AddItemRow(onAdd: (String) -> Unit) {
var text by remember { mutableStateOf("") }
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.Add,
contentDescription = null,
modifier = Modifier.padding(horizontal = 12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
PlainTextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.weight(1f),
hint = R.string.editor_add_item,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(onDone = {
onAdd(text)
text = ""
}),
)
}
}
@@ -0,0 +1,268 @@
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
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.BottomAppBar
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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, at the bottom where a thumb already is.
*
* 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 EditorBottomBar(
note: Note,
readOnly: Boolean,
tint: NoteTint,
onPicker: (Picker) -> Unit,
onConfirmDelete: () -> Unit,
onAction: (EditorAction) -> Unit,
) {
val dark = isSystemInDarkTheme()
BottomAppBar(containerColor = tint.background(dark)) {
if (!readOnly) {
// A dot in the note's CURRENT colour rather than a palette icon: it
// shows what the colour is as well as what the button does.
IconButton(onClick = { onPicker(Picker.COLOR) }) {
Box(
modifier =
Modifier
.size(SWATCH_DOT)
.clip(CircleShape)
.background(tint.chipBackground(dark))
.border(1.dp, tint.border(dark), CircleShape),
)
}
IconButton(onClick = { onPicker(Picker.REMINDER) }) {
Icon(
Icons.Filled.Notifications,
contentDescription = stringResource(R.string.editor_reminder),
)
}
// Adds the first checklist item, which is what makes the checklist
// editor appear. Hidden once the note already has one — there is nothing
// left to add that the checklist's own "+" row doesn't do better.
if (note.items.isEmpty()) {
IconButton(onClick = { onAction(EditorAction.AddChecklist) }) {
Icon(
Icons.AutoMirrored.Filled.List,
contentDescription = stringResource(R.string.editor_add_checklist),
)
}
}
}
Box(modifier = Modifier.weight(1f))
OverflowMenu(
note = note,
readOnly = readOnly,
onPicker = onPicker,
onConfirmDelete = onConfirmDelete,
onAction = onAction,
)
}
}
@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) }
}
}
}
}
@Composable
private fun MenuItem(
@StringRes labelRes: Int,
onClose: () -> Unit,
onClick: () -> Unit,
) {
DropdownMenuItem(
text = { Text(stringResource(labelRes)) },
onClick = {
// Close BEFORE acting. An overflow menu left hanging over the sheet
// that just opened underneath it is the classic version of this bug,
// and doing it here means no call site can forget.
onClose()
onClick()
},
)
}
/**
* 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 = noteTint(label.color)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 2.dp),
) {
Text(
text = label.name,
style = MaterialTheme.typography.labelLarge,
color = tint.chipForeground(dark),
modifier =
Modifier
.clip(CircleShape)
.background(tint.chipBackground(dark))
.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 val SWATCH_DOT = 22.dp
private const val SNOOZE_HOUR = 60L
private const val SNOOZE_DAY = 1440L
@@ -1,7 +1,11 @@
package com.fabledsword.inkwell.ui
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -9,16 +13,21 @@ 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.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
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.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
@@ -33,12 +42,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.core.Label
import com.fabledsword.inkwell.core.Note
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
@@ -47,17 +57,80 @@ 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.
// The three things you pick rather than type: a colour, a set of labels, a time.
//
// 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.
/** The note palette, as swatches. Order and colours come from [NOTE_TINTS]. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ColorSheet(
selected: String,
onPick: (String) -> Unit,
onDismiss: () -> Unit,
) {
val dark = isSystemInDarkTheme()
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.navigationBarsPadding(),
) {
SheetTitle(R.string.color_picker_title)
// Chunked into fixed rows rather than a flow layout: ten swatches
// always lay out as two rows of five on every phone width, and a flow
// would reshuffle them between devices for no gain.
NOTE_TINTS.entries.chunked(SWATCHES_PER_ROW).forEach { row ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
row.forEach { (key, tint) ->
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier
.size(SWATCH_SIZE)
.clip(CircleShape)
.background(tint.background(dark))
.border(
// The selected swatch gets a heavier ring
// as well as a tick: on the pale tints the
// tick alone is nearly invisible.
if (key == selected) 2.dp else 1.dp,
if (key == selected) {
MaterialTheme.colorScheme.primary
} else {
tint.border(dark)
},
CircleShape,
).clickable(onClickLabel = tint.label) { onPick(key) },
) {
if (key == selected) {
Icon(
Icons.Filled.Check,
contentDescription = tint.label,
modifier = Modifier.size(18.dp),
)
}
}
}
// Pad a short final row so its swatches line up with the row
// above instead of spreading across the full width.
repeat(SWATCHES_PER_ROW - row.size) {
Box(modifier = Modifier.size(SWATCH_SIZE))
}
}
}
}
}
}
/**
* Every label, ticked where it is on the note.
*
@@ -388,6 +461,8 @@ private val RECURRENCE_RULES: List<Pair<String?, Int>> =
"yearly" to R.string.recurrence_yearly,
)
private const val SWATCHES_PER_ROW = 5
private const val EVENING_HOUR = 18
private const val MORNING_HOUR = 8
private val SWATCH_SIZE = 44.dp
private val LABEL_LIST_MAX_HEIGHT = 320.dp
@@ -1,4 +1,4 @@
package com.fabledsword.inkwell.ui
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -16,7 +16,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.fabledsword.inkwell.R
import com.fabledsword.thoughtsync.R
// Shared by the board and the editor.
//
@@ -1,4 +1,4 @@
package com.fabledsword.inkwell.ui
package com.fabledsword.thoughtsync.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -1,4 +1,4 @@
package com.fabledsword.inkwell.ui
package com.fabledsword.thoughtsync.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -0,0 +1,188 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.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.MaterialTheme
import androidx.compose.material3.Text
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.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
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.ChecklistItem
import com.fabledsword.thoughtsync.core.Note
import com.fabledsword.thoughtsync.core.NoteLabel
@Composable
fun NoteCard(
note: Note,
onOpen: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = noteTint(note.color)
Column(
modifier =
Modifier
.fillMaxWidth()
// Clipped BEFORE clickable, so the ripple is bounded by the card's
// rounded corners instead of a rectangle overhanging them.
.clip(RoundedCornerShape(CARD_RADIUS))
.clickable(onClickLabel = stringResource(R.string.board_open_note), onClick = onOpen)
.background(tint.background(dark))
.border(1.dp, tint.border(dark), RoundedCornerShape(CARD_RADIUS))
.padding(12.dp),
) {
// Body then checklist, in order — a note can carry both (M13 step 2), and
// nothing above them: 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()) {
Text(
text = note.body,
style = MaterialTheme.typography.bodyMedium,
maxLines = MAX_PREVIEW_LINES,
overflow = TextOverflow.Ellipsis,
)
}
if (note.items.isNotEmpty()) {
if (note.body.isNotBlank()) Spacer(Modifier.height(4.dp))
Checklist(items = note.items)
}
// A note with no body and no items still has to occupy the board legibly —
// otherwise it reads as a rendering bug.
if (note.body.isBlank() && note.items.isEmpty()) {
Text(
text = stringResource(R.string.board_empty_note),
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (note.labels.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
LabelChips(labels = note.labels)
}
note.remindAt?.let { at ->
Spacer(Modifier.height(8.dp))
ReminderChip(instant = at, recurrence = note.recurrence)
}
}
}
@Composable
private fun Checklist(items: List<ChecklistItem>) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
items.take(MAX_CHECKLIST_ROWS).forEach { item ->
Row(verticalAlignment = Alignment.Top) {
// A glyph rather than a real Checkbox: the card is a PREVIEW, and
// a live control here would invite taps that the board cannot yet
// honour. It becomes interactive with the editor.
Text(
text = if (item.checked) "☑" else "☐",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(end = 6.dp),
)
Text(
text = item.text,
style = MaterialTheme.typography.bodyMedium,
textDecoration = if (item.checked) TextDecoration.LineThrough else null,
color =
if (item.checked) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
val hidden = items.size - MAX_CHECKLIST_ROWS
if (hidden > 0) {
Text(
text = pluralStringResource(R.plurals.board_more_items, hidden, hidden),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
@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 = noteTint(label.color)
Text(
text = label.name,
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),
)
}
}
}
/**
* 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
private const val MAX_CHECKLIST_ROWS = 8
private const val MAX_LABEL_CHIPS = 3
private val CARD_RADIUS = 12.dp
private val CHIP_RADIUS = 6.dp
@@ -0,0 +1,276 @@
package com.fabledsword.thoughtsync.ui
import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.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.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Label
import com.fabledsword.thoughtsync.core.Note
/**
* The note editor: a full screen, not a sheet.
*
* A sheet works for capture, where the board behind it is reassurance that the
* thought landed somewhere. Editing is different — 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 gives the actions a bottom bar,
* which is where a thumb already is.
*
* The note's own colour paints the WHOLE screen rather than a card inside it, so
* opening a note reads as the same object growing to fill the display.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NoteEditorScreen(
note: Note,
labels: List<Label>,
saving: Boolean,
error: String?,
onAction: (EditorAction) -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = noteTint(note.color)
// Keyed by note id: the editor is reused across notes, and without the key the
// second note opened would show the first one's text.
var body by remember(note.id) { mutableStateOf(note.body) }
var picker by remember(note.id) { mutableStateOf(Picker.NONE) }
var confirmingDelete by remember(note.id) { 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 && body != note.body) {
onAction(EditorAction.SaveText(body))
}
}
val leave = {
flush()
onAction(EditorAction.Close)
}
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)
Scaffold(
containerColor = tint.background(dark),
topBar = {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = leave) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.editor_back),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = tint.background(dark)),
)
},
bottomBar = {
EditorBottomBar(
note = note,
readOnly = readOnly,
tint = tint,
onPicker = { picker = it },
onConfirmDelete = { confirmingDelete = true },
onAction = onAction,
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.imePadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
) {
// A one-pixel line, not a spinner: a save slow enough to see is worth
// showing, and one that isn't must not make the screen jump.
if (saving) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
// 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) })
}
// One field. A note is its body; its NAME is that body's first line, so
// there is nothing separate to type into and nothing to render bolder
// than the line beneath it (M13 steps 3 and 4).
EditorField(
value = body,
onValueChange = { body = it },
hint = R.string.editor_body_hint,
enabled = !readOnly,
minLines = MIN_BODY_LINES,
)
// Below the body, not instead of it, and only once the note has items —
// the toolbar's add-checklist action is what puts the first one there.
if (note.items.isNotEmpty()) {
ChecklistEditor(note = note, readOnly = readOnly, onAction = onAction)
}
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) {
// The only irreversible action in the app earns the only confirmation in
// it. Everything else — archive, trash, even unlinking a server — undoes.
AlertDialog(
onDismissRequest = { confirmingDelete = false },
title = { Text(stringResource(R.string.editor_delete_forever_title)) },
text = { Text(stringResource(R.string.editor_delete_forever_body)) },
confirmButton = {
TextButton(onClick = {
confirmingDelete = false
onAction(EditorAction.DeleteForever)
}) {
Text(stringResource(R.string.editor_delete_forever_confirm))
}
},
dismissButton = {
TextButton(onClick = { confirmingDelete = false }) {
Text(stringResource(R.string.editor_cancel))
}
},
)
}
}
/** Which overlay is open. One at a time, so they cannot stack on a phone screen. */
enum class Picker { NONE, COLOR, 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.COLOR ->
ColorSheet(
selected = note.color,
onPick = {
onAction(EditorAction.SetColor(it))
dismiss()
},
onDismiss = dismiss,
)
Picker.LABELS ->
LabelSheet(
note = note,
labels = labels,
onAction = onAction,
onDismiss = dismiss,
)
Picker.REMINDER ->
ReminderSheet(
note = note,
onAction = onAction,
onDismiss = dismiss,
)
}
}
/**
* The note's body field.
*
* Undecorated, via the shared [PlainTextField]: the screen is already painted in
* the note's colour, and a filled field would draw a second surface over the first
* and turn a note into a form.
*
* One weight throughout. The first line is the note's name, but it is not a
* different KIND of text from the line after it, and typing it should not feel like
* filling in a header.
*/
@Composable
private fun EditorField(
value: String,
onValueChange: (String) -> Unit,
@StringRes hint: Int,
enabled: Boolean,
minLines: Int = 1,
) {
PlainTextField(
value = value,
onValueChange = onValueChange,
hint = hint,
enabled = enabled,
minLines = minLines,
textStyle = MaterialTheme.typography.bodyLarge,
)
}
private const val MIN_BODY_LINES = 6
@@ -0,0 +1,177 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color
/**
* The note colour palette, matching `frontend/src/notes/colors.ts` VALUE FOR VALUE.
*
* A note's 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 note 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 (`dark:bg-red-950/40`) instead of a
* precomputed blend — Compose composites a translucent colour over what's beneath
* exactly as CSS does, so the card sits on the background the same way in both.
*
* `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 lightChipForeground: Color,
val darkChipBackground: Color,
val darkChipForeground: Color,
) {
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
fun chipForeground(dark: Boolean): Color = if (dark) darkChipForeground else lightChipForeground
}
/** 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),
),
"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),
),
"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),
),
"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),
),
"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),
),
"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),
),
"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),
),
"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),
),
"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),
),
"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),
),
)
/**
* 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")
@@ -1,6 +1,5 @@
package com.fabledsword.inkwell.ui
package com.fabledsword.thoughtsync.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
@@ -9,8 +8,6 @@ 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
@@ -20,7 +17,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.fabledsword.inkwell.R
import com.fabledsword.thoughtsync.R
/**
* A bordered block, tinted from the same table the notes use.
@@ -82,66 +79,6 @@ fun Notice(
}
}
/**
* One row of a dropdown menu, closing the menu before it acts.
*
* Lives here rather than beside either menu because there are two now — the
* 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 }
@@ -1,4 +1,4 @@
package com.fabledsword.inkwell.ui
package com.fabledsword.thoughtsync.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.fillMaxWidth
@@ -9,7 +9,6 @@ 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
@@ -21,16 +20,12 @@ 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.
* Every writing surface in the app — the capture sheet, the editor's title and
* body, each checklist row — sits on a surface that already has its own edges and
* its own colour. Material's filled field would draw a second, differently
* coloured box inside the first, which makes writing a note look like filling in a
* form. Stripping the container and the indicator in four places independently is
* how they drift apart, so it happens once, here.
*
* 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
@@ -63,26 +58,18 @@ fun PlainTextField(
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
visualTransformation = visualTransformation,
colors = plainFieldColors(),
colors =
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,
),
)
}
/**
* 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,
)
@@ -1,4 +1,4 @@
package com.fabledsword.inkwell.ui
package com.fabledsword.thoughtsync.ui
import android.app.AlarmManager
import android.content.Context
@@ -17,8 +17,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationManagerCompat
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.Reminders
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.Reminders
/**
* Says so when a reminder would not actually reach anyone.
@@ -1,4 +1,4 @@
package com.fabledsword.inkwell.ui
package com.fabledsword.thoughtsync.ui
import android.os.Build
import androidx.compose.foundation.layout.Arrangement
@@ -27,9 +27,9 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.core.Compatibility
import com.fabledsword.inkwell.core.RevokeOutcome
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.
//
@@ -1,4 +1,4 @@
package com.fabledsword.inkwell.ui
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -31,20 +31,18 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.UpdateOutcome
import com.fabledsword.inkwell.installedVersionName
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.** Inkwell is local-first and completely usable having never opened
* 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.
@@ -122,38 +120,10 @@ fun SyncScreen(
onDismissRevokeNotice = onDismissRevokeNotice,
)
}
BuildLine()
}
}
}
/**
* The build, dim, at the foot of Sync — the same thing the web UI puts at the
* bottom of its rail (#3181).
*
* Note 3127 §5 is why it is here at all. With version tags gone, an artifact's own
* self-report is the only answer to "which build is this?" — so it renders
* "unknown" rather than nothing when the name is absent, because a blank line looks
* like a layout bug and a plausible default cannot be caught by anything.
*
* The read itself is `installedVersionName()`, shared with the client header the
* app sends its server: one answer to "which build is on this phone", so the line
* a person quotes in a bug report and the line in the server's log cannot disagree.
*/
@Composable
private fun BuildLine() {
val context = LocalContext.current
val unknown = stringResource(R.string.build_unknown)
val version = remember(context) { context.installedVersionName() ?: unknown }
Text(
text = version,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 16.dp),
)
}
// ───────────────────────────────── linked ─────────────────────────────────
@Composable
@@ -1,11 +1,11 @@
package com.fabledsword.inkwell.ui
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.inkwell.R
import com.fabledsword.inkwell.core.Compatibility
import com.fabledsword.inkwell.core.SyncOutcome
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Compatibility
import com.fabledsword.thoughtsync.core.SyncOutcome
// Turning sync results into sentences.
//
@@ -1,4 +1,4 @@
package com.fabledsword.inkwell.ui
package com.fabledsword.thoughtsync.ui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -6,12 +6,12 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.fabledsword.inkwell.core.Compatibility
import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.inkwell.core.ProbeResult
import com.fabledsword.inkwell.core.RevokeOutcome
import com.fabledsword.inkwell.core.SyncOutcome
import com.fabledsword.inkwell.core.SyncStatus
import 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
@@ -113,7 +113,7 @@ data class SyncState(
* stored cursor next time (Scribe #2736).
*/
class SyncViewModel(
private val core: Inkwell,
private val core: ThoughtSync,
/**
* Called after a sync that changed the store.
*
@@ -316,7 +316,7 @@ class SyncViewModel(
companion object {
fun factory(
core: Inkwell,
core: ThoughtSync,
onStoreChanged: () -> Unit,
): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@@ -344,7 +344,7 @@ private fun SyncOutcome.changedTheStore(): Boolean =
* The message to show for a failure.
*
* The core writes these for people to read — "notes.example.com responded, but not
* with Inkwell's configuration" — so they are shown as-is rather than
* 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."
@@ -1,4 +1,4 @@
package com.fabledsword.inkwell.ui
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
@@ -59,7 +59,7 @@ private val DarkColors =
)
/**
* Material 3 in Inkwell's own colours, following the system light/dark setting.
* 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
@@ -72,7 +72,7 @@ private val DarkColors =
* If dynamic colour is ever wanted it belongs behind a setting, not as the default.
*/
@Composable
fun InkwellTheme(
fun ThoughtSyncTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
@@ -1,5 +1,6 @@
package com.fabledsword.inkwell.ui
package com.fabledsword.thoughtsync.ui
import java.time.Instant
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneId
@@ -66,14 +67,9 @@ fun reminderLabel(
}
}
/** 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()
}
fun isPast(raw: String): Boolean =
runCatching { OffsetDateTime.parse(raw).toInstant() < Instant.now() }.getOrDefault(false)
/**
* Whether a timestamp is older than [minutes] ago — or absent entirely.
@@ -87,8 +83,8 @@ fun olderThan(
raw: String?,
minutes: Long,
): Boolean {
val at = raw?.let { epochMillis(it) }
return at == null || at < System.currentTimeMillis() - minutes * MILLIS_PER_MINUTE
val at = raw?.let { runCatching { OffsetDateTime.parse(it).toInstant() }.getOrNull() }
return at == null || at < Instant.now().minusSeconds(minutes * SECONDS_PER_MINUTE)
}
private const val MILLIS_PER_MINUTE = 60_000L
private const val SECONDS_PER_MINUTE = 60L
@@ -1,33 +1,24 @@
package com.fabledsword.inkwell.ui
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.inkwell.AppUpdate
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.UpdateOutcome
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.
@@ -122,59 +113,6 @@ fun UpdateCard(
}
}
/**
* The nag: an update is waiting, said where someone will actually see it.
*
* Until this existed the only way to learn about a new build was to open the sync
* screen and press Check — so the updates that got installed were the ones somebody
* went looking for, and the rest were simply never found.
*
* 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.
*
@@ -0,0 +1,128 @@
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,
val working: Boolean = false,
val error: String? = null,
) {
val busy: Boolean get() = checking || working
}
/**
* 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
/** Ask the linked server what it has. */
fun check() {
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)
} 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)
}
}
}
/**
* Download the update and hand it to the system installer.
*
* One action rather than two buttons: nobody wants a downloaded APK sitting
* around as an intermediate state they have to think about.
*/
fun downloadAndInstall() {
viewModelScope.launch {
state = state.copy(working = true, error = null)
UpdateOutcome.clear()
val failure =
try {
val target = AppUpdate.downloadTarget(context)
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."
@@ -3,12 +3,10 @@
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 inkwell mark alone on transparency, inside the 66dp safe
circle; the yellow is the background colour. It is rendered by
packaging/icons.py from the same drawing as the web and desktop icons, so all
three wear one face. Transparent rather than a full-bleed tile because the
monochrome layer below reuses it: a themed icon draws its alpha as a silhouette,
and an opaque foreground would come out as a solid square.
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" />
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 10 KiB

+1 -1
View File
@@ -2,6 +2,6 @@
<resources>
<!-- The product's brand colour, same value the web app's manifest and
<meta name="theme-color"> already use. One source of truth for "what
colour is Inkwell" across the three surfaces. -->
colour is ThoughtSync" across the three surfaces. -->
<color name="ic_launcher_background">#F5C518</color>
</resources>
+21 -83
View File
@@ -1,26 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Inkwell</string>
<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">Tags</string>
<string name="nav_labels">Labels</string>
<!-- Compose sheet -->
<string name="compose_open">New note</string>
<string name="compose_body_hint">Take a note…</string>
<string name="compose_discard">Discard</string>
<string name="compose_save">Save</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>
<plurals name="board_more_items">
<item quantity="one">+%d more item</item>
<item quantity="other">+%d more items</item>
</plurals>
<!-- 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. -->
@@ -39,20 +38,15 @@
<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_body_hint">Note</string>
<string name="editor_add_item">Add item</string>
<string name="editor_remove_item">Remove item</string>
<string name="editor_remove_label">Remove tag</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">Tags…</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>
@@ -66,61 +60,12 @@
<string name="editor_delete_forever_body">It will be removed from this device and from every device you sync with. This cannot be undone.</string>
<string name="editor_delete_forever_confirm">Delete</string>
<!-- Quick capture from outside the app: the share sheet and the text-selection
toolbar. "New note" says what happens; the activity's own label would say
who it happens in. -->
<string name="capture_process_text">New note</string>
<!-- Tag management. The whole vocabulary is "tag" (see Scribe #2966); the
schema still says Label, and no string here needs to know that. -->
<string name="tags_manage">Manage tags</string>
<string name="tags_title">Tags</string>
<string name="tags_back">Back</string>
<string name="tags_new_hint">New tag</string>
<string name="tags_create">Create</string>
<string name="tags_count">%1$d notes</string>
<string name="tags_count_one">1 note</string>
<string name="tags_count_none">No notes yet</string>
<string name="tags_empty_title">No tags yet</string>
<string name="tags_empty_body">Create one above, or write a #tag in a note and it becomes one.</string>
<string name="tags_actions">More actions</string>
<string name="tags_colour">Colour</string>
<string name="tags_colour_of">Colour for %1$s</string>
<string name="tags_rename">Rename</string>
<string name="tags_rename_title">Rename %1$s</string>
<string name="tags_rename_confirm">Rename</string>
<!-- Renaming onto an existing tag merges the two, older survives (Scribe
#3324). A merge cannot be undone by repeating it and is reachable here by
a typo, so it says so before it happens — same reasoning as #2116. -->
<string name="tags_rename_merges_title">Merge with %1$s?</string>
<string name="tags_rename_merges_body">A tag called %1$s already exists. Renaming will merge these two into one, carrying every note from both. The notes are kept; one of the two tags stops existing, and that cannot be undone.</string>
<string name="tags_rename_merges_confirm">Merge</string>
<string name="tags_merge">Merge into…</string>
<string name="tags_merge_title">Merge %1$s into…</string>
<!-- The survivor is named in the button, not just the title: this is the one
operation here that repeating does not undo. -->
<string name="tags_merge_body">Every note tagged %1$s will be tagged with the one you pick instead, and %1$s will stop existing. The notes are kept.</string>
<string name="tags_merge_none">There is no other tag to merge into.</string>
<string name="tags_delete">Delete</string>
<string name="tags_delete_title">Delete %1$s?</string>
<string name="tags_delete_body">It will be removed from every note that has it, on every device you sync with. The notes themselves are kept.</string>
<string name="tags_delete_body_counted">It is on %1$d notes. It will be removed from all of them, on every device you sync with. The notes themselves are kept.</string>
<string name="tags_delete_confirm">Delete</string>
<!-- A tag written as #tag in a note's body is owned by that text. Deleting the
row cannot un-write the word, so it comes back on that note's next edit —
said here rather than left as a surprise. -->
<string name="tags_delete_from_text">Tags written as #tag in a note come back when that note is next edited.</string>
<string name="tags_cancel">Cancel</string>
<!-- Pickers -->
<string name="label_picker_title">Tags</string>
<string name="label_new_hint">Type a tag and press enter</string>
<string name="label_from_tag">from the text</string>
<string name="label_none_body">No tags yet. Type one above, or write a #tag in a note and it becomes one.</string>
<string name="color_picker_title">Color</string>
<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>
@@ -164,7 +109,7 @@
<string name="reminder_channel">Reminders</string>
<string name="reminder_channel_description">Notifies you when a note\'s reminder is due.</string>
<string name="reminder_notifications_blocked_title">Reminders can\'t notify you</string>
<string name="reminder_notifications_blocked_body">Notifications are turned off for Inkwell, so reminders will only show here on the board.</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>
@@ -177,11 +122,9 @@
<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">Inkwell has to be allowed to install apps before it can update itself. This is a one-time setting.</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>
@@ -196,14 +139,14 @@
<!-- Unlinked -->
<string name="sync_offline_title">Working offline on this device</string>
<string name="sync_offline_body">Everything works without a server — your notes are stored on this phone. Connect an Inkwell server if you want them to reach your other devices.</string>
<string name="sync_offline_body">Everything works without a server — your notes are stored on this phone. Connect 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">Inkwell server</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>
@@ -250,9 +193,4 @@
<!-- Errors -->
<string name="error_dismiss">Dismiss</string>
<!-- The build, at the foot of Sync. Never blank: an APK with no versionName is
a real state (a bare `gradlew assembleDebug` with no override) and saying
so is better than an empty line that reads as a layout bug. -->
<string name="build_unknown">unknown</string>
</resources>
+1 -1
View File
@@ -6,5 +6,5 @@
truth in XML — the same reason the desktop reads its live theme rather
than hardcoding a window colour.
-->
<style name="Theme.Inkwell" parent="android:Theme.Material.NoActionBar" />
<style name="Theme.ThoughtSync" parent="android:Theme.Material.NoActionBar" />
</resources>
@@ -1,137 +0,0 @@
package com.fabledsword.inkwell.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)
}
}
+3 -3
View File
@@ -1,13 +1,13 @@
[package]
name = "inkwell-uniffi-bindgen"
name = "thoughtsync-uniffi-bindgen"
version = "0.1.0"
description = "Generates the Kotlin bindings for inkwell-ffi"
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 inkwell-ffi, which failed: building
# 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
+2 -2
View File
@@ -3,8 +3,8 @@
//! Invoked by Gradle (see android/app/build.gradle.kts) as:
//!
//! ```text
//! cargo run --locked -p inkwell-uniffi-bindgen -- \
//! generate --library <path/to/libinkwell_ffi.so> \
//! cargo run --locked -p thoughtsync-uniffi-bindgen -- \
//! generate --library <path/to/libthoughtsync_ffi.so> \
//! --language kotlin --out-dir <build/generated/uniffi>
//! ```
//!
+1 -1
View File
@@ -74,7 +74,7 @@ exceptions:
# right and still applies.
excludes:
- "**/ui/**"
- "**/InkwellApplication.kt"
- "**/ThoughtSyncApplication.kt"
- "**/SyncWorker.kt"
- "**/ReminderReceiver.kt"
- "**/AppUpdate.kt"
+4 -4
View File
@@ -1,7 +1,7 @@
[package]
name = "inkwell-ffi"
name = "thoughtsync-ffi"
version = "0.1.0"
description = "uniffi bindings exposing inkwell-core to the native Android client"
description = "uniffi bindings exposing thoughtsync-core to the native Android client"
authors = ["bvandeusen"]
edition = "2021"
@@ -10,10 +10,10 @@ edition = "2021"
# bindgen binary below — and this crate's own tests — can use the crate normally;
# a cdylib-only crate is unusable from Rust.
crate-type = ["cdylib", "lib"]
name = "inkwell_ffi"
name = "thoughtsync_ffi"
[dependencies]
inkwell-core = { path = "../../core" }
thoughtsync-core = { path = "../../core" }
serde_json = { workspace = true }
log = { workspace = true }
+30 -222
View File
@@ -1,4 +1,4 @@
//! uniffi bindings: `inkwell-core` as seen from Kotlin.
//! 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
@@ -7,7 +7,7 @@
//!
//! ## Shape
//!
//! One `Inkwell` object holds the store and the blob directory, mirroring how
//! 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.
//!
@@ -38,13 +38,13 @@ pub mod models;
use std::path::PathBuf;
use std::sync::Arc;
use inkwell_core::local::{self, Db};
use inkwell_core::sync::blobs::BlobStore;
use inkwell_core::sync::{client, compat, engine, push, state};
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,
patch_from, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery, ProbeResult,
RevokeOutcome, SyncOutcome, SyncStatus,
};
uniffi::setup_scaffolding!();
@@ -108,30 +108,30 @@ impl CoreError {
/// behind its mutex, the blob store being a path — which is what lets uniffi share
/// one instance across coroutines.
#[derive(uniffi::Object)]
pub struct Inkwell {
pub struct ThoughtSync {
db: Db,
blobs: BlobStore,
}
#[uniffi::export]
impl Inkwell {
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 — `inkwell.db` and
/// 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("inkwell.db")).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(Inkwell { db, blobs }))
Ok(Arc::new(ThoughtSync { db, blobs }))
}
/// A one-line count summary, for the boot log.
@@ -333,65 +333,6 @@ impl Inkwell {
.map_err(CoreError::store)
}
/// Rename a label. Every note carrying it follows, because notes reference it
/// by id and never by name.
///
/// Renaming onto a name another tag already holds MERGES the two, and the OLDER
/// row is the survivor — it keeps its id and colour and takes the new spelling.
/// Matching is case-insensitive, like `find_or_create_label`.
///
/// So this call can return a label whose id is NOT the one passed in, and it can
/// make another label stop existing. A UI over it should say so before calling:
/// the merge cannot be undone by repeating it, and here it is reachable by a
/// typo in a text field. The web asks first (`stores/labels.ts`); this binding
/// deliberately does not, because a confirmation belongs to the surface that has
/// a person in front of it, not to the store.
///
/// `store::rename_label` and the server's PATCH implement the same rule, so the
/// phone, the desktop and the web agree on which row survives.
pub fn rename_label(&self, id: String, name: String) -> Result<Label, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::rename_label(&conn, &id, &name)
.map(Label::from)
.map_err(CoreError::store)
}
/// Recolour a label.
///
/// `color` is a palette KEY from the shared vocabulary (`NoteTint.kt` on this
/// side), not a hex value — the point of the shared palette is that a colour
/// picked on the phone resolves to the same swatch on the web and the desktop,
/// which a literal colour could not promise across themes.
pub fn set_label_color(&self, id: String, color: String) -> Result<Label, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::set_label_color(&conn, &id, &color)
.map(Label::from)
.map_err(CoreError::store)
}
/// Delete a label. The notes that carried it are NOT deleted — they simply stop
/// carrying it, which is the thing a confirmation dialog has to say out loud.
///
/// A `#tag` in a body will re-derive the label on the next edit of that note.
/// That is correct rather than a leak: the text mandates it, and deleting the
/// row cannot un-write the word.
pub fn remove_label(&self, id: String) -> Result<(), CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::remove_label(&conn, &id).map_err(CoreError::store)
}
/// Fold `source` into `target` and return the survivor.
///
/// DIRECTIONAL and NOT reversible by repeating it: source stops existing. Any
/// UI over this has to name the survivor before it runs, because afterwards
/// there is nothing left to read the direction from.
pub fn merge_labels(&self, source_id: String, target_id: String) -> Result<Label, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::merge_labels(&conn, &source_id, &target_id)
.map(Label::from)
.map_err(CoreError::store)
}
// ─────────────────────────────── sync ────────────────────────────────
pub fn sync_status(&self) -> Result<SyncStatus, CoreError> {
@@ -413,7 +354,7 @@ impl Inkwell {
/// 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 Inkwell {
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.
@@ -525,7 +466,7 @@ impl Inkwell {
///
/// 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 `Inkwell::new`
/// 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()?;
@@ -558,69 +499,9 @@ impl Inkwell {
}
}
// ── 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)
}
/// Tell the core which app it is running inside, and which build of it.
///
/// Android has to say so because the core cannot: the same crate is compiled into
/// the desktop app, and it used to announce every phone in the field as
/// `inkwell-desktop` carrying the CORE crate's version — a number no build
/// stamps and nobody has seen. The honest value is the installed package's own
/// `versionName`, which is what Kotlin passes here.
///
/// Called once from `InkwellApplication.onCreate`, before anything can sync.
#[uniffi::export]
pub fn set_client_agent(name: String, version: String) {
compat::set_client_agent(&name, &version);
}
/// Every checklist item in a body, with the line each one sits on — so a renderer
/// walking the body line by line knows which lines are boxes and what is in them.
#[uniffi::export]
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 Inkwell {
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 +
@@ -680,7 +561,7 @@ mod tests {
use std::sync::atomic::{AtomicU32, Ordering};
static NEXT: AtomicU32 = AtomicU32::new(0);
let dir = std::env::temp_dir().join(format!(
"inkwell-ffi-{}-{}",
"thoughtsync-ffi-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
@@ -690,6 +571,7 @@ mod tests {
fn draft(body: &str) -> NoteDraft {
NoteDraft {
body: body.to_string(),
color: "default".to_string(),
items: None,
}
}
@@ -701,7 +583,7 @@ mod tests {
#[test]
fn creates_a_store_and_round_trips_a_note() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let created = app
.create_note(draft("Groceries\nmilk"))
@@ -723,7 +605,7 @@ mod tests {
#[test]
fn a_note_is_named_by_its_first_line() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let created = app
.create_note(draft("just a thought"))
@@ -738,11 +620,12 @@ mod tests {
#[test]
fn a_note_with_only_items_is_named_by_its_first_item() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let created = app
.create_note(NoteDraft {
body: String::new(),
color: "default".to_string(),
items: Some(vec!["milk".to_string(), "eggs".to_string()]),
})
.expect("create should succeed");
@@ -757,7 +640,7 @@ mod tests {
#[test]
fn syncing_unlinked_reports_not_linked() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
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);
@@ -774,10 +657,11 @@ mod tests {
#[test]
fn checklist_items_can_be_added_ticked_retitled_and_removed() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app
.create_note(NoteDraft {
body: "Packing".to_string(),
color: "default".to_string(),
items: Some(vec!["socks".to_string()]),
})
.expect("create");
@@ -798,9 +682,8 @@ mod tests {
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"
"ticking a box must not disturb its text — the two setters write \
different columns and neither may clear the other"
);
let renamed = app
@@ -828,7 +711,7 @@ mod tests {
#[test]
fn setting_labels_leaves_tag_derived_ones_alone() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app
.create_note(draft("Trip\nbook the ferry #travel"))
@@ -869,7 +752,7 @@ mod tests {
#[test]
fn deleting_forever_removes_the_note() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
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())
@@ -886,7 +769,7 @@ mod tests {
#[test]
fn reminders_can_be_snoozed_and_completed() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
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);
@@ -912,7 +795,7 @@ mod tests {
#[test]
fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
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
@@ -965,81 +848,6 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
/// Renaming a tag onto a name another tag already holds MERGES the two, and the
/// OLDER row is the survivor.
///
/// Before this, the bare UPDATE met `idx_labels_name` — unique on `lower(name)` —
/// and the user got a raw "UNIQUE constraint failed" from SQLite. Merging is what
/// a person means by typing an existing tag's name onto this one.
#[test]
fn renaming_onto_an_existing_tag_merges_into_the_older_one() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let older = app.create_label("grocery".to_string()).expect("older");
// `created_at` is RFC3339 to the MILLISECOND. Without a gap the two rows can
// share a timestamp, and then the tie-break is under test instead of the age
// rule this test is about.
std::thread::sleep(std::time::Duration::from_millis(5));
let newer = app.create_label("errands".to_string()).expect("newer");
let one = app.create_note(draft("milk")).expect("note one");
let two = app.create_note(draft("stamps")).expect("note two");
app.set_note_labels(one.id.clone(), vec![older.id.clone()])
.expect("tag one");
app.set_note_labels(two.id.clone(), vec![newer.id.clone()])
.expect("tag two");
// The YOUNGER one is renamed onto the older's name, in a different case —
// matching is case-insensitive, and the survivor takes the spelling asked for.
let survivor = app
.rename_label(newer.id.clone(), "Grocery".to_string())
.expect("a rename onto an existing name merges instead of failing");
assert_eq!(
survivor.id, older.id,
"the older row is the one that survives"
);
assert_eq!(survivor.name, "Grocery", "spelled the way the caller asked");
let all = app.list_labels().expect("list");
assert_eq!(all.len(), 1, "the two became one");
assert_eq!(all[0].id, older.id);
assert_eq!(all[0].count, Some(2), "carrying every note from both sides");
std::fs::remove_dir_all(&dir).ok();
}
/// The mirror of the test above. Renaming the OLDER one onto the younger's name
/// still leaves the older row standing — it just changes its name.
///
/// This is the whole reason age decides rather than "whoever already held the
/// name": otherwise the survivor depends on which way round someone typed it,
/// and two devices tidying the same pair would disagree about which id exists.
#[test]
fn the_rename_merge_survivor_does_not_depend_on_the_direction() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let older = app.create_label("grocery".to_string()).expect("older");
std::thread::sleep(std::time::Duration::from_millis(5));
let newer = app.create_label("errands".to_string()).expect("newer");
let survivor = app
.rename_label(older.id.clone(), "errands".to_string())
.expect("rename");
assert_eq!(survivor.id, older.id, "age wins in this direction too");
assert_eq!(survivor.name, "errands");
assert_ne!(
survivor.id, newer.id,
"the younger row is the one that went"
);
assert_eq!(app.list_labels().expect("list").len(), 1);
std::fs::remove_dir_all(&dir).ok();
}
/// A crude RFC3339 sanity check that doesn't pull a date crate into this
/// crate's dev-dependencies to assert one field is well-formed.
fn chrono_free_parse(raw: &str) -> usize {
+23 -70
View File
@@ -1,6 +1,6 @@
//! The types that cross into Kotlin.
//!
//! These MIRROR `inkwell_core::local::models` rather than reusing it. The core's
//! 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
@@ -13,13 +13,13 @@
//! what to do with it. That is the entire reason for the `let Core { .. } = value`
//! style here; please keep it.
use inkwell_core::local::models as core_models;
use inkwell_core::sync::client as core_client;
use inkwell_core::sync::compat as core_compat;
use inkwell_core::sync::engine as core_engine;
use inkwell_core::sync::pull as core_pull;
use inkwell_core::sync::push as core_push;
use inkwell_core::sync::state as core_state;
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.
///
@@ -33,6 +33,7 @@ pub struct Note {
/// Always present. Derived by the core, never stored.
pub display_title: String,
pub body: String,
pub color: String,
pub position: i64,
pub pinned: bool,
pub archived: bool,
@@ -48,63 +49,6 @@ pub struct Note {
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<inkwell_core::local::derive::DerivedItem> for BodyItem {
fn from(i: inkwell_core::local::derive::DerivedItem) -> Self {
let inkwell_core::local::derive::DerivedItem {
text,
checked,
line,
} = 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<inkwell_core::local::derive::DerivedTag> for BodyTag {
fn from(t: inkwell_core::local::derive::DerivedTag) -> Self {
let inkwell_core::local::derive::DerivedTag {
line,
start,
end,
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
@@ -121,12 +65,12 @@ pub struct ClientUpdate {
pub size: i64,
}
impl From<inkwell_core::sync::client::ClientRelease> for ClientUpdate {
fn from(r: inkwell_core::sync::client::ClientRelease) -> Self {
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 inkwell_core::sync::client::ClientRelease {
let thoughtsync_core::sync::client::ClientRelease {
version,
version_code,
size,
@@ -186,6 +130,7 @@ impl From<core_models::Note> for Note {
id,
display_title,
body,
color,
position,
pinned,
archived,
@@ -204,6 +149,7 @@ impl From<core_models::Note> for Note {
id,
display_title,
body,
color,
position,
pinned,
archived,
@@ -341,6 +287,7 @@ pub struct NoteQuery {
#[derive(Debug, Clone, uniffi::Record)]
pub struct NoteFacets {
pub q: Option<String>,
pub color: Option<String>,
pub label: Option<Vec<String>>,
pub has_reminder: Option<bool>,
pub has_attachment: Option<bool>,
@@ -369,6 +316,7 @@ impl From<NoteFacets> for core_models::Facets {
fn from(value: NoteFacets) -> Self {
let NoteFacets {
q,
color,
label,
has_reminder,
has_attachment,
@@ -377,6 +325,7 @@ impl From<NoteFacets> for core_models::Facets {
} = value;
core_models::Facets {
q,
color,
label,
has_reminder,
has_attachment,
@@ -390,6 +339,8 @@ impl From<NoteFacets> for core_models::Facets {
#[derive(Debug, Clone, uniffi::Record)]
pub struct NoteDraft {
pub body: String,
/// "default" unless the user picked a colour.
pub color: 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>>,
@@ -397,8 +348,8 @@ pub struct NoteDraft {
impl From<NoteDraft> for core_models::NoteCreateInput {
fn from(value: NoteDraft) -> Self {
let NoteDraft { body, items } = value;
core_models::NoteCreateInput { body, items }
let NoteDraft { body, color, items } = value;
core_models::NoteCreateInput { body, color, items }
}
}
@@ -413,6 +364,7 @@ impl From<NoteDraft> for core_models::NoteCreateInput {
#[derive(Debug, Clone, uniffi::Enum)]
pub enum NoteEdit {
Body { value: String },
Color { value: String },
Pinned { value: bool },
Archived { value: bool },
RemindAt { value: String },
@@ -432,6 +384,7 @@ impl NoteEdit {
use serde_json::Value;
match self {
NoteEdit::Body { value } => ("body", Value::String(value)),
NoteEdit::Color { value } => ("color", 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)),
+3 -3
View File
@@ -1,5 +1,5 @@
# Where the generated Kotlin lands. Matches the app's package so the bindings are
# `com.fabledsword.inkwell.core.*` rather than something the app has to alias.
# `com.fabledsword.thoughtsync.core.*` rather than something the app has to alias.
[bindings.kotlin]
package_name = "com.fabledsword.inkwell.core"
cdylib_name = "inkwell_ffi"
package_name = "com.fabledsword.thoughtsync.core"
cdylib_name = "thoughtsync_ffi"
+1 -1
View File
@@ -18,7 +18,7 @@ dependencyResolutionManagement {
mavenCentral()
}
}
rootProject.name = "Inkwell"
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
+29 -35
View File
@@ -1,4 +1,4 @@
# CI Requirements — Inkwell
# CI Requirements — ThoughtSync
> Spec lives in [`docs/process.md`](https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md)
> in the CI-Runner repo.
@@ -39,26 +39,28 @@ entirely on `ci-python:3.14`.
install` cold cost is a non-blocker.
- Build gates on `typecheck` + `lint` only. The `test` job runs in parallel for
visibility but does not block the dev image push. DB-backed / integration tests
run against the dev image manually — Inkwell's unit tests are DB-free (no
run against the dev image manually — ThoughtSync's unit tests are DB-free (no
Postgres service lane in CI yet).
- `dev` push -> `:dev` + `:<sha>`; `v*` tag -> `:latest` + `:<version>` + `:<sha>`
(family rule 46).
- The production runtime `Dockerfile` tracks python:3.12 so test results stay
representative of the deployed image.
- **Artifacts — stock `actions/upload-artifact@v7`, never `@v3`.**
- **Artifacts — use the mirrored upload action, never `actions/upload-artifact`.**
```yaml
uses: actions/upload-artifact@v7
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
```
Stock works on this forge since the runner moved to gitea/runner 3.x, which
edits the action's client-side `isGhes()` refusal out of its bundle. Proven on
2026-09-10 for upload-artifact v4–v7 and download-artifact v4–v8 (Scribe spike
#3843). Until then this repo pinned a SHA mirror of the Forgejo project's
fork, because upstream threw on the hostname before it opened a connection.
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.
`@v3` is still broken: it reports success, and Gitea serves artifacts back only
through the v4 API (`content_encoding = application/zip`), so a v3 upload is
stored but invisible to every retrieval path. A green job producing nothing
retrievable.
`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
@@ -87,7 +89,7 @@ parts. Three of them are family rules for a reason:
`docker ps` by it. A spaced or underscored name breaks the filter.
- **Service hostnames are not routable** on this runner (rule 79), so the step resolves
the Postgres container's bridge IP with `docker ps --filter` + `docker inspect` and
builds `INKWELL_DATABASE_URL` from it. `postgres:5432` will not connect.
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.
@@ -205,7 +207,7 @@ backend/frontend push.
## Android lane — being rebuilt (M12)
The Tauri-mobile Android lane is gone. Android is a native Kotlin/Compose client
over the shared `inkwell-core` crate instead — see Scribe note 2730 for the
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
@@ -218,7 +220,7 @@ second image, and JDK 25 (which requires **Gradle 9.1+** in this repo's wrapper
the old JDK 17 pin existed only because Tauri generated a Gradle 8.x project).
The Rust pin is in LOCKSTEP with `ci-tauri` and `ci-tauri-win`. All three build
`inkwell-core` from one workspace `Cargo.lock` under `--locked`, so a
`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.
@@ -333,9 +335,8 @@ 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`,
named for the crates as they were before the rename to Inkwell) matched CI run
3931's byte for byte. Same image, same lockfile, same units.
`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
@@ -410,25 +411,18 @@ the lockfile format and the picked versions identical to what CI would have
chosen. Commit the result in the same change as the `Cargo.toml` edit — a
manifest change pushed without it fails the gate.
## Channel releases: `dev-rolling` and `stable`
## Pushing: `dev` is both a branch and a tag
The two update channels are releases on fixed tags, because Fabled-Git has no
`/releases/latest/download/<asset>` route and the updater needs a permanent URL.
The **channel** is still called `dev` everywhere a person sees it; its **release
tag** is `dev-rolling`. `packaging/channel-tag.sh` is the one mapping CI reads.
`desktop/src-tauri/src/update.rs` and `desktop/packaging/install.sh` carry their
own copy because neither can run it — change all three together.
`git push origin dev` fails in this repo:
The tag used to be `dev`, which shadowed the branch of the same name: once a clone
had fetched it, `git push origin dev` failed with
`error: src refspec dev matches more than one` (Scribe #2184). Never name a channel
tag after a branch; `tests/test_channel_tag.py` and the `update.rs` tests fail if
one is.
```
error: src refspec dev matches more than one
```
**Transitional, from 2026-09-10:** the old `dev` release still exists so desktop
apps installed from it can update across — the manifest job writes `latest.json`
to it too (`BRIDGE_TAG=dev` in `desktop.yml`). Until that release and its tag are
deleted, fully qualify pushes:
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
+2 -2
View File
@@ -1,7 +1,7 @@
[package]
name = "inkwell-core"
name = "thoughtsync-core"
version = "0.1.0"
description = "Inkwell client core — local-first SQLite store and opt-in sync engine"
description = "ThoughtSync client core — local-first SQLite store and opt-in sync engine"
authors = ["bvandeusen"]
edition = "2021"
+1 -1
View File
@@ -1,4 +1,4 @@
//! Inkwell's client core: the on-device SQLite store and the sync engine.
//! 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
+9 -707
View File
@@ -1,31 +1,19 @@
//! 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):
//! Deriving `#tags` 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();
/// Extract every `#tag` name (without the leading `#`) from `body`.
pub fn extract_tags(body: &str) -> Vec<String> {
let chars: Vec<char> = body.chars().collect();
let mut out: Vec<String> = Vec::new();
let mut i = 0;
while i < chars.len() {
if chars[i] == '#' {
@@ -36,7 +24,8 @@ fn line_tags(chars: &[char]) -> Vec<(usize, usize, String)> {
while j < chars.len() && is_tag_char(chars[j]) {
j += 1;
}
out.push((i, j, chars[i + 1..j].iter().collect()));
let tag: String = chars[i + 1..j].iter().collect();
push_unique(&mut out, &tag);
i = j;
continue;
}
@@ -46,183 +35,6 @@ fn line_tags(chars: &[char]) -> Vec<(usize, usize, String)> {
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 == '-'
}
@@ -233,239 +45,6 @@ fn push_unique(out: &mut Vec<String>, candidate: &str) {
}
}
// ── 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::*;
@@ -493,281 +72,4 @@ mod tests {
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);
}
}
+9
View File
@@ -13,6 +13,7 @@ pub struct Note {
/// never stored.
pub display_title: String,
pub body: String,
pub color: String,
pub position: i64,
pub pinned: bool,
pub archived: bool,
@@ -120,10 +121,16 @@ pub struct User {
pub is_admin: bool,
}
fn default_color() -> String {
"default".to_string()
}
#[derive(Deserialize)]
pub struct NoteCreateInput {
#[serde(default)]
pub body: String,
#[serde(default = "default_color")]
pub color: String,
#[serde(default)]
pub items: Option<Vec<String>>,
}
@@ -147,6 +154,8 @@ pub struct Facets {
#[serde(default)]
pub q: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub label: Option<Vec<String>>,
#[serde(default)]
pub has_reminder: Option<bool>,
+1 -1
View File
@@ -1,6 +1,6 @@
//! Recurring-reminder math: where a reminder goes when it is marked done.
//!
//! A deliberate port of the server's `src/inkwell/notes/recurrence.py`, kept
//! 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
+2 -285
View File
@@ -6,16 +6,14 @@
//!
//! Migrations are gated on `PRAGMA user_version`; bump it and add a block per change.
use rusqlite::{params, Connection, OptionalExtension};
use crate::local::derive;
use rusqlite::Connection;
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
color TEXT NOT NULL DEFAULT 'default',
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,
@@ -56,8 +54,6 @@ CREATE TABLE checklist_items (
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,
@@ -184,96 +180,7 @@ 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))?;
@@ -305,195 +212,5 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
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");
}
}
+102 -256
View File
@@ -9,7 +9,7 @@
use chrono::{DateTime, Duration, SecondsFormat, Utc};
use rusqlite::{params, params_from_iter, Connection, OptionalExtension};
use serde_json::{json, Value};
use serde_json::Value;
use uuid::Uuid;
use crate::local::derive;
@@ -24,25 +24,24 @@ fn new_id() -> String {
Uuid::new_v4().to_string()
}
/// The note's NAME: the first line of its body that says anything.
/// The note's NAME: its first non-blank body line, else its first checklist item.
///
/// 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();
}
/// Pure, and given the items rather than fetching them: every caller has already
/// loaded them, so a query here would be a second trip for something already in hand.
fn display_title(body: &str, items: &[ChecklistItem]) -> String {
if let Some(line) = body.lines().map(str::trim).find(|l| !l.is_empty()) {
return line.to_string();
}
String::new()
items
.iter()
.map(|i| i.text.trim())
.find(|t| !t.is_empty())
.unwrap_or("")
.to_string()
}
fn escape_like(s: &str) -> String {
@@ -70,24 +69,19 @@ fn load_labels(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<NoteLab
rows.collect()
}
/// 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,
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)?,
})
.collect()
})?;
rows.collect()
}
fn load_attachments(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<Attachment>> {
@@ -141,7 +135,7 @@ 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, body, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
"SELECT id, body, color, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
FROM notes WHERE id = ?1",
[id],
|r| {
@@ -150,13 +144,14 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
id: r.get(0)?,
display_title: String::new(), // filled below — it may need a query
body,
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)?,
color: r.get(2)?,
position: r.get(3)?,
pinned: r.get(4)?,
archived: r.get(5)?,
trashed: r.get(6)?,
deleted_at: r.get(11)?,
remind_at: r.get(7)?,
recurrence: r.get(8)?,
labels: Vec::new(),
items: Vec::new(),
attachments: Vec::new(),
@@ -167,10 +162,11 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
},
)?;
note.labels = load_labels(conn, id)?;
note.items = items_of(&note.body);
note.items = load_items(conn, id)?;
note.attachments = load_attachments(conn, id)?;
note.previews = load_previews(conn, id)?;
note.display_title = display_title(&note.body);
// After the items, because a body-only-empty note is named by its first one.
note.display_title = display_title(&note.body, &note.items);
Ok(note)
}
@@ -204,89 +200,34 @@ fn find_or_create_label(conn: &Connection, name: &str) -> rusqlite::Result<Strin
Ok(id)
}
/// 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)?);
/// 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)?);
}
let current: Vec<(String, bool)> = {
let current: Vec<String> = {
let mut stmt =
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)>>>()?
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>>>()?
};
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) {
for lid in &current {
if !desired.contains(lid) {
conn.execute(
"DELETE FROM note_labels WHERE note_id = ?1 AND label_id = ?2 AND via_tag = 1",
params![note_id, lid],
)?;
}
}
// 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 {
for lid in &desired {
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(())
}
@@ -326,6 +267,10 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note
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 f.has_reminder == Some(true) {
sql.push_str(" AND remind_at IS NOT NULL");
}
@@ -413,62 +358,23 @@ pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Resu
[],
|r| r.get(0),
)?;
// 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();
conn.execute(
"INSERT INTO notes (id, body, color, position, created_at, updated_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?5, ?5, 1)",
params![id, input.body, input.color, position, ts],
)?;
if let Some(items) = &input.items {
for text in items {
body = derive::append_item(&body, text, false);
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],
)?;
}
}
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)?;
sync_tags(conn, &id, &input.body)?;
load_note(conn, &id)
}
/// 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 body: String =
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?;
@@ -485,12 +391,9 @@ 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 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)?;
}
// Snapshot the pre-edit body before changing it (version history).
if obj.contains_key("body") {
snapshot_revision(conn, id)?;
}
for (k, v) in obj {
@@ -501,7 +404,12 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
"UPDATE notes SET body = ?1 WHERE id = ?2",
params![body, id],
)?;
lift_and_sync_tags(conn, id, body)?;
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])?;
}
}
"pinned" => {
if let Some(b) = v.as_bool() {
@@ -600,32 +508,18 @@ 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 body = note_body(conn, id)?;
set_body(conn, id, derive::append_item(&body, text, false))
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)
}
pub fn update_item(
@@ -634,27 +528,29 @@ 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) {
body = derive::set_item_text(&body, index, text);
conn.execute(
"UPDATE checklist_items SET text = ?1 WHERE id = ?2 AND note_id = ?3",
params![text, item_id, id],
)?;
}
if let Some(checked) = changes.get("checked").and_then(Value::as_bool) {
body = derive::set_item_checked(&body, index, checked);
conn.execute(
"UPDATE checklist_items SET checked = ?1 WHERE id = ?2 AND note_id = ?3",
params![checked, item_id, id],
)?;
}
set_body(conn, id, body)
touch(conn, id)?;
load_note(conn, id)
}
pub fn delete_item(conn: &Connection, id: &str, item_id: &str) -> rusqlite::Result<Note> {
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))
conn.execute(
"DELETE FROM checklist_items WHERE id = ?1 AND note_id = ?2",
params![item_id, id],
)?;
touch(conn, id)?;
load_note(conn, id)
}
pub fn delete_attachment(conn: &Connection, id: &str, att_id: &str) -> rusqlite::Result<Note> {
@@ -772,7 +668,7 @@ pub fn restore_revision(conn: &Connection, id: &str, rev_id: &str) -> rusqlite::
"UPDATE notes SET body = ?1 WHERE id = ?2",
params![body, id],
)?;
lift_and_sync_tags(conn, id, &body)?;
sync_tags(conn, id, &body)?;
touch(conn, id)?;
load_note(conn, id)
}
@@ -820,57 +716,7 @@ pub fn create_label(conn: &Connection, name: &str) -> rusqlite::Result<Label> {
load_label(conn, &id)
}
/// Rename a label. Renaming ONTO a name another label already holds MERGES the two.
///
/// It cannot simply be an UPDATE: `idx_labels_name` is unique on `lower(name)`, so
/// the bare statement failed with a raw SQLite "UNIQUE constraint failed" that
/// reached the user as database internals. Merging is the operator's call, and it
/// is the reading that matches what a person means — typing an existing tag's name
/// onto this one says "these are the same thing."
///
/// THE OLDER ROW SURVIVES, and takes the new spelling. Older rather than "the one
/// that already held the name" because age is the property neither participant's
/// role can change: rename A→B and rename B→A must land on the same survivor, or
/// the result depends on which way round someone happened to type it. Ties (two
/// labels minted in the same millisecond) go to the incumbent, so the outcome is
/// still deterministic.
///
/// Matching is case-insensitive, agreeing with `find_or_create_label` — "Groceries"
/// finds "groceries", and the survivor ends up spelled the way the caller asked.
pub fn rename_label(conn: &Connection, id: &str, name: &str) -> rusqlite::Result<Label> {
let clash: Option<(String, String)> = conn
.query_row(
"SELECT id, created_at FROM labels WHERE lower(name) = lower(?1) AND id <> ?2",
params![name, id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
if let Some((other_id, other_created)) = clash {
let mine_created: String =
conn.query_row("SELECT created_at FROM labels WHERE id = ?1", [id], |r| {
r.get(0)
})?;
// `created_at` is RFC3339 to the millisecond with a `Z`, so it is fixed-width
// and lexicographic order IS chronological order — no parsing needed.
let (survivor, doomed) = if other_created <= mine_created {
(other_id, id.to_string())
} else {
(id.to_string(), other_id)
};
// Reuse the merge rather than re-implement it: it is the only place that
// knows to mark every affected NOTE dirty before the delete cascades the
// membership rows away, which is what makes the merge reach the server.
merge_labels(conn, &doomed, &survivor)?;
// The survivor may still carry the old spelling — it is the one that keeps
// existing, so it is the one that has to end up named what was asked for.
conn.execute(
"UPDATE labels SET name = ?1, updated_at = ?2, dirty = 1 WHERE id = ?3",
params![name, now(), survivor],
)?;
return load_label(conn, &survivor);
}
conn.execute(
"UPDATE labels SET name = ?1, updated_at = ?2, dirty = 1 WHERE id = ?3",
params![name, now(), id],
+7 -7
View File
@@ -1,4 +1,4 @@
//! HTTP transport to an Inkwell server.
//! 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,
@@ -151,8 +151,8 @@ fn unexpected_status(base_url: &str, status: StatusCode) -> String {
/// Ask a server who it is and whether we can sync with it.
///
/// `Err` means we never got a usable answer (bad address, unreachable, not an
/// Inkwell server). A server that answers but is *incompatible* comes back `Ok`
/// `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> {
@@ -170,12 +170,12 @@ pub async fn probe(raw_url: &str) -> Result<ProbeResult, String> {
return Err(unexpected_status(&base_url, status));
}
// Something answered 200 that isn't an Inkwell server (a router login page, a
// 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 Inkwell's configuration. \
"{base_url} responded, but not with ThoughtSync's configuration. \
Is that the right address?"
)
})?;
@@ -427,8 +427,8 @@ mod tests {
#[test]
fn urls_preserve_a_port_and_subpath() {
assert_eq!(
config_url("http://192.168.1.10:8000/inkwell"),
"http://192.168.1.10:8000/inkwell/api/config"
config_url("http://192.168.1.10:8000/thoughtsync"),
"http://192.168.1.10:8000/thoughtsync/api/config"
);
}
}
+14 -71
View File
@@ -17,28 +17,13 @@
//! `docs/sync.md` for the policy that governs when those numbers move.
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
/// The sync wire protocol this client speaks.
///
/// v4 (M315): `color` left the note. NOT a floor raise on either side — see the note
/// on [`MIN_SERVER_PROTOCOL_VERSION`].
pub const CLIENT_PROTOCOL_VERSION: u32 = 4;
pub const CLIENT_PROTOCOL_VERSION: u32 = 2;
/// 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;
pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 2;
/// Capabilities without which syncing is meaningless, so their absence BLOCKS the
/// link rather than degrading it.
@@ -146,7 +131,7 @@ pub fn evaluate(info: &ServerInfo) -> Compatibility {
return incompatible(
&format!(
"This server requires client protocol v{floor} or newer; this app \
speaks v{CLIENT_PROTOCOL_VERSION}. Update Inkwell."
speaks v{CLIENT_PROTOCOL_VERSION}. Update ThoughtSync."
),
true,
);
@@ -173,44 +158,16 @@ pub fn evaluate(info: &ServerInfo) -> Compatibility {
}
}
/// Who this client says it is, set once by the host application at startup.
///
/// THE CORE CANNOT KNOW THIS, and the value it used to invent was wrong twice. It
/// was `inkwell-desktop/{CARGO_PKG_VERSION}`, and this crate is compiled into
/// the desktop app AND the Android app — so every phone in the field announced
/// itself as a desktop. The version was worse: `CARGO_PKG_VERSION` here is the
/// version of the CORE crate, a number no build stamps and no user has ever seen,
/// while the thing a reader of that header wants is the app's own build (note 3127
/// §5 — with no version tags, the artifact's self-report is the only answer to
/// "which build is this?").
///
/// So the host names itself. `OnceLock` because identity is fixed for the life of
/// the process and a second caller should be ignored rather than race the first.
static CLIENT_AGENT: OnceLock<String> = OnceLock::new();
/// Name this client for the servers it talks to — `("inkwell-android", "2026.08.31.1204")`.
///
/// Call once at startup, before any sync. Calling twice is not an error and the
/// first name wins; not calling it at all is visible in the header rather than
/// silently plausible.
pub fn set_client_agent(name: &str, version: &str) {
let _ = CLIENT_AGENT.set(format!("{name}/{version}"));
}
/// Headers this client puts on every request to a linked server, so the server can
/// log or gate on client identity without a separate handshake round-trip.
pub fn client_headers() -> [(&'static str, String); 2] {
// `unidentified/unknown`, never a plausible default. Nothing reads this header
// today, which is exactly why a wrong value could sit in it for months: the
// first person to look at a server log is the first person who could catch it,
// and only if what they see is obviously a host that never introduced itself.
let agent = CLIENT_AGENT
.get()
.cloned()
.unwrap_or_else(|| "inkwell-unidentified/unknown".to_string());
let agent = format!("thoughtsync-desktop/{}", env!("CARGO_PKG_VERSION"));
[
("X-Inkwell-Client", agent),
("X-Inkwell-Protocol", CLIENT_PROTOCOL_VERSION.to_string()),
("X-ThoughtSync-Client", agent),
(
"X-ThoughtSync-Protocol",
CLIENT_PROTOCOL_VERSION.to_string(),
),
]
}
@@ -231,7 +188,7 @@ pub fn normalize_base_url(raw: &str) -> Option<String> {
let with_scheme = match trimmed.split_once("://") {
Some((scheme, rest)) => {
// Anything that isn't HTTP(S) (ftp://, file://, a stray "foo://") can't
// be an Inkwell server; reject rather than fail confusingly later.
// be a ThoughtSync server; reject rather than fail confusingly later.
let scheme = scheme.to_ascii_lowercase();
if scheme != "http" && scheme != "https" {
return None;
@@ -256,7 +213,7 @@ mod tests {
/// A server matching this client exactly, which each test then degrades.
fn current_server() -> ServerInfo {
ServerInfo {
site_name: Some("Inkwell".into()),
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),
@@ -279,7 +236,7 @@ mod tests {
fn server_without_protocol_fields_is_too_old() {
// A pre-M10.6 server: /api/config parses, but carries no protocol block.
let info = ServerInfo {
site_name: Some("Inkwell".into()),
site_name: Some("ThoughtSync".into()),
version: Some("0.0.9".into()),
..Default::default()
};
@@ -403,24 +360,10 @@ mod tests {
#[test]
fn client_headers_identify_app_and_protocol() {
// Sets the process-wide agent, which is why this test also owns the
// assertion about it: a second test calling `set_client_agent` would race
// this one for the OnceLock, and whichever lost would see the other's name.
// One test, both branches, in order.
assert!(
client_headers()[0].1.starts_with("inkwell-unidentified/"),
"a host that never introduced itself must say so"
);
set_client_agent("inkwell-test", "2026.08.31.1204");
let headers = client_headers();
assert_eq!(headers[0].0, "X-Inkwell-Client");
assert_eq!(headers[0].1, "inkwell-test/2026.08.31.1204");
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());
// First name wins — a second host cannot rename a running process.
set_client_agent("inkwell-impostor", "0");
assert_eq!(client_headers()[0].1, "inkwell-test/2026.08.31.1204");
}
#[test]
+1 -1
View File
@@ -1,4 +1,4 @@
//! Talking to an Inkwell server — entirely opt-in.
//! 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.
+76 -23
View File
@@ -240,12 +240,13 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
// `created_at` is deliberately absent from the UPDATE clause: a note's birth time
// never changes, and the server's copy is the same value anyway.
conn.execute(
"INSERT INTO notes (id, body, position, pinned, archived,
"INSERT INTO notes (id, body, color, 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)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 0)
ON CONFLICT(id) DO UPDATE SET
body = excluded.body,
color = excluded.color,
position = excluded.position,
pinned = excluded.pinned,
archived = excluded.archived,
@@ -259,6 +260,7 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
params![
note.id,
note.body,
note.color,
note.position,
note.pinned,
note.archived,
@@ -275,12 +277,34 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
// 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_items(conn, note)?;
replace_attachments(conn, note)?;
replace_previews(conn, note)?;
replace_labels(conn, note)?;
Ok(())
}
fn replace_items(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
conn.execute(
"DELETE FROM checklist_items WHERE note_id = ?1",
params![note.id],
)?;
for (index, item) in note.items.iter().enumerate() {
conn.execute(
"INSERT INTO checklist_items (id, note_id, text, checked, position)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
item.id,
note.id,
item.text,
item.checked,
position_of(item.position, index)
],
)?;
}
Ok(())
}
fn replace_attachments(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
conn.execute(
"DELETE FROM attachments WHERE note_id = ?1",
@@ -369,6 +393,16 @@ fn ensure_label_stub(conn: &Connection, label: &wire::NoteLabel) -> rusqlite::Re
Ok(())
}
/// Trust an explicit position; fall back to arrival order when the server sent 0 for
/// everything (which is what an unordered list looks like on the wire).
fn position_of(explicit: i64, index: usize) -> i64 {
if explicit > 0 {
explicit
} else {
index as i64
}
}
/// 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
@@ -461,6 +495,7 @@ mod tests {
wire::Note {
id: id.to_string(),
body: "Body".into(),
color: "default".into(),
position: 0,
pinned: false,
archived: false,
@@ -473,22 +508,12 @@ mod tests {
sync_revision: revision,
purged_at: None,
labels: vec![],
items: 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,
@@ -587,20 +612,35 @@ mod tests {
#[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")];
first.items = vec![
wire::Item {
id: "i1".into(),
text: "one".into(),
checked: false,
position: 0,
},
wire::Item {
id: "i2".into(),
text: "two".into(),
checked: false,
position: 1,
},
];
apply_page(&conn, &page(vec![first], vec![], 1)).expect("apply");
assert_eq!(count(&conn, "SELECT COUNT(*) FROM attachments"), 2);
assert_eq!(count(&conn, "SELECT COUNT(*) FROM checklist_items"), 2);
// The server dropped an item; the local copy must drop it too.
let mut second = note("n1", 2);
second.attachments = vec![attachment("a1")];
second.items = vec![wire::Item {
id: "i1".into(),
text: "one".into(),
checked: true,
position: 0,
}];
apply_page(&conn, &page(vec![second], vec![], 2)).expect("apply");
assert_eq!(count(&conn, "SELECT COUNT(*) FROM attachments"), 1);
assert_eq!(count(&conn, "SELECT COUNT(*) FROM checklist_items"), 1);
}
#[test]
@@ -770,10 +810,23 @@ mod tests {
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.
// checklist-item id inside one page.
let conn = db();
let mut n = note("n1", 3);
n.attachments = vec![attachment("dup"), attachment("dup")];
n.items = vec![
wire::Item {
id: "dup".into(),
text: "one".into(),
checked: false,
position: 0,
},
wire::Item {
id: "dup".into(),
text: "two".into(),
checked: false,
position: 1,
},
];
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);
+38 -15
View File
@@ -64,8 +64,6 @@ pub struct Change {
#[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")]
@@ -81,6 +79,8 @@ pub struct Change {
#[serde(skip_serializing_if = "Option::is_none")]
pub position: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub items: Option<Vec<ItemOut>>,
#[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>,
@@ -103,6 +103,7 @@ impl Change {
remind_at: None,
recurrence: None,
position: None,
items: None,
label_ids: None,
created_at: None,
name: None,
@@ -110,6 +111,12 @@ impl Change {
}
}
#[derive(Debug, Serialize)]
pub struct ItemOut {
pub text: String,
pub checked: bool,
}
// --- incoming results --------------------------------------------------------
#[derive(Debug, Deserialize)]
@@ -194,6 +201,7 @@ fn collect_labels(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rus
remind_at: None,
recurrence: None,
position: None,
items: None,
label_ids: None,
created_at: None,
})
@@ -222,6 +230,7 @@ fn collect_notes(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rusq
/// field-to-column mapping stays readable at the call site.
struct NoteRow {
body: String,
color: String,
position: i64,
pinned: bool,
archived: bool,
@@ -234,21 +243,22 @@ struct NoteRow {
fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
conn.query_row(
"SELECT body, position, pinned, archived, trashed,
"SELECT body, color, 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)?,
color: r.get(1)?,
position: r.get(2)?,
pinned: r.get::<_, i64>(3)? != 0,
archived: r.get::<_, i64>(4)? != 0,
trashed: r.get::<_, i64>(5)? != 0,
remind_at: r.get(6)?,
recurrence: r.get(7)?,
created_at: r.get(8)?,
updated_at: r.get(9)?,
})
},
)
@@ -257,6 +267,19 @@ fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> {
let row = note_row(conn, id)?;
let items = {
let mut stmt = conn.prepare(
"SELECT text, checked FROM checklist_items WHERE note_id = ?1 ORDER BY position",
)?;
let rows = stmt.query_map(params![id], |r| {
Ok(ItemOut {
text: r.get(0)?,
checked: r.get::<_, i64>(1)? != 0,
})
})?;
rows.collect::<rusqlite::Result<Vec<ItemOut>>>()?
};
// 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.
@@ -275,14 +298,14 @@ fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> {
// 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,
color: Some(row.color),
pinned: Some(row.pinned),
archived: Some(row.archived),
trashed: Some(row.trashed),
remind_at: row.remind_at,
recurrence: row.recurrence,
position: Some(row.position),
items: Some(items),
label_ids: Some(label_ids),
created_at: Some(row.created_at),
name: None,
@@ -498,9 +521,9 @@ mod tests {
fn seed_note(conn: &Connection, id: &str, dirty: i64) {
conn.execute(
"INSERT INTO notes (id, body, position, pinned, archived,
"INSERT INTO notes (id, body, color, position, pinned, archived,
trashed, created_at, updated_at, sync_revision, dirty)
VALUES (?1, 'B', 0, 0, 0, 0,
VALUES (?1, 'B', 'default', 0, 0, 0, 0,
'2026-07-26T00:00:00.000Z', '2026-07-26T00:00:00.000Z', 3, ?2)",
params![id, dirty],
)
+15
View File
@@ -25,6 +25,8 @@ pub struct Note {
pub id: String,
#[serde(default)]
pub body: String,
#[serde(default = "default_color")]
pub color: String,
#[serde(default)]
pub position: i64,
#[serde(default)]
@@ -56,6 +58,8 @@ pub struct Note {
#[serde(default)]
pub labels: Vec<NoteLabel>,
#[serde(default)]
pub items: Vec<Item>,
#[serde(default)]
pub attachments: Vec<Attachment>,
#[serde(default)]
pub previews: Vec<Preview>,
@@ -83,6 +87,17 @@ pub struct NoteLabel {
pub via_tag: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Item {
pub id: String,
#[serde(default)]
pub text: String,
#[serde(default)]
pub checked: bool,
#[serde(default)]
pub position: i64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Attachment {
pub id: String,
+6 -7
View File
@@ -1,9 +1,9 @@
# Inkwell desktop (Tauri v2)
# ThoughtSync desktop (Tauri v2)
Local-first desktop client. The window loads the shared **Vue 3 frontend** from
`../frontend`; the Rust core (`src-tauri`) owns the on-device store and the opt-in
sync engine (built out across the M10 milestone). Works fully offline; optionally
syncs to a self-hosted Inkwell server.
syncs to a self-hosted ThoughtSync server.
## Layout
@@ -45,14 +45,13 @@ cd desktop/src-tauri && cargo tauri dev
## Build (Linux)
```sh
cargo tauri icon "$(git rev-parse --show-toplevel)/frontend/public/icon.svg"
cd desktop/src-tauri && cargo tauri build # -> .deb + .AppImage
```
App icons are **generated and committed**: `python3 packaging/icons.py` draws the
inkwell mark once and renders every web, desktop and Android icon from it,
including `app-icon.png` and `icons/*.png` here. The Windows lane derives its
`.ico` from `app-icon.png` with `cargo tauri icon` at build time. Bundle targets:
`deb`, `appimage` (Linux-first; Windows/macOS later, no code changes expected).
App icons are **generated** from the frontend's `icon.svg` via `cargo tauri icon`
(not committed; CI does this before `cargo tauri build`). Bundle targets: `deb`,
`appimage` (Linux-first; Windows/macOS later, no code changes expected).
## Status
+15 -16
View File
@@ -1,6 +1,6 @@
# Inkwell desktop — Arch package
# ThoughtSync desktop — Arch package
A **prebuilt** native pacman package, published as an asset on every Inkwell
A **prebuilt** native pacman package, published as an asset on every ThoughtSync
release. Nothing to compile, no toolchain to install.
Installing natively on Arch matters for more than tidiness: pacman pulls
@@ -15,22 +15,22 @@ Easiest — the one-command installer picks this package automatically on any
pacman system:
```sh
curl -fsSL https://git.fabledsword.com/bvandeusen/inkwell/raw/branch/dev/desktop/packaging/install.sh | sh
curl -fsSL https://git.fabledsword.com/bvandeusen/thoughtsync/raw/branch/dev/desktop/packaging/install.sh | sh
```
That installs the newest build from `main`. To follow the rolling development
That installs the newest tagged release. To follow the rolling development
channel instead, pass the flag through the pipe:
```sh
curl -fsSL https://git.fabledsword.com/bvandeusen/inkwell/raw/branch/dev/desktop/packaging/install.sh | sh -s -- --channel dev
curl -fsSL https://git.fabledsword.com/bvandeusen/thoughtsync/raw/branch/dev/desktop/packaging/install.sh | sh -s -- --channel dev
```
Or grab the `.pkg.tar.*` from the
[releases page](https://git.fabledsword.com/bvandeusen/inkwell/releases)
[releases page](https://git.fabledsword.com/bvandeusen/thoughtsync/releases)
and install it directly:
```sh
sudo pacman -U inkwell-*-x86_64.pkg.tar.*
sudo pacman -U thoughtsync-*-x86_64.pkg.tar.*
```
The compression suffix depends on what the build image provides — `.zst` when
@@ -39,18 +39,17 @@ all of them; only the filename differs.
Either way you get:
- `/usr/bin/inkwell` — the app
- `/usr/share/applications/inkwell.desktop` — the menu entry
- `/usr/share/icons/hicolor/*/apps/inkwell.png` — themed icons
- `/usr/bin/thoughtsync` — the app
- `/usr/share/applications/thoughtsync.desktop` — the menu entry
- `/usr/share/icons/hicolor/*/apps/thoughtsync.png` — themed icons
Launch **Inkwell** from your app menu, or run `inkwell`.
Launch **ThoughtSync** from your app menu, or run `thoughtsync`.
Uninstall: `sudo pacman -R inkwell`.
Uninstall: `sudo pacman -R thoughtsync`.
The package was called `thoughtsync` until the app was renamed Inkwell, and
`thoughtsync-desktop` before that. It declares `replaces`/`conflicts` on both
names, so an upgrade from either is a normal `pacman -U` and leaves nothing
behind.
The package was called `thoughtsync-desktop` before; it declares `replaces`/
`conflicts` on that name, so an upgrade from it is a normal `pacman -U` and
leaves nothing behind.
## How the package is built
+20 -23
View File
@@ -29,20 +29,19 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
SRC_TAURI="$REPO_ROOT/desktop/src-tauri"
BINARY="$REPO_ROOT/target/release/inkwell"
BINARY="$REPO_ROOT/target/release/thoughtsync"
OUT_DIR="${1:-$REPO_ROOT/target/release/bundle/arch}"
PKGNAME="inkwell"
# The names this package used to ship under: `thoughtsync` until the app became
# Inkwell (Scribe note 5071), `thoughtsync-desktop` before that. pacman needs both
# conflict and replace to retire them: without them a `pacman -U` of the renamed
# package installs ALONGSIDE the old one (issue 2075).
REPLACES=(thoughtsync thoughtsync-desktop)
PKGNAME="thoughtsync"
# The name this package used to ship under. pacman needs both to retire it: without
# them a `pacman -U` of the renamed package installs ALONGSIDE the old one, and two
# packages both own /usr/bin/thoughtsync (issue 2075).
REPLACES=(thoughtsync-desktop)
PKGREL=1
PKGDESC="Inkwell desktop — local-first Keep-style thought capture"
URL="https://git.fabledsword.com/bvandeusen/inkwell"
PKGDESC="ThoughtSync desktop — local-first Keep-style thought capture"
URL="https://git.fabledsword.com/bvandeusen/thoughtsync"
LICENSE="MIT"
PACKAGER="Inkwell CI <noreply@fabledsword.com>"
PACKAGER="ThoughtSync CI <noreply@fabledsword.com>"
# Runtime deps. Deliberately minimal and grounded in what the binary actually
# links: tauri is built with features=[] (no tray -> no libayatana-appindicator)
@@ -65,9 +64,7 @@ DEPENDS=(webkit2gtk-4.1 gtk3)
# this?" question unanswerable.
# `|| true` so a miss falls through to the explicit error below rather than
# aborting on pipefail with no explanation.
# The ORDERING KEY: pacman compares this, and it must match the filename the
# bundle build produced (write-manifest.sh selects on it).
PKGVER="$(sh "$SCRIPT_DIR/../../../packaging/version.sh" key desktop || true)"
PKGVER="$(sh "$SCRIPT_DIR/../build-version.sh" || true)"
[ -n "$PKGVER" ] || { echo "ERROR: could not determine the build version" >&2; exit 1; }
# Reproducible-ish: prefer the commit date over "now" so rebuilding the same
@@ -80,25 +77,25 @@ STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT INT TERM
# --- lay out the filesystem tree --------------------------------------------
# /usr/bin/inkwell — the same command name the .deb installs and the AppImage
# /usr/bin/thoughtsync — the same command name the .deb installs and the AppImage
# installer symlinks into ~/.local/bin, so it's identical whichever way the app
# arrived. The binary already carries this name (Cargo `[[bin]]`), which is also
# what the .desktop entry's StartupWMClass has to match.
install -Dm755 "$BINARY" "$STAGE/usr/bin/inkwell"
install -Dm644 "$SCRIPT_DIR/inkwell.desktop" \
"$STAGE/usr/share/applications/inkwell.desktop"
install -Dm755 "$BINARY" "$STAGE/usr/bin/thoughtsync"
install -Dm644 "$SCRIPT_DIR/thoughtsync.desktop" \
"$STAGE/usr/share/applications/thoughtsync.desktop"
# Themed icons, so the menu entry's `Icon=inkwell` resolves at every size the
# Themed icons, so the menu entry's `Icon=thoughtsync` resolves at every size the
# desktop asks for; pixmaps is the legacy fallback for older launchers.
install -Dm644 "$SRC_TAURI/icons/32x32.png" \
"$STAGE/usr/share/icons/hicolor/32x32/apps/inkwell.png"
"$STAGE/usr/share/icons/hicolor/32x32/apps/thoughtsync.png"
install -Dm644 "$SRC_TAURI/icons/128x128.png" \
"$STAGE/usr/share/icons/hicolor/128x128/apps/inkwell.png"
"$STAGE/usr/share/icons/hicolor/128x128/apps/thoughtsync.png"
install -Dm644 "$SRC_TAURI/icons/128x128@2x.png" \
"$STAGE/usr/share/icons/hicolor/256x256/apps/inkwell.png"
"$STAGE/usr/share/icons/hicolor/256x256/apps/thoughtsync.png"
install -Dm644 "$SRC_TAURI/icons/icon.png" \
"$STAGE/usr/share/icons/hicolor/512x512/apps/inkwell.png"
install -Dm644 "$SRC_TAURI/icons/icon.png" "$STAGE/usr/share/pixmaps/inkwell.png"
"$STAGE/usr/share/icons/hicolor/512x512/apps/thoughtsync.png"
install -Dm644 "$SRC_TAURI/icons/icon.png" "$STAGE/usr/share/pixmaps/thoughtsync.png"
# Installed size in KiB, as pacman reports it.
INSTALLED_SIZE="$(du -sb "$STAGE" | cut -f1)"
@@ -3,10 +3,10 @@
# grouping. Tauri writes the same value into the .deb's generated entry.
[Desktop Entry]
Type=Application
Name=Inkwell
Name=ThoughtSync
Comment=Capture a fleeting thought in a second
Exec=inkwell %U
Icon=inkwell
Exec=thoughtsync %U
Icon=thoughtsync
Terminal=false
Categories=Utility;Office;
StartupWMClass=inkwell
StartupWMClass=thoughtsync
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env sh
#
# Echo the version this build should carry. One definition, used in three places
# (both bundle jobs and the manifest writer) — if they ever disagreed, the app would
# compare its own version against a manifest describing a different build, and the
# updater would either offer nothing or loop forever offering the same thing.
#
# WHY DEV BUILDS NEED THEIR OWN VERSION AT ALL:
# an updater decides by comparing semver. Every dev build carries the version in
# Cargo.toml, so without this they'd all be `0.1.0` — an installed build would see a
# manifest advertising the version it already has, conclude it was current, and never
# update. The rolling channel needs a number that actually rises.
#
# The CI run number is that number: monotonic, already unique per build, and it needs
# no state carried between runs. `0.1.0` + run 2932 becomes `0.1.2932`.
#
# Plain semver on purpose, NOT a `-dev.N` prerelease tag: prerelease versions sort
# BELOW the release they qualify (`0.1.0-dev.5` < `0.1.0`), so a tagged build would
# never update to a newer dev one, and Windows installer metadata wants a numeric
# X.Y.Z anyway. 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.
set -eu
CARGO_TOML="$(dirname "$0")/../src-tauri/Cargo.toml"
base="$(grep -m1 '^version' "$CARGO_TOML" | sed -E 's/.*"([^"]+)".*/\1/')"
# Dev builds only. Anything else (a v* tag, main) ships the version as written.
if [ "${GITHUB_REF_NAME:-}" = "dev" ] && [ -n "${GITHUB_RUN_NUMBER:-}" ]; then
printf '%s.%s\n' "${base%.*}" "$GITHUB_RUN_NUMBER"
else
printf '%s\n' "$base"
fi
+8 -13
View File
@@ -13,7 +13,7 @@
# Five checks, cheapest first:
# 1. Print the control file + contents — the generated metadata becomes ground
# truth in the build log instead of an assumption.
# 2. Naming: the binary is /usr/bin/inkwell and the generated .desktop
# 2. Naming: the binary is /usr/bin/thoughtsync and the generated .desktop
# entry's StartupWMClass matches it. The app used to identify itself three
# different ways depending on install channel (issue 2075); this is what
# keeps the .deb — the only channel whose entry Tauri generates for us —
@@ -72,17 +72,12 @@ echo " (binary: ${BIN#"$WORK/root"})"
# CANON is the one name the app answers to everywhere: the binary, the CLI
# command, the icon, and the WM_CLASS the window reports. Hardcoded here on
# purpose — this literal IS the contract the three install channels are held to.
#
# The control file's `Package:` field is held to it too. tauri-bundler derives it
# as kebab-case(productName) with no override, which split "ThoughtSync" into
# `thought-sync` (issue 2075). "Inkwell" has no hump to split, so the package and
# the command finally agree, and a productName that brought the split back would
# also break the `conflicts`/`replaces` retiring the old package.
CANON="inkwell"
package="$(dpkg-deb -f "$DEB" Package 2>/dev/null || true)"
[ "$package" = "$CANON" ] ||
note_fail "the package is named '${package:-<unset>}', expected '$CANON' (kebab-case of productName)."
# Deliberately NOT asserted: the control file's `Package:` field, which is
# `thought-sync`. tauri-bundler derives it as kebab-case(productName) with no
# config override, so "ThoughtSync" splits at the hump. Fixing it would mean
# unpacking and rewriting the control archive on every build — a fragile step for
# a cosmetic gain on one uninstall command. Left as a known wart (issue 2075).
CANON="thoughtsync"
installed_bin="${BIN#"$WORK/root"}"
[ "$installed_bin" = "/usr/bin/$CANON" ] ||
@@ -112,7 +107,7 @@ if command -v dpkg-shlibdeps >/dev/null 2>&1; then
# dpkg-shlibdeps insists on a debian/control in the working directory even with
# -O (write to stdout); a stub is enough to let it do the ELF analysis.
mkdir -p "$WORK/deb-stub/debian"
printf 'Source: inkwell\n\nPackage: inkwell\nArchitecture: amd64\n' \
printf 'Source: thoughtsync\n\nPackage: thoughtsync\nArchitecture: amd64\n' \
>"$WORK/deb-stub/debian/control"
# `|| true`: pipefail is on and dpkg-shlibdeps still exits non-zero on some
# symbol warnings even with --ignore-missing-info. An empty result degrades to
+58 -51
View File
@@ -1,19 +1,12 @@
#!/bin/sh
#
# Inkwell desktop — one-command Linux installer.
# ThoughtSync desktop — one-command Linux installer.
#
# curl -fsSL https://git.fabledsword.com/bvandeusen/inkwell/raw/branch/dev/desktop/packaging/install.sh | sh
# curl -fsSL https://git.fabledsword.com/bvandeusen/thoughtsync/raw/branch/dev/desktop/packaging/install.sh | sh
#
# Two channels, the SAME two the app's own updater offers (src-tauri/src/update.rs):
# stable (default) — the rolling build from every merge to `main`.
# stable (default) — the newest tagged v* release.
# dev — the rolling build from every green push to `dev`.
# Both are fixed-tag releases: the tag never moves and the assets are pruned to the
# current build, so the tag alone names the newest one. `stable` only became one in
# M314 step 3, when `main` started publishing — before that it was a manifest-only
# pointer at whatever `v*` tag somebody had last cut, and this script carried a
# fallback that chased the `v*` release its manifest named. That came out once
# `main` had published to `stable` for real (`b6673c6`); the two channels are the
# same shape now and nothing here should special-case one of them again.
# Pick one with `--channel dev` or `TS_CHANNEL=dev`. Through a pipe the options go
# after a `--`: curl -fsSL <url> | sh -s -- --channel dev
#
@@ -35,7 +28,7 @@
set -eu
INSTANCE="https://git.fabledsword.com"
REPO="bvandeusen/inkwell"
REPO="bvandeusen/thoughtsync"
API="$INSTANCE/api/v1/repos/$REPO"
say() { printf '==> %s\n' "$1"; }
@@ -44,11 +37,11 @@ have() { command -v "$1" >/dev/null 2>&1; }
usage() {
cat <<'USAGE'
Inkwell desktop installer.
ThoughtSync desktop installer.
install.sh [--channel stable|dev]
--channel stable newest build from main (default)
--channel stable newest tagged release (default)
--channel dev rolling build from the latest green push to `dev`
-h, --help this text
@@ -83,27 +76,41 @@ have curl || die "curl is required."
arch="$(uname -m)"
case "$arch" in
x86_64 | amd64) : ;;
*) die "Inkwell ships x86_64 Linux builds only right now (this machine: $arch)." ;;
*) die "ThoughtSync ships x86_64 Linux builds only right now (this machine: $arch)." ;;
esac
# --- resolve the release for this channel -----------------------------------
say "Finding the latest Inkwell build on the $channel channel…"
say "Finding the latest ThoughtSync build on the $channel channel…"
# ONE lookup, both channels. Each is a release whose tag never moves and whose assets
# are pruned to the current build, so the tag alone names the newest build on that
# channel — which is exactly what an installer wants and what the in-app updater
# already reads.
# The channel's RELEASE TAG, which is not always its name: `dev` publishes on the
# tag `dev-rolling`, because a tag called `dev` shadowed the branch of the same name
# and broke `git push origin dev` (Scribe #2184). Same mapping as
# packaging/channel-tag.sh — inlined because this script is fetched alone through a
# pipe and has nothing to source.
case "$channel" in
dev) tag=dev-rolling ;;
*) tag="$channel" ;;
esac
json="$(curl -fsSL "$API/releases/tags/$tag" 2>/dev/null)" ||
die "the $channel channel has nothing published yet."
if [ "$channel" = "dev" ]; then
# A release whose tag never moves and whose assets are pruned to the current
# build — so the tag alone always names the newest dev build.
json="$(curl -fsSL "$API/releases/tags/dev" 2>/dev/null)" ||
die "the dev channel has nothing published yet."
else
# Ask the stable channel's own manifest which version is current, then install
# THAT release. This is the same file the in-app updater reads, so the installer
# and the updater can never disagree about what `stable` means.
#
# Not `/releases/latest`: that returns the newest non-prerelease release by date,
# and the `stable` pointer release (manifest only, no bundles — see
# write-manifest.sh) is itself a non-prerelease created moments after the
# versioned one. It would win, and it carries nothing installable.
manifest="$(curl -fsSL "$INSTANCE/$REPO/releases/download/stable/latest.json" 2>/dev/null || true)"
stable_version="$(printf '%s' "$manifest" |
grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 |
sed -E 's/.*"([^"]+)"$/\1/')"
if [ -n "$stable_version" ]; then
json="$(curl -fsSL "$API/releases/tags/v$stable_version" 2>/dev/null)" ||
die "the stable channel names $stable_version, but there is no v$stable_version release to install."
else
# No stable pointer yet — the channel predates the updater. Fall back to the
# newest non-prerelease release, which is what stable meant before there was
# a manifest to ask.
json="$(curl -fsSL "$API/releases/latest" 2>/dev/null)" ||
die "no stable release published yet — try --channel dev, or ask the maintainer to tag one."
fi
fi
# Pull asset URLs straight out of the release JSON (no jq). Anchored on the closing
# quote so a `…AppImage.sig` URL can't be truncated into a match of its own.
@@ -131,10 +138,10 @@ trap 'rm -rf "$tmp"' EXIT INT TERM
# The app reads it at startup (src-tauri/src/update.rs, INSTALL_MARKER) and only acts
# when the value CHANGED, so switching channel in the app isn't undone on next launch.
#
# The directory is Tauri's app-data dir for identifier com.fabledsword.inkwell;
# The directory is Tauri's app-data dir for identifier com.fabledsword.thoughtsync;
# both sides hardcode it, so a change to the identifier has to change both.
record_channel() {
marker_dir="${XDG_DATA_HOME:-$HOME/.local/share}/com.fabledsword.inkwell"
marker_dir="${XDG_DATA_HOME:-$HOME/.local/share}/com.fabledsword.thoughtsync"
# Best-effort: a failure here costs the channel setting, not the install, and a
# native install run as root would only be writing into root's home anyway.
mkdir -p "$marker_dir" 2>/dev/null && printf '%s\n' "$channel" > "$marker_dir/install-channel" 2>/dev/null || true
@@ -176,7 +183,7 @@ if have pacman && [ -n "$pkg_url" ]; then
need_root
$sudo pacman -U --noconfirm "$pkg_file"
record_channel
say "Done. Launch Inkwell from your application menu, or run inkwell."
say "Done. Launch ThoughtSync from your application menu, or run thoughtsync."
native_update_note
exit 0
fi
@@ -184,33 +191,33 @@ fi
# --- native .deb path (Debian/Ubuntu) ---------------------------------------
if have dpkg && have apt-get && [ -n "$deb_url" ]; then
say "Debian-family system detected — installing the native .deb"
curl -fSL -o "$tmp/inkwell.deb" "$deb_url"
curl -fSL -o "$tmp/thoughtsync.deb" "$deb_url"
need_root
# apt-get resolves the .deb's dependencies (webkit2gtk etc.). dpkg is the
# fallback if this apt is too old for local-file installs — it leaves the deps
# unconfigured, so `apt-get -f install` is what actually completes that path.
$sudo apt-get install -y "$tmp/inkwell.deb" ||
{ $sudo dpkg -i "$tmp/inkwell.deb" || true; $sudo apt-get -f install -y; }
$sudo apt-get install -y "$tmp/thoughtsync.deb" ||
{ $sudo dpkg -i "$tmp/thoughtsync.deb" || true; $sudo apt-get -f install -y; }
record_channel
say "Done. Launch Inkwell from your application menu."
say "Done. Launch ThoughtSync from your application menu."
native_update_note
exit 0
fi
# --- universal AppImage path (user-local, no sudo) --------------------------
# Install into ~/Applications/Inkwell.AppImage — the SAME location the app's
# Install into ~/Applications/ThoughtSync.AppImage — the SAME location the app's
# own self-integration uses (src/integration.rs) — so the running app sees
# itself already installed and never makes a second copy or menu entry.
say "Installing the de-bundled AppImage (user-local, no sudo)"
[ -n "$appimage_url" ] || die "the $channel release (${version:-unknown}) has no AppImage asset."
apps_dir="$HOME/Applications"
dest="$apps_dir/Inkwell.AppImage"
dest="$apps_dir/ThoughtSync.AppImage"
mkdir -p "$apps_dir"
say "Downloading $(basename "$appimage_url")…"
curl -fSL -o "$tmp/Inkwell.AppImage" "$appimage_url"
chmod +x "$tmp/Inkwell.AppImage"
mv -f "$tmp/Inkwell.AppImage" "$dest"
curl -fSL -o "$tmp/ThoughtSync.AppImage" "$appimage_url"
chmod +x "$tmp/ThoughtSync.AppImage"
mv -f "$tmp/ThoughtSync.AppImage" "$dest"
# Menu entry — written to match integration.rs verbatim (same paths + fields),
# so the app reports is_integrated=true and won't duplicate it.
@@ -222,39 +229,39 @@ mkdir -p "$apps_menu" "$icons_dir"
# entry looks right immediately. Extraction is a non-GUI unsquash (no FUSE, no
# black-window risk); if it fails we fall back to the themed name and the app
# writes its embedded icon on first launch anyway.
icon_ref="inkwell"
icon_ref="thoughtsync"
if ( cd "$tmp" && "$dest" --appimage-extract .DirIcon >/dev/null 2>&1 ) \
&& cp -L "$tmp/squashfs-root/.DirIcon" "$icons_dir/inkwell.png" 2>/dev/null; then
icon_ref="$icons_dir/inkwell.png"
&& cp -L "$tmp/squashfs-root/.DirIcon" "$icons_dir/thoughtsync.png" 2>/dev/null; then
icon_ref="$icons_dir/thoughtsync.png"
fi
rm -rf "$tmp/squashfs-root" 2>/dev/null || true
# StartupWMClass is the BINARY name, not the product name and not the AppImage
# filename: the AppImage's AppRun execs usr/bin/inkwell, and GTK derives
# filename: the AppImage's AppRun execs usr/bin/thoughtsync, and GTK derives
# WM_CLASS from whatever it ends up running. Anything else here means the window
# never associates with this entry and the taskbar shows a second, generic icon.
cat > "$apps_menu/inkwell.desktop" <<EOF
cat > "$apps_menu/thoughtsync.desktop" <<EOF
[Desktop Entry]
Type=Application
Name=Inkwell
Name=ThoughtSync
Comment=Capture a fleeting thought in a second
Exec=$dest %U
Icon=$icon_ref
Terminal=false
Categories=Utility;Office;
StartupWMClass=inkwell
StartupWMClass=thoughtsync
EOF
have update-desktop-database && update-desktop-database "$apps_menu" >/dev/null 2>&1 || true
# Convenience CLI launcher.
mkdir -p "$HOME/.local/bin"
ln -sf "$dest" "$HOME/.local/bin/inkwell"
ln -sf "$dest" "$HOME/.local/bin/thoughtsync"
record_channel
say "Installed to $dest"
printf ' Launch it from your application menu, or run \033[1minkwell\033[0m'
printf ' Launch it from your application menu, or run \033[1mthoughtsync\033[0m'
printf ' (if ~/.local/bin is on your PATH).\n'
# This is the one path where the app can update itself, so say what it will follow.
if [ "$channel" = "dev" ]; then

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