Checklists in the body, colour from tags, and commit-derived CalVer #4

Merged
bvandeusen merged 73 commits from dev into main 2026-08-29 13:39:45 -04:00
93 changed files with 7365 additions and 2122 deletions
+75 -25
View File
@@ -19,15 +19,9 @@ name: Android
on: on:
push: 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] 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: workflow_dispatch:
concurrency: concurrency:
@@ -42,8 +36,46 @@ env:
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED" JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
jobs: jobs:
# Does the APK need rebuilding, or is the channel already serving this source?
# 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: build:
name: Kotlin + Rust (APK) 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 # runs-on is only a scheduling label (Label Model B). flutter-ci is the
# proven-working label that can pull our container images. # proven-working label that can pull our container images.
runs-on: flutter-ci runs-on: flutter-ci
@@ -63,6 +95,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - 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 - name: Cache Gradle and Cargo
uses: actions/cache@v4 uses: actions/cache@v4
@@ -85,13 +121,17 @@ jobs:
env: env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: | run: |
version="$(sh ../desktop/packaging/build-version.sh)" # TWO CLOCKS, ON PURPOSE (note 3127 §2). The NAME answers "is this the
# same code?", so it comes from the COMMIT and a dev build and the main
# build of one commit read identically. The CODE answers "may this be
# installed over that?" and must be monotonic BY CONSTRUCTION, because
# Android hard-fails a downgrade with INSTALL_FAILED_VERSION_DOWNGRADE and
# leaves a channel you cannot get out of — so it comes from BUILD time,
# which cannot go backwards. Commit time can.
version="$(sh ../packaging/version.sh display android)"
code="$(sh ../packaging/version.sh key android)"
echo "name=$version" >> $GITHUB_OUTPUT echo "name=$version" >> $GITHUB_OUTPUT
# versionCode must RISE for Android to accept an update, and the run echo "code=$code" >> $GITHUB_OUTPUT
# 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 if [ -n "${ANDROID_KEYSTORE_BASE64:-}" ]; then
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/thoughtsync-release.jks printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/thoughtsync-release.jks
@@ -105,7 +145,7 @@ jobs:
echo "profile=debug" >> $GITHUB_OUTPUT echo "profile=debug" >> $GITHUB_OUTPUT
echo "keystore=/tmp/thoughtsync-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 "apk=android/app/build/outputs/apk/release/app-release.apk" >> $GITHUB_OUTPUT
echo "Signed release build — $version (versionCode $GITHUB_RUN_NUMBER)" echo "Signed release build — $version (versionCode $code)"
else 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 "::warning::No ANDROID_KEYSTORE_BASE64 secret. Building an UNSIGNED DEBUG APK: it cannot be installed over a signed build and cannot self-update."
echo "variant=Debug" >> $GITHUB_OUTPUT echo "variant=Debug" >> $GITHUB_OUTPUT
@@ -199,19 +239,29 @@ jobs:
JSON JSON
cat dist/thoughtsync-android.json cat dist/thoughtsync-android.json
# The rolling dev channel, same fixed-tag release the desktop bundles use. # The rolling channel for this branch, the same fixed-tag releases the desktop
# CI artifacts are per-run and auth-gated, so they are no use as a fetch # bundles use. CI artifacts are per-run and auth-gated, so they are no use as a
# target; a release asset has a permanent URL. Only ever a SIGNED build — # fetch target; a release asset has a permanent URL. Only ever a SIGNED build —
# publishing an unsigned APK would offer people something they cannot # publishing an unsigned APK would offer people something they cannot install
# install over what they already have. # over what they already have.
- name: Publish to the dev channel #
if: github.ref == 'refs/heads/dev' && steps.build.outputs.keystore != '' # `stable` from main is new in M314 step 3, and it is what lets the server image
# bake in a client that matches its own channel: a :latest image fetches the APK
# from `stable`, a :dev image from `dev`. Before this, main published no APK at
# all and every image — stable included — baked in the dev one.
- name: Publish to the channel for this branch
if: (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && steps.build.outputs.keystore != ''
working-directory: . working-directory: .
env: env:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
RELEASE_TAG: dev run: |
RELEASE_PRERELEASE: "true" case "$GITHUB_REF_NAME" in
run: bash desktop/packaging/publish-release.sh main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
esac
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing the APK to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh
- name: Upload the APK - name: Upload the APK
# Mirrored action, never actions/upload-artifact. @v4+ throws # Mirrored action, never actions/upload-artifact. @v4+ throws
+82 -53
View File
@@ -1,12 +1,21 @@
# CI runs first; build only proceeds if lint + typecheck pass. # CI runs first; build only proceeds if lint + typecheck pass.
# #
# Push to dev: typecheck + lint + test + build :dev + :<sha> # Push to dev: typecheck + lint + test + build :dev
# Push to main: typecheck + lint + test + build :latest + :<sha> # Push to main: typecheck + lint + test + build :latest + :<sha>
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
# #
# main is the production line, so a merge to main rebuilds and moves :latest to its # THAT IS THE COMPLETE TAG SET (rule 145). No version-shaped image tag in any lane:
# tip (family rule 46) — no version release required. The :<sha> image is the # nothing pins one — verified by looking for a consumer, not for whether one is
# immutable rollback unit for every build. # imaginable — and the git release tag is a different object in a different system
# (step 7). The image is addressed by CHANNEL or by COMMIT; the release by date.
#
# A `v*` tag builds nothing at all. The merge to main already published everything,
# so a tag rebuilding that same source would re-push :<sha> with different bytes,
# which rule 145 forbids even when they match.
#
# main is the production line, so a merge moves :latest to its tip (family rule 46)
# — no version release required. :<sha> is the immutable rollback unit, and it is
# on main ONLY: a sha tag per dev push is a rollback target nobody has ever pulled,
# accumulating forever, for a channel whose entire contract is that it moves.
# #
# Required secret (repo -> Settings -> Secrets -> Actions): # Required secret (repo -> Settings -> Secrets -> Actions):
# REGISTRY_TOKEN -- Forgejo PAT with write:packages scope # REGISTRY_TOKEN -- Forgejo PAT with write:packages scope
@@ -16,27 +25,29 @@ name: CI & Build
on: on:
push: 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] 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 # 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 # that bakes it in is built AFTER the APK exists rather than racing it. See the
# `gate` job below for the other half. # `gate` job below for the other half.
workflow_dispatch: workflow_dispatch:
# Cancel older runs on the same branch when a newer push lands. Tag runs get their # Cancel older runs on the same branch when a newer push lands.
# own group implicitly and are never cancelled.
concurrency: concurrency:
group: ci-${{ github.ref }} group: ci-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} cancel-in-progress: true
permissions: permissions:
contents: read contents: read
@@ -64,7 +75,7 @@ jobs:
# than a config so at least it is inspectable in the log. # than a config so at least it is inspectable in the log.
gate: gate:
name: Build now, or wait for Android? name: Build now, or wait for Android?
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci runs-on: python-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14 image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -89,17 +100,6 @@ jobs:
exit 0 exit 0
fi 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 # No parent (first commit, or a force-push that orphaned it) — nothing to
# compare, so build rather than stall. # compare, so build rather than stall.
if ! git rev-parse --verify -q HEAD^ >/dev/null; then if ! git rev-parse --verify -q HEAD^ >/dev/null; then
@@ -125,7 +125,12 @@ jobs:
echo "Changed in this push:" echo "Changed in this push:"
echo "$changed" | sed 's/^/ /' echo "$changed" | sed 's/^/ /'
if echo "$changed" | grep -qE '^(android/|core/|Cargo\.toml$|Cargo\.lock$|\.forgejo/workflows/android\.yml$)'; then # MUST match android's file set in packaging/version.sh. `packaging/` was
# missing here after step 4 added it there — so a packaging-only push had
# the Android lane rebuild and dispatch while this gate ALSO let the image
# build, producing two images for one commit and, on main, a second push of
# the same :<sha> with different bytes. Rule 145's exact prohibition.
if echo "$changed" | grep -qE '^(android/|core/|packaging/|Cargo\.toml$|Cargo\.lock$|\.forgejo/workflows/android\.yml$)'; then
echo "" echo ""
echo "This push also changes the Android client. Standing down: the" echo "This push also changes the Android client. Standing down: the"
echo "Android lane will publish a new APK and dispatch this workflow," echo "Android lane will publish a new APK and dispatch this workflow,"
@@ -140,7 +145,7 @@ jobs:
typecheck: typecheck:
name: TypeScript typecheck name: TypeScript typecheck
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci runs-on: python-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14 image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -157,7 +162,7 @@ jobs:
lint: lint:
name: Python lint name: Python lint
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci runs-on: python-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14 image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -170,7 +175,7 @@ jobs:
test: test:
name: Python tests name: Python tests
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci runs-on: python-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14 image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -193,15 +198,14 @@ jobs:
# them ever executed by CI — and the schema the migrations build had never been # them ever executed by CI — and the schema the migrations build had never been
# checked against the models that read it. # checked against the models that read it.
# #
# Runs for visibility and does NOT gate the build, matching the `test` lane and # Gates the build, along with every other lane — see the `build` job's `needs`.
# FabledScribe's equivalent job.
# #
# Job key stays separator-free ("integration") with no `name:` — rule 80. act_runner # 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 # derives the service-container name from the truncated job display name, and the
# discovery step below filters `docker ps` by it. Service hostnames are not routable # discovery step below filters `docker ps` by it. Service hostnames are not routable
# on this runner (rule 79), so the step resolves the container's bridge IP. # on this runner (rule 79), so the step resolves the container's bridge IP.
integration: integration:
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci runs-on: python-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14 image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -261,10 +265,16 @@ jobs:
build: build:
name: Build & push image name: Build & push image
# Build gates on lint + typecheck. The `test` job runs in parallel for # Every lane gates the build. This once stopped at lint + typecheck, on the
# visibility but does not block dev image builds (DB-backed integration # reasoning that DB-backed testing happened manually against the dev image
# testing happens against the dev image manually, not on every push). # rather than on every push — true until 6f21db8 added the integration lane,
needs: [gate, typecheck, lint] # and false since.
#
# What that gap cost: run 4293 failed `test` and published :dev and :<sha>
# anyway, so the deployed server ran a build whose test lane was red. An image
# tag is the rollback substrate (family rule 46); one that can be published
# from a failing run is not a substrate you can roll back TO.
needs: [gate, typecheck, lint, test, integration]
if: needs.gate.outputs.build == 'true' if: needs.gate.outputs.build == 'true'
runs-on: python-ci runs-on: python-ci
container: container:
@@ -274,27 +284,35 @@ jobs:
packages: write packages: write
steps: steps:
- uses: actions/checkout@v6 - 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 - name: Generate image tags and version
id: tags id: tags
# run: steps execute under busybox sh (family rule 81), so use POSIX `case`, # run: steps execute under busybox sh (family rule 81), so use POSIX `case`,
# NOT bash `[[ ]]`. # NOT bash `[[ ]]`.
run: | run: |
TAGS="${{ env.IMAGE }}:${{ github.sha }}" # The image's version is DERIVED from its own shipped files — including the
BUILD_VERSION="dev" # Android client it bakes in, which is why an APK-only change re-versions
# it. One value and no ordering key: nothing compares a server image, so
# §2 says do not invent one just because the other artifacts have one.
#
# This was a short sha on main and the literal "dev" elsewhere, which could
# not answer "how old is this instance?" — the question that actually gets
# asked of a self-hosted app running in several places.
BUILD_VERSION="$(sh packaging/version.sh display server)"
case "${{ github.ref }}" in case "${{ github.ref }}" in
refs/heads/dev) refs/heads/dev)
TAGS="$TAGS,${{ env.IMAGE }}:dev" TAGS="${{ env.IMAGE }}:dev"
;; ;;
refs/heads/main) refs/heads/main)
# Production line: :latest tracks main's tip (rule 46). No :main tag; TAGS="${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.sha }}"
# the :<sha> above is the rollback unit. Version label = short sha.
TAGS="$TAGS,${{ env.IMAGE }}:latest"
BUILD_VERSION="$(echo ${{ github.sha }} | cut -c1-7)"
;; ;;
refs/tags/*) *)
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}" echo "::error::This lane builds images for dev and main only."
BUILD_VERSION="${{ github.ref_name }}" exit 1
;; ;;
esac esac
echo "value=$TAGS" >> $GITHUB_OUTPUT echo "value=$TAGS" >> $GITHUB_OUTPUT
@@ -325,7 +343,18 @@ jobs:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
run: | run: |
mkdir -p client mkdir -p client
base="${{ github.server_url }}/${{ github.repository }}/releases/download/dev" # THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves the dev
# client; :latest serves the stable one. This read `download/dev`
# unconditionally until M314 step 3, on every branch — so every stable
# server shipped a dev-channel APK to anyone who downloaded the client
# from it. Not a versioning gap; a plain defect, fixed here because this
# is the step that gave `stable` an APK to point at.
case "${{ github.ref_name }}" in
main) channel=stable ;;
*) channel=dev ;;
esac
echo "Baking in the $channel client."
base="${{ github.server_url }}/${{ github.repository }}/releases/download/$channel"
ok=1 ok=1
for f in thoughtsync.apk thoughtsync-android.json; do for f in thoughtsync.apk thoughtsync-android.json; do
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "client/$f" "$base/$f" || ok=0 curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "client/$f" "$base/$f" || ok=0
+150 -85
View File
@@ -16,33 +16,20 @@ name: Desktop (Tauri)
on: on:
push: 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] 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: workflow_dispatch:
concurrency: concurrency:
group: desktop-${{ github.ref }} group: desktop-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} cancel-in-progress: true
permissions: permissions:
# write (not read) so the tag build can publish a Release with the bundles # write (not read) so the tag build can publish a Release with the bundles
@@ -51,9 +38,47 @@ permissions:
contents: write contents: write
jobs: 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: build:
name: Tauri desktop (Linux) name: Tauri desktop (Linux)
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') needs: [decide]
if: needs.decide.outputs.build == 'true'
runs-on: python-ci runs-on: python-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97 image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
@@ -64,6 +89,14 @@ jobs:
APPIMAGE_EXTRACT_AND_RUN: "1" APPIMAGE_EXTRACT_AND_RUN: "1"
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with:
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
# the lane green — note 3127 §6.1, and the direction you cannot recover
# from. `packaging/version.sh` fails loudly on an empty result rather than
# emitting something plausible, which is what turns this into a red lane
# if it is ever dropped.
fetch-depth: 0
# tauri's generate_context! embeds the built frontend at compile time, so the # tauri's generate_context! embeds the built frontend at compile time, so the
# frontend must exist before any cargo compile (clippy/test/build), not just # frontend must exist before any cargo compile (clippy/test/build), not just
@@ -123,8 +156,12 @@ jobs:
else else
echo "No TAURI_SIGNING_PRIVATE_KEY — building unsigned, no updater artifacts." echo "No TAURI_SIGNING_PRIVATE_KEY — building unsigned, no updater artifacts."
fi fi
version="$(sh ../packaging/build-version.sh)" # The ORDERING KEY, not the display version: this string is what Tauri's
echo "Building version $version" # updater parses as semver, and what it stamps into bundle FILENAMES that
# `write-manifest.sh` then selects on. The human-readable version is a
# separate value and arrives with the UI that shows it (#3181).
version="$(sh ../../packaging/version.sh key desktop)"
echo "Building desktop ordering key $version"
cargo tauri build \ cargo tauri build \
--config '{"build":{"beforeBuildCommand":""}}' \ --config '{"build":{"beforeBuildCommand":""}}' \
--config "{\"version\":\"$version\"}" \ --config "{\"version\":\"$version\"}" \
@@ -205,37 +242,40 @@ jobs:
# failure, not as a green run with an empty artifact. # failure, not as a green run with an empty artifact.
if-no-files-found: error if-no-files-found: error
# Tag builds only: publish a real, versioned Fabled-Git Release with the # The rolling channel for this branch: `dev` from dev, `stable` from main. Both
# AppImage + .deb attached — the stable fetch target the install script and # are releases whose tag never moves, so the updater has a permanent URL to
# the in-app updater consume (Actions artifacts above are ephemeral/test). # read — Forgejo has no /releases/latest/download/<asset> route, so "newest"
# Cutting the tag is the operator's action (rule 2); this only publishes a # cannot be named in a URL.
# Release for a tag that already exists. Dormant on dev/main pushes. #
- name: Publish release # MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until
if: startsWith(github.ref, 'refs/tags/v') # M314 step 3 this job built on main and published nothing, so the stable
env: # channel moved only when somebody cut a tag — that section's diagnostic
GITHUB_TOKEN: ${{ github.token }} # failing outright: main publishing was not sufficient for a user to receive
run: bash desktop/packaging/publish-release.sh # the build.
# 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 # Gated on the signing key INSIDE the script rather than with an `if:`, because
# the secrets context isn't reliably available to step conditions. Publishing # the secrets context isn't reliably available to step conditions. Publishing
# bundles the app would then refuse to verify is worse than publishing nothing: # bundles the app would then refuse to verify is worse than publishing nothing:
# it looks like a working feed. # it looks like a working feed.
- name: Publish to the dev channel - name: Publish to the channel for this branch
if: github.ref == 'refs/heads/dev' if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
env: env:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
RELEASE_TAG: dev
RELEASE_PRERELEASE: "true"
run: | run: |
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the dev channel publish." echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
exit 0 exit 0
fi fi
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
# `prerelease` is true for dev so it does not read as a supported build,
# and false for stable, which is the real thing.
case "$GITHUB_REF_NAME" in
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
esac
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh bash desktop/packaging/publish-release.sh
# Windows installer, CROSS-COMPILED from Linux — there is no Windows build host. # Windows installer, CROSS-COMPILED from Linux — there is no Windows build host.
@@ -253,12 +293,21 @@ jobs:
# built, not that it runs. A real-machine check stays mandatory before trusting it. # built, not that it runs. A real-machine check stays mandatory before trusting it.
windows: windows:
name: Windows installer (cross-compiled) name: Windows installer (cross-compiled)
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') needs: [decide]
if: needs.decide.outputs.build == 'true'
runs-on: python-ci runs-on: python-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-tauri-win:1.97 image: git.fabledsword.com/bvandeusen/ci-tauri-win:1.97
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with:
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
# the lane green — note 3127 §6.1, and the direction you cannot recover
# from. `packaging/version.sh` fails loudly on an empty result rather than
# emitting something plausible, which is what turns this into a red lane
# if it is ever dropped.
fetch-depth: 0
# Same reason as the Linux job: generate_context! embeds the built frontend # Same reason as the Linux job: generate_context! embeds the built frontend
# at compile time, so it must exist before cargo runs. # at compile time, so it must exist before cargo runs.
@@ -292,8 +341,12 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: | run: |
version="$(sh ../packaging/build-version.sh)" # The ORDERING KEY, not the display version: this string is what Tauri's
echo "Building version $version" # updater parses as semver, and what it stamps into bundle FILENAMES that
# `write-manifest.sh` then selects on. The human-readable version is a
# separate value and arrives with the UI that shows it (#3181).
version="$(sh ../../packaging/version.sh key desktop)"
echo "Building desktop ordering key $version"
updater='{}' updater='{}'
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
updater='{"bundle":{"createUpdaterArtifacts":true}}' updater='{"bundle":{"createUpdaterArtifacts":true}}'
@@ -317,35 +370,40 @@ jobs:
path: target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe path: target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
if-no-files-found: error if-no-files-found: error
# Publishes to the SAME release as the Linux job. Safe to run twice: the # The rolling channel for this branch: `dev` from dev, `stable` from main. Both
# script reuses an existing release (409) and nullglob means each job uploads # are releases whose tag never moves, so the updater has a permanent URL to
# only the bundles present in its own workspace. # read — Forgejo has no /releases/latest/download/<asset> route, so "newest"
- name: Publish release # cannot be named in a URL.
if: startsWith(github.ref, 'refs/tags/v') #
env: # MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until
GITHUB_TOKEN: ${{ github.token }} # M314 step 3 this job built on main and published nothing, so the stable
run: bash desktop/packaging/publish-release.sh # 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 rolling DEVELOPMENT channel (M10.9): a release whose tag never moves, so # the build.
# 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 # Gated on the signing key INSIDE the script rather than with an `if:`, because
# the secrets context isn't reliably available to step conditions. Publishing # the secrets context isn't reliably available to step conditions. Publishing
# bundles the app would then refuse to verify is worse than publishing nothing: # bundles the app would then refuse to verify is worse than publishing nothing:
# it looks like a working feed. # it looks like a working feed.
- name: Publish to the dev channel - name: Publish to the channel for this branch
if: github.ref == 'refs/heads/dev' if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
env: env:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
RELEASE_TAG: dev
RELEASE_PRERELEASE: "true"
run: | run: |
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the dev channel publish." echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
exit 0 exit 0
fi fi
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
# `prerelease` is true for dev so it does not read as a supported build,
# and false for stable, which is the real thing.
case "$GITHUB_REF_NAME" in
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
esac
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh bash desktop/packaging/publish-release.sh
# The updater manifest, written AFTER both bundle jobs — they run in separate # The updater manifest, written AFTER both bundle jobs — they run in separate
@@ -359,12 +417,20 @@ jobs:
manifest: manifest:
name: Update manifest name: Update manifest
needs: [build, windows] needs: [build, windows]
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v') if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci runs-on: python-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97 image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with:
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
# the lane green — note 3127 §6.1, and the direction you cannot recover
# from. `packaging/version.sh` fails loudly on an empty result rather than
# emitting something plausible, which is what turns this into a red lane
# if it is ever dropped.
fetch-depth: 0
- name: Write and publish latest.json - name: Write and publish latest.json
env: env:
@@ -376,23 +442,22 @@ jobs:
echo "manifest to write. Add the secret to enable in-app updates." echo "manifest to write. Add the secret to enable in-app updates."
exit 0 exit 0
fi fi
# The SAME helper the bundles were built with — a second derivation here # The SAME helper AND the same request the bundles were built with — a
# could drift, and a manifest whose version doesn't match the binary it # second derivation here could drift, and a manifest whose version doesn't
# points at is an updater that never settles. # match the binary it points at is an updater that never settles. It must
version="$(sh desktop/packaging/build-version.sh)" # be `key`: this value is matched against bundle filenames.
if [ "${GITHUB_REF_NAME}" = "dev" ]; then version="$(sh packaging/version.sh key desktop)"
export RELEASE_TAG=dev # Both channels are rolling: the manifest lands on the same release that
export RELEASE_NOTES="Development build from ${GITHUB_SHA}" # holds the bundles, and the previous build's bundles are dropped once it
# Rolling channel: drop the previous build's bundles once the manifest # points at this one. Nothing can reach them, and they are ~100 MB a push.
# points at this one. Nothing can reach them, and they're ~100 MB a push. #
export PRUNE_OLD_ASSETS=true # No tag arm any more. A `v*` tag does not reach this workflow at all — it
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh # triggers release.yml, which writes a changelog and builds nothing.
else case "${GITHUB_REF_NAME}" in
export RELEASE_TAG="${GITHUB_REF_NAME}" main) export RELEASE_TAG=stable
export RELEASE_NOTES="ThoughtSync ${GITHUB_REF_NAME}" export RELEASE_NOTES="Stable build from ${GITHUB_SHA}" ;;
# Twice: once onto the versioned release itself, and once onto the *) export RELEASE_TAG=dev
# permanent `stable` pointer the app actually reads. Same manifest both export RELEASE_NOTES="Development build from ${GITHUB_SHA}" ;;
# times — its URLs point at the versioned assets either way. esac
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh export PRUNE_OLD_ASSETS=true
APP_VERSION="$version" MANIFEST_TAG=stable bash desktop/packaging/write-manifest.sh APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
fi
+68
View File
@@ -0,0 +1,68 @@
name: Release
# A RELEASE BUILDS NOTHING. That is the whole point of this lane (M314 step 7).
#
# The merge to `main` already published everything a user can receive: the server
# image as `:latest` + `:<sha>`, the desktop bundles and the APK to the `stable`
# channel, and the updater manifest that advertises them. A tag rebuilding that same
# source would produce identical artifacts under identical names, and would re-push
# `:<sha>` with different bytes — which rule 145 forbids even when they match.
#
# So the tag is a BOOKMARK, and this lane gives it the only job it has left: saying
# what was in it. Note 3127 §5 — there are two halves to "what am I running", and
# the version answers only the first:
#
# which build is this? the footer, /api/config, the APK's versionName
# what changed since the one ← this
# I was running last month?
#
# Cutting the tag is the operator's act (rule 2). This only responds to one.
#
# THE TAG IS NOT AN IMAGE TAG and never becomes one. `ci.yml` does not trigger on
# tags at all. The image is addressed by channel or by commit; the release by date.
# Same string as the artifact version (rule 148, `vYYYY.MM.DD.HHMM`), different
# system.
on:
push:
tags: ["v*"]
permissions:
contents: write
jobs:
notes:
name: Write the changelog
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
- uses: actions/checkout@v6
with:
# The whole history AND every tag: the notes are the commit range between
# this tag and the previous `v*` one, and neither end exists in a shallow
# clone. A depth-limited checkout here does not fail — it produces a
# shorter changelog, which is the kind of wrong nobody notices.
fetch-depth: 0
- name: Publish the release notes
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
notes="$(sh packaging/release-notes.sh "$GITHUB_REF_NAME")"
echo "$notes"
echo "---"
# JSON-escaped HERE rather than in publish-release.sh, which cannot assume
# python3 is on PATH in the three images that call it. `json.dumps` then
# strip the surrounding quotes — the script supplies those.
RELEASE_BODY_JSON="$(printf '%s' "$notes" \
| python3 -c 'import json,sys; print(json.dumps(sys.stdin.read())[1:-1])')"
export RELEASE_BODY_JSON
# Through publish-release.sh for its create-or-PATCH-on-409 path: a
# release that is only ever POSTed keeps whatever body its first run
# wrote (#2182), so re-tagging or re-running must rewrite it. No bundles
# exist in this workspace, so its asset globs match nothing and it
# uploads none — which is the intended behaviour, not a side effect.
RELEASE_TAG="$GITHUB_REF_NAME" bash desktop/packaging/publish-release.sh
+4 -2
View File
@@ -99,8 +99,10 @@ Then open `http://<host>:5000` and register — **the first account becomes the
unset, a signing key is generated and persisted in the database (sessions survive restarts). unset, a signing key is generated and persisted in the database (sessions survive restarts).
- Uploaded images live under the `thoughtsync-data` volume at `/var/thoughtsync`. - Uploaded images live under the `thoughtsync-data` volume at `/var/thoughtsync`.
- The app waits for the database and runs migrations (`alembic upgrade head`) automatically on start. - The app waits for the database and runs migrations (`alembic upgrade head`) automatically on start.
- **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev` build) · - **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev`
`:<git-sha>` (immutable, for pinning / rollback). build) · `:<git-sha>` on `main` only (immutable, the rollback unit). There are
no version-shaped tags: nothing pins one, and the build reports its own version
at `/api/config` and `/health`.
- **Putting it on the public internet:** there are four things to do first — close - **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 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 port, and back up the attachment volume as well as the database. See
@@ -0,0 +1,128 @@
"""fold note_items into the note body and drop the table
Revision ID: 0027
Revises: 0026
Create Date: 2026-08-24
M304. A checklist item becomes a `- [ ] milk` line of `notes.body`, and `note_items`
goes. The reason is positional, not cosmetic: a row had a position in a table and no
position in the text, so a separate list could only ever render AFTER the prose. With
the items in the body, a list can sit between two paragraphs — which is the thing that
could not be built before and no amount of restyling would have delivered.
## This migration rewrites note bodies
Every note that has items gets its body appended to. The rules below are strict
because rewriting somebody's text deserves it — not, as an earlier draft of this
docstring claimed, because this instance holds imported Google Keep notes. It does
not; note 2916's headline is that nothing here is anyone's work but the operator's
test data. What 2916 actually says about imports is conditional — text arriving from
another app WOULD be real, and any import path has to treat it that way — and the
importer this migration shares a format with is one nobody here has run.
Careful was still the right call. It cost little, and the same care is what the rule
demands the day someone does import something:
* Rows are read BEFORE the table is dropped, in this one transaction.
* The existing body is never rewritten, only appended to.
* The layout — a blank line between prose and the list, nothing between consecutive
items — is byte-for-byte what `_note_markdown` has always exported and what
`derive::append_item` produces on every client. All three landing on the same text
is what lets the clients migrate their own SQLite stores independently and still
agree with the server, with no sync required to reconcile them.
## The fold is inlined on purpose
`notes/checklist.py` has this same function and this migration deliberately does not
import it. A migration has to keep producing what it produced the day it ran; if the
app's spacing rule ever changes, this file must not change with it.
## `updated_at` is left alone, and that is load-bearing
Raw SQL, so SQLAlchemy's `onupdate` never fires. Two reasons, and the second matters
more than the first. Every client folds the same rows the same way, so the new body is
news to nobody. And a client holding an UNPUSHED body edit still has the newer
`updated_at`, so when it pulls the migrated note last-write-wins keeps its edit instead
of the migration silently winning.
The `notes` row's own `sync_revision` trigger (migration 0015) does fire, so every
migrated note becomes pullable once. That is wanted: it is what makes a client whose
local fold somehow differed converge on the server's text.
## The downgrade is not a true inverse, and says so
It recreates an empty `note_items` and leaves the bodies alone. Nothing is lost —
every item is still there as text, which is where this migration put it — but the old
code would show those notes as prose with no checklist. A faithful inverse is not
possible: once the items are lines, nothing distinguishes a line this migration wrote
from one somebody typed, and a downgrade that guessed would eat hand-written task
lists. The real rollback is a database restore.
Recreating the table is not decoration, though. Migration 0015's downgrade runs
`DROP TRIGGER IF EXISTS trg_note_items_bump_note ON note_items`, and `IF EXISTS`
covers the trigger, not the table — against a missing table that statement errors. So
this is what keeps the migration chain runnable all the way back down.
"""
import re
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision = "0027"
down_revision = "0026"
branch_labels = None
depends_on = None
_TASK_RE = re.compile(r"^\s*[-*] +\[[ xX]\](?: +.*)?$")
def _append_item(body: str, text: str, checked: bool) -> str:
mark = "x" if checked else " "
text = (text or "").strip()
line = f"- [{mark}] {text}" if text else f"- [{mark}]"
trimmed = (body or "").rstrip("\n")
if not trimmed.strip():
return line
follows_a_list = bool(_TASK_RE.match(trimmed.split("\n")[-1]))
return f"{trimmed}\n{line}" if follows_a_list else f"{trimmed}\n\n{line}"
def upgrade():
bind = op.get_bind()
rows = bind.execute(
sa.text("SELECT note_id, text, checked FROM note_items ORDER BY note_id, position, created_at")
).fetchall()
grouped: dict = {}
for note_id, text, checked in rows:
grouped.setdefault(note_id, []).append((text, bool(checked)))
for note_id, items in grouped.items():
body = bind.execute(sa.text("SELECT body FROM notes WHERE id = :id"), {"id": note_id}).scalar()
# An item whose note is already gone has nothing to fold into. The foreign key
# should make this impossible; skipping costs nothing and failing here would
# leave the database half-migrated.
if body is None:
continue
for text, checked in items:
body = _append_item(body, text, checked)
bind.execute(sa.text("UPDATE notes SET body = :body WHERE id = :id"), {"body": body, "id": note_id})
op.drop_table("note_items")
def downgrade():
# Column-for-column as migration 0006 created it, index name included: 0015's
# downgrade names both the table and its trigger, so a near-enough copy is not
# good enough.
op.create_table(
"note_items",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("note_id", UUID(as_uuid=True), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
sa.Column("text", sa.Text(), nullable=False),
sa.Column("checked", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_note_items_note", "note_items", ["note_id"])
@@ -0,0 +1,168 @@
"""lift standalone #tags out of note bodies
Revision ID: 0028
Revises: 0027
Create Date: 2026-08-26
M311. A `#tag` was being shown twice — once as the text you typed and once as a chip —
and with the chip moved to the top of the card the text is redundant. This removes it,
but only from notes where the tag was standing on its own.
## This migration rewrites note bodies
The rule is deliberately narrow, and the same one `notes/tags.py:split_body_tags`
applies from here on:
* A line containing nothing but tags and whitespace is REMOVED.
* Every other line is left exactly as written.
So `#todo` on its own line goes, and `remember to call #mom tomorrow` does not. The
looser reading — also stripping a trailing tag off a prose line — was rejected because
the text does not say which kind it is: `buy milk #grocery` is filing, `remember to
call #mom` is the sentence's object, and lifting the second leaves "remember to call".
Rewriting somebody's words to save a duplicate chip is a bad trade, and a migration is
the worst possible place to make it.
Two guards, both of which cost a note nothing:
* A line inside a ``` fence is never touched. A `#tag` there is a shell comment in a
snippet somebody pasted, and deleting it would eat a line of their example.
* A note that is NOTHING but tags keeps its text. Lifting would leave a blank card,
which is worse than the duplication this fixes.
## The label rows have to graduate in the same transaction
A `via_tag` row means "this label is backed by text still in the body". Once the text
is gone that is false, and leaving it true is not cosmetic: `_lift_and_reconcile_tags`
detaches any `via_tag` row it cannot find a `#tag` for, so the note would lose the tag
on its very next save. The flip to `via_tag = false` is what makes the label the record
instead — and what makes the chip's × appear in both editors, which is now the only way
to remove a tag whose text no longer exists.
## The transform is inlined, like 0027's
`split_body_tags` is deliberately NOT imported. A migration has to keep producing what
it produced the day it ran; if the app's rule is ever loosened, this file must not
loosen with it and start eating prose it previously left alone.
`_display_title` is inlined for the same reason, and is only recomputed for a note whose
body actually moved — a note named after a `#todo` line needs a new name, and reading it
from the app would couple this migration to a rule that has already changed once (M13).
## `updated_at` is left alone, and that is load-bearing
Raw SQL, so SQLAlchemy's `onupdate` never fires. A client holding an UNPUSHED body edit
keeps the newer `updated_at`, so when it pulls the migrated note last-write-wins keeps
its edit instead of the migration silently winning.
The `sync_revision` trigger (migration 0015) does fire, so every rewritten note becomes
pullable once and clients converge on the server's text. That is wanted here: unlike
0027, the clients do NOT yet apply this rule locally, so the server's copy is the only
correct one until they do.
## The downgrade is not a true inverse, and says so
It cannot be. Nothing distinguishes a `#todo` line this migration deleted from one that
was never there, and putting one back would be guessing at where in the note it went.
Nothing is lost, though, which is why that is acceptable: the tag still exists as a
label on the note, and the chip still shows it. What a downgrade cannot restore is the
DUPLICATE — which is the thing this migration set out to remove. Rolling the rows back
to `via_tag = true` would be actively harmful: the text that flag claims to be backed by
is gone, so the next save would detach the label and lose the tag for real. So the
downgrade leaves both alone. The real rollback is a database restore.
"""
import re
import sqlalchemy as sa
from alembic import op
revision = "0028"
down_revision = "0027"
branch_labels = None
depends_on = None
# Frozen copies. See "The transform is inlined" above — these must not follow the app.
_TAG_RE = re.compile(r"(?:^|(?<=\s))#(\w[\w-]*)")
_FENCE_RE = re.compile(r"^\s*(?:```|~~~)")
_TASK_RE = re.compile(r"^(?P<indent>\s*)(?P<bullet>[-*]) +\[(?P<mark>[ xX])\](?: +(?P<text>.*))?$")
_DISPLAY_TITLE_CAP = 200
def _is_tag(name: str) -> bool:
"""A tag must contain a letter, so #2024 and #_ are not tags — and a line holding
only those is therefore not a tag-only line and is left alone."""
return any(c.isalpha() for c in name)
def _split(body: str) -> tuple[list[str], str]:
"""(standalone tag names, body with their lines removed)."""
standalone: list[str] = []
kept: list[str] = []
in_fence = False
for line in body.split("\n"):
if _FENCE_RE.match(line):
in_fence = not in_fence
kept.append(line)
continue
matches = [m for m in _TAG_RE.finditer(line) if _is_tag(m.group(1))]
remainder = line
for m in reversed(matches):
remainder = remainder[: m.start()] + remainder[m.end() :]
if in_fence or not matches or remainder.strip():
kept.append(line)
else:
standalone.extend(m.group(1) for m in matches)
lifted = re.sub(r"\n{3,}", "\n\n", "\n".join(kept)).strip("\n")
if body.strip() and not lifted.strip():
return [], body # nothing but tags: keep the note readable
# A tag still written in prose somewhere keeps its text, so it stays derived.
still_in_prose = {m.group(1).lower() for m in _TAG_RE.finditer(lifted) if _is_tag(m.group(1))}
return [n for n in standalone if n.lower() not in still_in_prose], lifted
def _display_title(body: str) -> str:
for line in body.splitlines():
stripped = line.strip()
match = _TASK_RE.match(stripped)
text = (match.group("text") or "") if match else stripped
text = text.strip()
if text:
return text[:_DISPLAY_TITLE_CAP]
return ""
def upgrade():
bind = op.get_bind()
rows = bind.execute(sa.text("SELECT id, body FROM notes WHERE body LIKE '%#%'")).fetchall()
flip = sa.text(
"UPDATE note_labels nl SET via_tag = false "
"FROM labels l "
"WHERE nl.label_id = l.id AND nl.note_id = :nid AND nl.via_tag = true "
"AND lower(l.name) IN :names"
).bindparams(sa.bindparam("names", expanding=True))
for note_id, body in rows:
if not body:
continue
standalone, lifted = _split(body)
if lifted != body:
bind.execute(
sa.text("UPDATE notes SET body = :body, display_title = :title WHERE id = :id"),
{"body": lifted, "title": _display_title(lifted), "id": note_id},
)
# Even when the body did not move, a tag can be standalone only in the sense
# that its line was already removed by an earlier pass — so the flip is driven
# by the tag list, not by whether the text changed.
if standalone:
bind.execute(flip, {"nid": note_id, "names": [n.lower() for n in standalone]})
def downgrade():
"""Deliberately empty — see the module docstring.
Restoring the deleted lines would be guessing, and flipping the rows back to
`via_tag = true` would be worse than doing nothing: the text that flag claims backs
them is gone, so the next save would detach the label and lose the tag for real.
"""
+93
View File
@@ -0,0 +1,93 @@
"""drop notes.color — a card is one neutral surface, colour lives on the tag
Revision ID: 0029
Revises: 0028
Create Date: 2026-08-28
M315 step 3. A note's colour was set by a picker and read by three card renderers.
Steps 1 and 2 stopped every one of those reads: the card is one neutral per theme and
the only coloured thing on a board is a tag. This drops the column that nothing has
been reading since, and the picker goes with it.
`labels.color` is untouched. That is the colour that survived, and the one the whole
milestone was about keeping.
## What is lost, and why that is the change rather than a cost of it
Any colour a note was explicitly given. There is nowhere to preserve it TO — the field
it would be preserved in is the one being dropped — and nothing renders it, so a
preserved value would be a column kept warm for a feature that was deliberately
removed. A note that had a colour now takes its identity from its tags, which is what
the operator asked for: "strip color from the cards ... and keep the color for tags
just on the tag."
The palette itself is not lost. `NOTE_COLORS` moved from `models/note.py` to
`colors.py` in the same change — labels still name a colour, and leaving the vocabulary
defined on the model that lost one would be an invitation to put the column back.
## The saved-filter sweep is not optional
`saved_filters.params` is opaque JSON mirroring the `GET /api/notes` facet query, and
a stored view could carry `"color": "teal"`. With the facet gone that key would sit
there forever, and `clean_params` only guards what is written FROM here on. A view that
silently filters on a field the app no longer has is worse than one that visibly lost a
criterion, so the stored rows are swept too.
Done in Python rather than as `params::jsonb - 'color'`, deliberately. Postgres has no
try-cast: one malformed blob would abort the whole migration, and these rows are
somebody's saved views. `json.loads` in a try/except lets a corrupt row keep whatever it
holds and lets every other row be fixed.
## Search is not affected
`notes.search_vector` is a stored generated column over `display_title` and `body`
(rebuilt in 0026). It never named `color`, so unlike the title drop there is nothing
here to tear down and recreate.
## Downgrade
Restores the column, empty, at its old default. The values are not recoverable — see
above. It is the schema that comes back, not the data.
"""
import json
from alembic import op
import sqlalchemy as sa
revision = "0029"
down_revision = "0028"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column("notes", "color")
bind = op.get_bind()
rows = bind.execute(
sa.text("SELECT id, params FROM saved_filters WHERE params LIKE '%color%'")
).fetchall()
for sf_id, params in rows:
try:
parsed = json.loads(params)
except (ValueError, TypeError):
# A blob that does not parse cannot be edited safely. Leaving it is
# correct: it was already unreadable by the app, and this migration is not
# the place to decide what it should have said.
continue
if not isinstance(parsed, dict) or "color" not in parsed:
continue
parsed.pop("color")
bind.execute(
sa.text("UPDATE saved_filters SET params = :p WHERE id = :id"),
{"p": json.dumps(parsed), "id": sf_id},
)
def downgrade() -> None:
# Comes back at the default every note would have had anyway. Which notes once
# carried a chosen colour is not recorded anywhere after the upgrade.
op.add_column(
"notes",
sa.Column("color", sa.Text(), nullable=False, server_default="default"),
)
+3
View File
@@ -7,6 +7,9 @@
this permission never exercised. this permission never exercised.
--> -->
<uses-permission android:name="android.permission.INTERNET" /> <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 Four more permissions are NOT declared here and still reach the merged
@@ -4,6 +4,8 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentSender import android.content.IntentSender
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.Settings import android.provider.Settings
@@ -62,6 +64,30 @@ object AppUpdate {
.setData(Uri.fromParts("package", context.packageName, null)) .setData(Uri.fromParts("package", context.packageName, null))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .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. */ /** Where a download goes: app-private, so no storage permission is involved. */
fun downloadTarget(context: Context): File = File(context.cacheDir, "update.apk") fun downloadTarget(context: Context): File = File(context.cacheDir, "update.apk")
@@ -25,8 +25,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.fabledsword.thoughtsync.core.ThoughtSync import com.fabledsword.thoughtsync.core.ThoughtSync
import com.fabledsword.thoughtsync.ui.BoardScreen import com.fabledsword.thoughtsync.ui.BoardScreen
import com.fabledsword.thoughtsync.ui.BoardSync import com.fabledsword.thoughtsync.ui.BoardSync
import com.fabledsword.thoughtsync.ui.BoardUpdate
import com.fabledsword.thoughtsync.ui.BoardViewModel import com.fabledsword.thoughtsync.ui.BoardViewModel
import com.fabledsword.thoughtsync.ui.ComposeSheet
import com.fabledsword.thoughtsync.ui.ForegroundTransitions import com.fabledsword.thoughtsync.ui.ForegroundTransitions
import com.fabledsword.thoughtsync.ui.NoteEditorScreen import com.fabledsword.thoughtsync.ui.NoteEditorScreen
import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen
@@ -141,14 +141,13 @@ private fun App(
val sync: SyncViewModel = val sync: SyncViewModel =
viewModel(factory = SyncViewModel.factory(core, onStoreChanged = board::refresh)) viewModel(factory = SyncViewModel.factory(core, onStoreChanged = board::refresh))
// Sheet and screen visibility are view STATE, not view-model state: they are // Screen visibility is view STATE, not view-model state: it is about what is on
// about what is on the display, and nothing in the store cares. // the display, and nothing in the store cares. Saveable so a rotation does not
// close it.
// //
// Saveable, though: `remember` alone meant rotating the phone closed whatever // The capture sheet used to keep its own flag here too. It is gone: the + button
// was open and took the half-written note in the capture sheet with it. The // opens the editor on an unsaved draft, so writing a note and editing one are the
// editor never had that problem because the note it is on lives in a view // same surface with the same toolbar.
// model; these two are the only screen state that did not.
var composing by rememberSaveable { mutableStateOf(false) }
var showingSync by rememberSaveable { mutableStateOf(false) } var showingSync by rememberSaveable { mutableStateOf(false) }
val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context)) val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context))
@@ -156,6 +155,7 @@ private fun App(
var automatic by remember { mutableStateOf(settings.automatic) } var automatic by remember { mutableStateOf(settings.automatic) }
AutomaticSync(state = sync.state, enabled = automatic, onSync = sync::syncQuietly) AutomaticSync(state = sync.state, enabled = automatic, onSync = sync::syncQuietly)
AutomaticUpdate(linked = sync.state.linked, onCheck = update::checkInBackground)
val editing = board.state.editing val editing = board.state.editing
val screen = val screen =
@@ -192,6 +192,7 @@ private fun App(
NoteEditorScreen( NoteEditorScreen(
// Non-null by construction: `screen` is EDITOR only when it is. // Non-null by construction: `screen` is EDITOR only when it is.
note = requireNotNull(editing) { "the editor screen needs a note" }, note = requireNotNull(editing) { "the editor screen needs a note" },
sessionKey = board.state.editingSession,
labels = board.state.labels, labels = board.state.labels,
saving = board.state.saving, saving = board.state.saving,
error = board.state.error, error = board.state.error,
@@ -218,20 +219,28 @@ private fun App(
), ),
onOpenSync = { showingSync = true }, onOpenSync = { showingSync = true },
onSearch = board::search, onSearch = board::search,
onCompose = { composing = true }, onCompose = board::compose,
onToggleItem = board::toggleItem,
// The SAME seam the editor uses. `onEditorAction` is already the
// exhaustive dispatcher for every action a note has, and it takes
// the note to act on rather than reading the open one — so the board
// can hand it a card without a second dispatcher existing to drift.
onNoteAction = board::onEditorAction,
// Null unless there is genuinely something to say — the board is
// handed a decision, not a state to interpret.
update =
update.state.available
?.takeIf { update.state.nagging }
?.let {
BoardUpdate(
version = it.version,
busy = update.state.busy,
onInstall = update::downloadAndInstall,
onDismiss = update::dismissNag,
)
},
onDismissError = board::dismissError, onDismissError = board::dismissError,
) )
if (composing) {
ComposeSheet(
saving = board.state.saving,
onDismiss = { composing = false },
onSave = { content ->
board.create(content)
composing = false
},
)
}
} }
} }
@@ -282,6 +291,36 @@ private fun ReminderAlarms(core: ThoughtSync) {
} }
} }
/**
* 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. * Syncing without being asked.
* *
@@ -0,0 +1,264 @@
package com.fabledsword.thoughtsync.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
/**
* The note's body, as fields and checkboxes rather than as markup.
*
* The point of the whole shape: a box you can tick while looking at the note, rather
* than `- [ ] ` to read and edit around. What the note IS never changed.
*/
@Composable
fun BlockBody(
blocks: List<EditorBlock>,
readOnly: Boolean,
focus: Long?,
onChange: (List<EditorBlock>) -> Unit,
onFocus: (Long?) -> Unit,
modifier: Modifier = Modifier,
) {
// Focus is addressed by block ID, never by position — the id is the only thing
// about a block that survives one being inserted above it. Hoisted to the caller
// rather than kept here, because the TOOLBAR also asks for a focus when its button
// appends an item, and two owners of one cursor is one too many.
val requesters = remember { mutableMapOf<Long, FocusRequester>() }
LaunchedEffect(focus) {
val id = focus ?: return@LaunchedEffect
// Honoured after the composition that created the field: a FocusRequester not
// yet attached to anything throws when asked.
requesters[id]?.requestFocus()
onFocus(null)
}
fun replace(
index: Int,
block: EditorBlock,
) = onChange(blocks.toMutableList().also { it[index] = block })
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) {
blocks.forEachIndexed { index, block ->
val requester = requesters.getOrPut(block.id) { FocusRequester() }
if (block.isTask) {
TaskBlock(
block = block,
readOnly = readOnly,
requester = requester,
onChange = { replace(index, it) },
onEnter = {
val next = blocks.nextId()
onChange(afterEnter(blocks, index, next))
// The new item if there was one; otherwise the block that just
// became prose, which keeps the caret where the person left it.
onFocus(if (blocks[index].value.text.isBlank()) block.id else next)
},
onDelete = {
val remaining = blocks.withoutIndex(index)
onChange(remaining)
// The row above — or, for the FIRST row, whichever one takes
// its place. `index - 1` alone is -1 there, which left the
// keyboard up with nothing focused.
onFocus(remaining.getOrNull((index - 1).coerceAtLeast(0))?.id)
},
)
} else {
ProseBlock(
block = block,
readOnly = readOnly,
requester = requester,
onChange = { replace(index, it) },
onBlur = {
// Compared by IDENTITY, not equality: `promotingTasks` hands
// back the same list when there was nothing to promote, and a
// blur that changed nothing must not touch the state at all.
val promoted = blocks.promotingTasks(index)
if (promoted !== blocks) onChange(promoted)
},
)
}
}
}
}
/**
* A run of prose: one ordinary multi-line field, exactly as the editor always had.
*
* Leaving it is when a `- [ ] ` typed by hand becomes a real checklist item — see
* [promotingTasks] for why blur is the only safe moment to do that.
*
* `onFocusChanged` also fires with `isFocused = false` on the first composition, before
* the field has ever held focus. Deliberately not guarded: [splitBlocks] ran when the
* editor opened, so a prose block nobody has typed in cannot contain a task line, and
* the promotion is a no-op that the caller's identity check drops on the floor.
*/
@Composable
private fun ProseBlock(
block: EditorBlock,
readOnly: Boolean,
requester: FocusRequester,
onChange: (EditorBlock) -> Unit,
onBlur: () -> Unit,
) {
BlockField(
value = block.value,
onValueChange = { onChange(block.copy(value = it)) },
modifier =
Modifier
.focusRequester(requester)
.onFocusChanged { if (!it.isFocused) onBlur() },
enabled = !readOnly,
hint = R.string.editor_body_hint,
)
}
/**
* One checklist item: a real box, and the item's text beside it.
*
* Single-line with [ImeAction.Next], which is what turns the keyboard's return key
* into "next item" — the reason a list can be typed straight through rather than a
* marker at a time.
*/
@Composable
private fun TaskBlock(
block: EditorBlock,
readOnly: Boolean,
requester: FocusRequester,
onChange: (EditorBlock) -> Unit,
onEnter: () -> Unit,
onDelete: () -> Unit,
) {
// Material sizes every interactive component to a 48dp touch target, and on a
// checklist that IS the row height — which is why six items filled a phone screen
// even after the field's own padding came off.
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides ROW_TOUCH) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = block.checked == true,
onCheckedChange = { onChange(block.copy(checked = it)) },
enabled = !readOnly,
)
BlockField(
value = block.value,
onValueChange = { onChange(block.copy(value = it)) },
modifier = Modifier.weight(1f).focusRequester(requester),
enabled = !readOnly,
singleLine = true,
textStyle =
MaterialTheme.typography.bodyLarge.copy(
// Struck through when done, matching the card and the web.
textDecoration =
if (block.checked == true) TextDecoration.LineThrough else null,
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { onEnter() }),
)
if (!readOnly) {
IconButton(onClick = onDelete) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.editor_remove_item),
)
}
}
}
}
}
/**
* The touch target for a checklist row's controls.
*
* Material's floor is 48dp and this is deliberately under it. That floor is sized for
* a control somebody has to find; a checklist box sits in a predictable column with an
* identical box directly above and below, and the cost of a near miss is ticking the
* neighbouring item — visible, and undone by tapping again. Trading twelve of those
* dp for a list that fits on a screen is what was asked for, twice.
*/
private val ROW_TOUCH = 36.dp
/**
* The field a block is typed into.
*
* `BasicTextField`, not the Material one [PlainTextField] wraps, and the reason is
* density. Material's TextField puts 16dp above and below its text — padding that
* makes a FORM field comfortable to hit, and that on a checklist IS the row height. It
* made six items twice as tall as the six items, which is what the operator saw.
*
* Nothing is lost by dropping down a layer. `PlainTextField` exists to strip a
* container and an indicator; `BasicTextField` never had either, so there is no box
* here to drift back into existence. What it does not supply and this must:
*
* - the text COLOUR. It defaults to `Color.Unspecified`, which draws BLACK — the same
* default that made the editor's toolbar invisible in dark mode. Set, not inherited.
* - the cursor brush, which would otherwise be black for the same reason.
* - the placeholder, which is a plain Text behind the field rather than a slot.
*
* `enabled = false` deliberately does not grey the text out: a trashed note renders
* read-only through this and its words are meant to be READ.
*/
@Composable
private fun BlockField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
singleLine: Boolean = false,
@StringRes hint: Int? = null,
textStyle: TextStyle = MaterialTheme.typography.bodyLarge,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
val style = textStyle.copy(color = MaterialTheme.colorScheme.onSurface)
Box(modifier = modifier) {
if (hint != null && value.text.isEmpty()) {
Text(
text = stringResource(hint),
style = style,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
enabled = enabled,
singleLine = singleLine,
textStyle = style,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
)
}
}
@@ -6,11 +6,14 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
@@ -37,6 +40,11 @@ import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Scaffold 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.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
@@ -44,7 +52,11 @@ import androidx.compose.material3.pulltorefresh.pullToRefresh
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -64,10 +76,52 @@ fun BoardScreen(
onOpenSync: () -> Unit, onOpenSync: () -> Unit,
onSearch: (String) -> Unit, onSearch: (String) -> Unit,
onCompose: () -> Unit, onCompose: () -> Unit,
onToggleItem: (Note, Int, Boolean) -> Unit,
onNoteAction: (Note, EditorAction) -> Unit,
update: BoardUpdate?,
onDismissError: () -> Unit, onDismissError: () -> Unit,
) { ) {
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() 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( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
@@ -88,6 +142,22 @@ fun BoardScreen(
}, },
) { ) {
Scaffold( 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 = { floatingActionButton = {
// The + is the ONLY way in, by design: one obvious target rather // The + is the ONLY way in, by design: one obvious target rather
// than a capture bar and a button competing for the same job. // than a capture bar and a button competing for the same job.
@@ -117,6 +187,17 @@ fun BoardScreen(
ErrorBanner(message = message, onDismiss = sync.onDismissError) 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 // Only where someone is already thinking about reminders. On the
// main board it would nag people who have never set one. // main board it would nag people who have never set one.
if (state.destination == Destination.Reminders) ReminderNotice() if (state.destination == Destination.Reminders) ReminderNotice()
@@ -140,7 +221,14 @@ fun BoardScreen(
when { when {
state.loading -> LoadingBoard() state.loading -> LoadingBoard()
state.notes.isEmpty() -> EmptyBoard(state) state.notes.isEmpty() -> EmptyBoard(state)
else -> NoteBoard(notes = state.notes, onOpenNote = onOpenNote) else ->
NoteBoard(
notes = state.notes,
onOpenNote = onOpenNote,
onToggleItem = onToggleItem,
onNoteAction = onCardAction,
onConfirmDelete = { confirmingDelete = it },
)
} }
// `PullToRefreshBox` would be less code, but it takes no // `PullToRefreshBox` would be less code, but it takes no
// `enabled`, so the modifier and the indicator are wired by // `enabled`, so the modifier and the indicator are wired by
@@ -153,6 +241,16 @@ fun BoardScreen(
} }
} }
} }
confirmingDelete?.let { note ->
ConfirmDeleteDialog(
onConfirm = {
confirmingDelete = null
onNoteAction(note, EditorAction.DeleteForever)
},
onDismiss = { confirmingDelete = null },
)
}
} }
} }
@@ -181,6 +279,25 @@ data class BoardSync(
val onDismissError: () -> Unit, val onDismissError: () -> Unit,
) )
/**
* The waiting app update, or null when there is nothing to say.
*
* A holder rather than five loose parameters, for the same reason [BoardSync] is one:
* `version` and a pair of booleans as positional arguments could be swapped with
* nothing to catch it.
*
* Null covers every reason there is nothing to show — unlinked, up to date, found but
* not yet downloaded, dismissed for this sitting — so the board never has to know
* which.
*/
data class BoardUpdate(
val version: String,
/** An install is in flight — the banner stays and reports it. */
val busy: Boolean,
val onInstall: () -> Unit,
val onDismiss: () -> Unit,
)
/** /**
* A search field IS the top bar, following the phone convention rather than the * A search field IS the top bar, following the phone convention rather than the
* desktop's title-plus-sidebar. * desktop's title-plus-sidebar.
@@ -323,6 +440,9 @@ private fun DrawerRow(
private fun NoteBoard( private fun NoteBoard(
notes: List<Note>, notes: List<Note>,
onOpenNote: (Note) -> Unit, onOpenNote: (Note) -> Unit,
onToggleItem: (Note, Int, Boolean) -> Unit,
onNoteAction: (Note, EditorAction) -> Unit,
onConfirmDelete: (Note) -> Unit,
) { ) {
LazyVerticalStaggeredGrid( LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(BOARD_COLUMNS), columns = StaggeredGridCells.Fixed(BOARD_COLUMNS),
@@ -336,7 +456,13 @@ private fun NoteBoard(
// rebuilding them — and so a newly captured note slides in instead of // rebuilding them — and so a newly captured note slides in instead of
// making every card below it flicker. // making every card below it flicker.
items(items = notes, key = { it.id }) { note -> items(items = notes, key = { it.id }) { note ->
NoteCard(note = note, onOpen = { onOpenNote(note) }) NoteCard(
note = note,
onOpen = { onOpenNote(note) },
onToggleItem = { index, checked -> onToggleItem(note, index, checked) },
onAction = { onNoteAction(note, it) },
onConfirmDelete = { onConfirmDelete(note) },
)
} }
} }
} }
@@ -68,6 +68,16 @@ data class BoardState(
* has to re-query to see its own change. * has to re-query to see its own change.
*/ */
val editing: Note? = null, 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. */ /** Search overrides the destination while there is a query to run. */
val searching: Boolean get() = query.isNotBlank() val searching: Boolean get() = query.isNotBlank()
@@ -188,44 +198,6 @@ 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 ────────────────────────────── // ─────────────────────────────── the editor ──────────────────────────────
/** /**
@@ -238,12 +210,111 @@ class BoardViewModel(
fun openNoteById(id: String) { fun openNoteById(id: String) {
viewModelScope.launch { viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { core.getNote(id) } } runCatching { withContext(Dispatchers.IO) { core.getNote(id) } }
.onSuccess { state = state.copy(editing = it) } .onSuccess { state = state.copy(editing = it, editingSession = state.editingSession + 1) }
} }
} }
fun openNote(note: Note) { fun openNote(note: Note) {
state = state.copy(editing = note) state = state.copy(editing = note, editingSession = state.editingSession + 1)
}
/**
* Open the editor on a note that does not exist yet.
*
* The + button used to raise a separate capture sheet, which meant a note being
* WRITTEN could not be given a colour, a reminder or a checklist — those live on
* the editor's toolbar, and the sheet had none. Writing and editing are now the
* same surface.
*
* The draft is a real [Note] carrying [DRAFT_ID] rather than a null, so the
* editor renders it without knowing that "not saved yet" is a state it can be
* in. It becomes a row on its first save; see [onDraftAction].
*/
fun compose() {
draftDismissed = false
state = state.copy(editing = blankDraft(), editingSession = state.editingSession + 1)
}
/**
* Set when a draft's editor closes, so a create still in flight does not reopen
* it. The editor flushes its text and then closes, and the flush is a coroutine —
* without this the note would be created, the screen would close, and the create
* would finish and put the screen back.
*/
private var draftDismissed = false
/**
* The editor's actions, for a note that has no row yet.
*
* Everything a toolbar button does needs an id to act on, so the first action
* that needs one creates the note and replays itself against the real thing.
*/
private fun onDraftAction(
draft: Note,
action: EditorAction,
) {
when (action) {
// Nothing exists, so leaving leaves nothing behind — which is what makes
// tapping + and changing your mind free. Text typed before this point has
// already gone to createFromDraft via the editor's autosave or its flush.
EditorAction.Close, EditorAction.Trash -> {
draftDismissed = true
state = state.copy(editing = null)
}
EditorAction.DismissError -> dismissError()
is EditorAction.SaveText -> createFromDraft(action.body)
// Colour, reminder, pin, labels: attributes OF a note, so there has to be
// a note. With autosave at a second, "typed something" is true by the time
// anyone reaches the toolbar; before that there is nothing to attribute.
else -> createFromDraft(draft.body) { created -> onEditorAction(created, action) }
}
}
/**
* Turn a draft into a row, and keep the editor on it.
*
* Adopting the created note is what lets a session of autosaves stay one note:
* the second save sees a real id and updates rather than creating again.
*/
private fun createFromDraft(
content: String,
allowEmpty: Boolean = false,
then: (Note) -> Unit = {},
) {
val cleanContent = content.trim()
// A blank draft is not a note. Ignored rather than rejected: tapping + and
// walking away is a slip, not a mistake worth interrupting someone over.
if (cleanContent.isEmpty() && !allowEmpty) return
viewModelScope.launch {
state = state.copy(saving = true)
state =
try {
val created = withContext(Dispatchers.IO) { core.createNote(draft(cleanContent)) }
// Prepend rather than reload: the new note belongs at the top of
// the board, and a full re-query would cost a round trip to tell
// us what we already know. Skipped when the board is not showing
// plain notes — a note created while looking at Trash does not
// belong in that list.
val notes =
if (state.destination == Destination.Notes && !state.searching) {
listOf(created) + state.notes
} else {
state.notes
}
withContext(Dispatchers.IO) { onRemindersChanged() }
// editingSession is NOT bumped: this is the same sitting, and the
// editor's field must not be re-keyed underneath the typing.
state.copy(
notes = notes,
editing = if (draftDismissed) state.editing else created,
saving = false,
error = null,
)
} catch (e: Exception) {
state.copy(saving = false, error = e.message ?: FALLBACK_ERROR)
}
if (!draftDismissed) state.editing?.let(then)
}
} }
/** /**
@@ -268,18 +339,21 @@ class BoardViewModel(
note: Note, note: Note,
action: EditorAction, action: EditorAction,
) { ) {
if (note.id == DRAFT_ID) {
onDraftAction(note, action)
return
}
val id = note.id val id = note.id
when (action) { when (action) {
EditorAction.Close -> state = state.copy(editing = null) EditorAction.Close -> state = state.copy(editing = null)
EditorAction.DismissError -> dismissError() EditorAction.DismissError -> dismissError()
// Saved on close rather than per keystroke, so a session of typing // Sent on an idle debounce while typing, and again on close. Writing
// costs one write and one revision snapshot. // this often is affordable because a body write no longer snapshots a
// revision — the core keeps one per editing session, not one per save.
is EditorAction.SaveText -> is EditorAction.SaveText ->
mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) } 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 // 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 // you often pin while still reading — so unlike the three below, it
// deliberately leaves the editor open. // deliberately leaves the editor open.
@@ -302,20 +376,6 @@ class BoardViewModel(
null 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.SetLabels -> mutate { it.setNoteLabels(id, action.labelIds) }
is EditorAction.CreateLabel -> is EditorAction.CreateLabel ->
@@ -351,9 +411,10 @@ class BoardViewModel(
/** /**
* The one path every store mutation takes. * The one path every store mutation takes.
* *
* Each core mutation returns the reloaded note, which goes straight into * Each core mutation returns the reloaded note, which refreshes
* [BoardState.editing] so an open editor shows its own change without a * [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: * re-query — and does nothing at all when the editor is closed, because that
* field doubles as "which screen is up". The BOARD list is then reloaded rather than patched in place:
* pinning re-sorts it, archiving removes the note from it, and adding a label * 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 * can move it in or out of a label view — a splice would have to reimplement
* the core's ordering and membership rules in Kotlin to get any of that right. * the core's ordering and membership rules in Kotlin to get any of that right.
@@ -389,7 +450,12 @@ class BoardViewModel(
withContext(Dispatchers.IO) { onRemindersChanged() } withContext(Dispatchers.IO) { onRemindersChanged() }
state.copy( state.copy(
notes = notes, notes = notes,
editing = if (closeEditor) null else updated ?: state.editing, // Only REFRESHES an open editor; it must never open one.
// `editing != null` IS "the editor is on screen", so writing
// the reloaded note in unconditionally meant any mutation
// started from the BOARD threw the editor open on top of it —
// which is exactly what ticking a checkbox on a card did.
editing = if (closeEditor) null else state.editing?.let { updated ?: it },
saving = false, saving = false,
error = null, error = null,
) )
@@ -402,6 +468,21 @@ 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() { fun dismissError() {
state = state.copy(error = null) state = state.copy(error = null)
} }
@@ -428,9 +509,6 @@ class BoardViewModel(
} }
} }
/** The palette key a note starts on, matching the web and the desktop. */
private const val DEFAULT_COLOR = "default"
// ── pure builders ─────────────────────────────────────────────────────────── // ── pure builders ───────────────────────────────────────────────────────────
// //
// Neither of these reads or writes view-model state; they only shape a core input // Neither of these reads or writes view-model state; they only shape a core input
@@ -446,4 +524,33 @@ private fun draft(content: String): NoteDraft =
// The core names the note from the body's first line, so a captured thought is // The core names the note from the body's first line, so a captured thought is
// findable without anyone being asked to name it. A checklist is added afterwards, // 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. // in the editor — it is something a note HAS, not a different thing to capture.
NoteDraft(body = content, color = DEFAULT_COLOR, items = null) 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,
)
@@ -1,130 +0,0 @@
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
@@ -0,0 +1,102 @@
package com.fabledsword.thoughtsync.ui
// The colour a LABEL wears when nobody picked one for it.
//
// Every `#tag` is born colourless, so without this a board of tags is a board of
// identical grey chips. Hashing the tag's NAME is deterministic, identical on every
// surface, costs no column and no migration, and a tag keeps its colour for life.
//
// THIS WAS THE CARD'S COLOUR TOO, ONCE. It is not any more (M315): a note's fill is
// one neutral and only its tags carry hue. The hash survived that removal because the
// job it still does — give a name a stable colour — was never the job that failed.
// What failed was asking a colour that means "which tag" to also mean nothing at all
// on an untagged note, at which point the board had two vocabularies and neither read.
//
// THIS IS HALF A MIRRORED PAIR. `frontend/src/notes/colors.ts` computes the same hash
// over the same key order, and the two must agree exactly or a tag is one colour on
// the phone and another in the browser. Same discipline as the checklist grammar's
// three implementations, and the same reason: a value that disagrees across surfaces
// is a bug you cannot unsee and cannot explain.
//
// NO COMPOSE IN THIS FILE, deliberately. It is the half of the pair that CAN be
// pinned by a host-JVM test, and staying free of `androidx.compose` is what keeps
// `DerivedTintTest` runnable in the Unit tests step rather than on an emulator. The
// web side has no test runner at all, so this test is the only mechanical guard the
// mirror gets — see the fixture comment in colors.ts.
/**
* The colours a derived hue can land on: `NOTE_TINTS`' keys minus `default`, which is
* the ABSENCE of a colour — a tag that derived it would be indistinguishable from one
* nobody has tagged. `gray` stays: as a chip it reads as a deliberate choice.
*
* Order is load-bearing and matches `DERIVED_TINT_KEYS` in colors.ts. Reordering this
* list silently recolours every tag on one surface only.
*/
val DERIVED_TINT_KEYS: List<String> =
listOf("red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray")
private const val FNV_OFFSET_BASIS = -0x7ee3623b // 0x811c9dc5 as a signed Int
private const val FNV_PRIME = 0x01000193
private const val BYTE_MASK = 0xFF
private const val UNSIGNED_MASK = 0xFFFFFFFFL
/**
* FNV-1a over the id's bytes, 32-bit.
*
* Chosen because both languages compute it identically in ten lines with no library.
* Explicitly NOT `String.hashCode()`: Kotlin's is specified but JS has no equivalent,
* and reimplementing Java's from memory in TypeScript is exactly how a mirror drifts.
*
* `and BYTE_MASK` is a no-op for the ASCII of a UUID, and is kept because it states
* the intent — this hashes BYTES, so the TypeScript side reading `charCodeAt(i) &
* 0xff` is the same function rather than a coincidence.
*
* Overflow is the point: Kotlin's `Int` wraps on multiply, which is what the web's
* `Math.imul` exists to reproduce.
*/
fun tintHash(id: String): Int {
var hash = FNV_OFFSET_BASIS
for (ch in id) {
hash = hash xor (ch.code and BYTE_MASK)
hash *= FNV_PRIME
}
return hash
}
/** The colour a name maps to, stable for as long as the name is. Called with a
* label's lowercased name; `id` is the parameter's history, not its meaning. */
fun derivedTint(id: String): String {
// Through Long to read the hash as unsigned. A signed remainder would be negative
// for half of all ids and index out of the list.
val index = (tintHash(id).toLong() and UNSIGNED_MASK) % DERIVED_TINT_KEYS.size
return DERIVED_TINT_KEYS[index.toInt()]
}
/**
* The colour key for a LABEL — its chip, and its `#tag` where it sits in the prose.
*
* Derived from the tag's NAME when nobody has picked one. Every `#tag` ever typed is
* `default`: the server mints one as `Label(owner_id=…, name=name)` with no colour,
* so without deriving, a board of tags would be a board of identical grey chips.
*
* DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing #2965's plan. That plan wanted
* a hashed colour written at each of the four places a label can be born — and named
* the risk itself: `find_or_create_label` is "easy to miss, and it is the common one",
* since most tags are born from typing `#grocery`, not from a management screen.
* Deriving has no mint points to miss and no backfill for the tags already out there.
* The cost is that renaming a tag recolours it, which is fair: the name IS the tag.
*
* Lowercased because tags dedupe case-insensitively — `#Todo` renamed to `#todo` is
* the same tag and should not change colour. Kotlin's `lowercase()` and the web's
* `toLowerCase()` are both locale-independent, so the mirror holds.
*/
fun resolvedLabelColor(
name: String,
color: String,
known: Set<String>,
): String =
when {
color.isNotEmpty() && color != "default" && color in known -> color
name.isEmpty() -> "default"
else -> derivedTint(name.lowercase())
}
@@ -25,19 +25,6 @@ sealed interface EditorAction {
val body: String, val body: String,
) : EditorAction ) : 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( data class SetPinned(
val pinned: Boolean, val pinned: Boolean,
) : EditorAction ) : EditorAction
@@ -52,23 +39,12 @@ sealed interface EditorAction {
data object DeleteForever : EditorAction data object DeleteForever : EditorAction
data class AddItem( // No checklist actions at all any more (M304). An item is a `- [ ] ` line of the
val text: String, // body, so adding, renaming, ticking or deleting one is editing text — which the
) : EditorAction // 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
data class SetItemChecked( // meant the store handing back a note whose body disagreed with the field the
val itemId: String, // person was typing in.
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. * The note's MANUAL labels, replacing whatever was there.
@@ -0,0 +1,189 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.runtime.saveable.Saver
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.fabledsword.thoughtsync.core.checklistItems
import com.fabledsword.thoughtsync.core.checklistRender
/**
* One piece of a note body, as the editor DRAWS it.
*
* The note is still one markdown string underneath (M304) — this is a rendering and
* input shape, and nothing below the editor can tell it exists. [joinBlocks] puts the
* string back together on every edit.
*
* A run of prose lines is ONE block rather than one per line. Typing a paragraph has
* to feel like typing a paragraph, and a separate field under every sentence would
* break the caret in the middle of writing. Only a checklist item earns a block of its
* own, because only a checklist item needs a widget.
*
* The block owns its [TextFieldValue], not just its text, so a caret survives an edit
* to some other block. And [id] is stable across edits: Compose keys fields by
* position unless told otherwise, so inserting an item above one would otherwise move
* everyone's caret up a row. Content cannot serve as that key — two empty items are
* identical and neither is the other.
*/
data class EditorBlock(
val id: Long,
val value: TextFieldValue,
/** null for prose; ticked-or-not for a checklist item. */
val checked: Boolean?,
) {
val isTask: Boolean get() = checked != null
}
/**
* Split a body into blocks, numbering them from [firstId].
*
* Which lines are items comes from the core, not from a pattern here — the grammar is
* written three times already and Kotlin is not going to be the fourth.
*/
fun splitBlocks(
body: String,
firstId: Long = 0,
): List<EditorBlock> {
val itemAt = checklistItems(body).associateBy { it.line.toInt() }
val out = mutableListOf<EditorBlock>()
val prose = mutableListOf<String>()
var id = firstId
fun flushProse() {
if (prose.isNotEmpty()) {
out += EditorBlock(id++, TextFieldValue(prose.joinToString("\n")), null)
prose.clear()
}
}
body.split("\n").forEachIndexed { n, line ->
val item = itemAt[n]
if (item == null) {
prose += line
} else {
flushProse()
out += EditorBlock(id++, TextFieldValue(item.text), item.checked)
}
}
flushProse()
// Never empty: an empty note still needs one field to type into.
return out.ifEmpty { listOf(EditorBlock(id, TextFieldValue(""), null)) }
}
/**
* The body those blocks stand for — byte-identical to what [splitBlocks] was given,
* for a body already in canonical form. A non-canonical one (`- [X]`, an odd bullet)
* comes back canonical, which is the same rule every other rewriter in `derive`
* follows.
*/
fun joinBlocks(blocks: List<EditorBlock>): String =
blocks.joinToString("\n") { block ->
val checked = block.checked
if (checked == null) block.value.text else checklistRender(block.value.text, checked)
}
/**
* Rotation carries the TEXT and re-derives the shape.
*
* Blocks are not parcelable and their ids are meaningless across a process death, so
* the body string is the honest thing to save — it is the real state, and everything
* else about a block is derived from it.
*/
val blocksSaver: Saver<List<EditorBlock>, String> =
Saver(save = { joinBlocks(it) }, restore = { splitBlocks(it) })
/**
* What the return key does on a checklist item.
*
* `internal` rather than private because BlockBody.kt calls it. These three helpers
* are the block MODEL and the composables are the block UI — one file was doing both,
* which detekt noticed by counting functions before anybody noticed by reading.
*
* On one with words in it, a new empty item below. On an EMPTY one, the item becomes
* prose — which is how a list ENDS, and the same rule the plain text field used
* before this: without it a list is impossible to get out of.
*
* Deliberately appends rather than splitting at the caret. Splitting an item in two is
* a rarity, and the caret is at the end for every ordinary use of this key.
*/
internal fun afterEnter(
blocks: List<EditorBlock>,
index: Int,
newId: Long,
): List<EditorBlock> {
val block = blocks[index]
val out = blocks.toMutableList()
if (block.value.text.isBlank()) {
out[index] = block.copy(value = TextFieldValue(""), checked = null)
} else {
out.add(index + 1, EditorBlock(newId, TextFieldValue(""), false))
}
return out
}
/** Drop a block, leaving at least one field to type into. */
internal fun List<EditorBlock>.withoutIndex(index: Int): List<EditorBlock> {
val out = toMutableList().also { it.removeAt(index) }
return out.ifEmpty { listOf(EditorBlock(nextId(), TextFieldValue(""), null)) }
}
/** An id nothing else is using. Monotonic within a session, which is all it has to be. */
internal fun List<EditorBlock>.nextId(): Long = (maxOfOrNull { it.id } ?: -1L) + 1L
/**
* One more empty checklist item at the end, and the id to put the caret in.
*
* What the toolbar's checklist button does. It appends rather than inserting at the
* caret because a block editor has no single caret to insert at — the field that had
* focus may not even be the one being looked at by the time this runs.
*/
fun List<EditorBlock>.plusTask(): Pair<List<EditorBlock>, Long> {
val id = nextId()
return (this + EditorBlock(id, TextFieldValue(""), false)) to id
}
/**
* Re-read ONE prose block for `- [ ] ` lines somebody typed by hand.
*
* [splitBlocks] runs once, when the editor opens. After that the blocks are the state
* and nothing reads the body again — every edit travels the other way, through
* [joinBlocks]. So a marker typed by hand stayed literal text on screen until the note
* was closed and reopened, even though it was already a real item in storage and the
* card was already drawing a checkbox for it. The editor was the only place that
* disagreed.
*
* **On blur, and only the block being left.** There is no good moment to convert while
* someone is typing: re-splitting on a keystroke moves the caret out of the word being
* written, and converting the instant `- [ ]` is complete does it before the item has
* any text. Blur is the one moment the person has demonstrably finished with the block,
* so a re-split costs no caret and cannot catch a half-typed line.
*
* Returns THIS LIST, not an equal copy, when there was nothing to promote — the caller
* leans on that to leave the state alone, and a blur that changed nothing must not
* re-key every field below it.
*
* Non-canonical markers (`- [X]`, an odd bullet) come back canonical, exactly as they
* would have on reopen. That is the only case where this changes the body rather than
* only the way it is drawn.
*/
internal fun List<EditorBlock>.promotingTasks(index: Int): List<EditorBlock> {
val block = getOrNull(index)
if (block == null || block.isTask) return this
val split = splitBlocks(block.value.text, nextId())
// A single prose block back means there was nothing to promote. `splitBlocks` never
// returns an empty list, so `first()` is safe.
val changed = split.size > 1 || split.first().isTask
return if (changed) take(index) + split + drop(index + 1) else this
}
/**
* Put the caret at the end of the last block, for an editor that has just opened.
*
* Opening an existing note means continuing it, and a caret at offset zero would put
* the cursor before the first character of the wrong field.
*/
fun List<EditorBlock>.focusedAtEnd(): List<EditorBlock> {
if (isEmpty()) return this
val last = last()
return dropLast(1) + last.copy(value = last.value.copy(selection = TextRange(last.value.text.length)))
}
@@ -1,144 +0,0 @@
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 = ""
}),
)
}
}
@@ -1,6 +1,6 @@
package com.fabledsword.thoughtsync.ui package com.fabledsword.thoughtsync.ui
import androidx.annotation.StringRes import android.text.format.DateUtils
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -8,23 +8,29 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
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.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons 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.automirrored.filled.List
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -39,7 +45,19 @@ import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Note import com.fabledsword.thoughtsync.core.Note
/** /**
* The editor's action bar, at the bottom where a thumb already is. * 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 * The three affordances with a permanent slot are the ones reached for while still
* writing — colour, reminder, note-or-list. Everything structural (pin, labels, * writing — colour, reminder, note-or-list. Everything structural (pin, labels,
@@ -54,57 +72,184 @@ import com.fabledsword.thoughtsync.core.Note
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun EditorBottomBar( fun EditorTopBar(
note: Note, note: Note,
readOnly: Boolean, readOnly: Boolean,
tint: NoteTint, onClose: () -> Unit,
onStartChecklist: () -> Unit,
onPicker: (Picker) -> Unit, onPicker: (Picker) -> Unit,
onConfirmDelete: () -> Unit, onConfirmDelete: () -> Unit,
onAction: (EditorAction) -> Unit, onAction: (EditorAction) -> Unit,
) { ) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
BottomAppBar(containerColor = tint.background(dark)) { TopAppBar(
if (!readOnly) { title = {},
// A dot in the note's CURRENT colour rather than a palette icon: it navigationIcon = {
// shows what the colour is as well as what the button does. // The only way out, and the only thing that needed a "save" button
IconButton(onClick = { onPicker(Picker.COLOR) }) { // before writes became continuous. Leaving IS saving now, which is what
Box( // the line in the bottom corner is there to say out loud.
modifier = IconButton(onClick = onClose) {
Modifier
.size(SWATCH_DOT)
.clip(CircleShape)
.background(tint.chipBackground(dark))
.border(1.dp, tint.border(dark), CircleShape),
)
}
IconButton(onClick = { onPicker(Picker.REMINDER) }) {
Icon( Icon(
Icons.Filled.Notifications, Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.editor_reminder), contentDescription = stringResource(R.string.editor_back),
) )
} }
// Adds the first checklist item, which is what makes the checklist },
// editor appear. Hidden once the note already has one — there is nothing actions = {
// left to add that the checklist's own "+" row doesn't do better. if (!readOnly) {
if (note.items.isEmpty()) { IconButton(onClick = { onPicker(Picker.REMINDER) }) {
IconButton(onClick = { onAction(EditorAction.AddChecklist) }) { 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( Icon(
Icons.AutoMirrored.Filled.List, Icons.AutoMirrored.Filled.List,
contentDescription = stringResource(R.string.editor_add_checklist), 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,
),
)
}
Box(modifier = Modifier.weight(1f)) /**
* The footer: when the note was last written, and the way out.
OverflowMenu( *
note = note, * **Where the note stands.** There is no save button, and there should not be — a
readOnly = readOnly, * note is saved continuously, so a button offering to do what already happened is a
onPicker = onPicker, * lie with a tap attached. But that left nothing on screen saying the work is safe,
onConfirmDelete = onConfirmDelete, * and "closing this keeps it" is not a thing anyone should have to be told twice. So
onAction = onAction, * 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(),
)
} }
} }
@@ -142,24 +287,6 @@ private fun OverflowMenu(
} }
} }
@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. * The note's labels, each removable.
* *
@@ -177,19 +304,23 @@ fun EditorLabelRow(
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
Column(modifier = Modifier.padding(top = 12.dp)) { Column(modifier = Modifier.padding(top = 12.dp)) {
note.labels.forEach { label -> note.labels.forEach { label ->
val tint = noteTint(label.color) val tint = labelTintFor(label.name, label.color)
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 2.dp), modifier = Modifier.padding(vertical = 2.dp),
) { ) {
Text( Text(
text = label.name, // `#` 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, style = MaterialTheme.typography.labelLarge,
color = tint.chipForeground(dark), color = tint.tagInk(dark),
modifier = modifier =
Modifier Modifier
.clip(CircleShape) .clip(CircleShape)
.background(tint.chipBackground(dark)) .background(tint.chipBackground(dark))
.border(1.dp, tint.chipBorder(dark), CircleShape)
.padding(horizontal = 10.dp, vertical = 4.dp), .padding(horizontal = 10.dp, vertical = 4.dp),
) )
if (label.viaTag) { if (label.viaTag) {
@@ -263,6 +394,5 @@ fun EditorReminderRow(
} }
} }
private val SWATCH_DOT = 22.dp
private const val SNOOZE_HOUR = 60L private const val SNOOZE_HOUR = 60L
private const val SNOOZE_DAY = 1440L private const val SNOOZE_DAY = 1440L
@@ -1,11 +1,7 @@
package com.fabledsword.thoughtsync.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.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -13,21 +9,16 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions 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.AlertDialog
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -42,7 +33,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -57,80 +47,17 @@ import java.time.LocalTime
import java.time.ZoneId import java.time.ZoneId
import java.time.temporal.TemporalAdjusters import java.time.temporal.TemporalAdjusters
// The three things you pick rather than type: a colour, a set of labels, a time. // The two things you pick rather than type: a set of labels, and a time.
//
// It was three. The colour sheet went with `note.color` in M315 — a card is one neutral
// surface now and colour lives on the tag, so the swatch grid was a control with nothing
// behind it.
// //
// All bottom sheets rather than dialogs. A dialog takes the middle of the screen // All bottom sheets rather than dialogs. A dialog takes the middle of the screen
// and asks to be dismissed; a sheet rises from the bottom, under the thumb, with // and asks to be dismissed; a sheet rises from the bottom, under the thumb, with
// the note still visible above it — which matters when the choice you are making // the note still visible above it — which matters when the choice you are making
// is about the thing you are looking at. // 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. * Every label, ticked where it is on the note.
* *
@@ -461,8 +388,6 @@ private val RECURRENCE_RULES: List<Pair<String?, Int>> =
"yearly" to R.string.recurrence_yearly, "yearly" to R.string.recurrence_yearly,
) )
private const val SWATCHES_PER_ROW = 5
private const val EVENING_HOUR = 18 private const val EVENING_HOUR = 18
private const val MORNING_HOUR = 8 private const val MORNING_HOUR = 8
private val SWATCH_SIZE = 44.dp
private val LABEL_LIST_MAX_HEIGHT = 320.dp private val LABEL_LIST_MAX_HEIGHT = 320.dp
@@ -3,8 +3,10 @@ package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -12,122 +14,352 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource 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.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.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.ChecklistItem import com.fabledsword.thoughtsync.core.BodyItem
import com.fabledsword.thoughtsync.core.Note import com.fabledsword.thoughtsync.core.Note
import com.fabledsword.thoughtsync.core.NoteLabel import com.fabledsword.thoughtsync.core.NoteLabel
import com.fabledsword.thoughtsync.core.bodyTags
import com.fabledsword.thoughtsync.core.checklistItems
@Composable @Composable
fun NoteCard( fun NoteCard(
note: Note, note: Note,
onOpen: () -> Unit, onOpen: () -> Unit,
onToggleItem: (Int, Boolean) -> Unit,
onAction: (EditorAction) -> Unit,
onConfirmDelete: () -> Unit,
) { ) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val tint = noteTint(note.color) val haptics = LocalHapticFeedback.current
var menuOpen by remember { mutableStateOf(false) }
Column( // NAMED rather than written inline in the chain below, and not for taste: ktlint's
modifier = // chain-method-continuation wants the next `.` glued to the closing paren of a
Modifier // multiline element — `).background(…)` — which is worse to read than a modifier
.fillMaxWidth() // with a name. Every other multiline element in this codebase happens to be last
// Clipped BEFORE clickable, so the ripple is bounded by the card's // in its chain, so this is the first place the rule bites.
// rounded corners instead of a rectangle overhanging them. val opening =
.clip(RoundedCornerShape(CARD_RADIUS)) Modifier.combinedClickable(
.clickable(onClickLabel = stringResource(R.string.board_open_note), onClick = onOpen) onClickLabel = stringResource(R.string.board_open_note),
.background(tint.background(dark)) onLongClickLabel = stringResource(R.string.board_note_actions),
.border(1.dp, tint.border(dark), RoundedCornerShape(CARD_RADIUS)) onLongClick = {
.padding(12.dp), // 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
// Body then checklist, in order — a note can carry both (M13 step 2), and // point of the buzz is to say "that registered" at the moment your
// nothing above them: the first line of the body IS the note's name, at the // finger has been still long enough — a menu that arrives with no tick
// same weight as the rest of it (M13 steps 3 and 4). // under it reads as a phone that missed the gesture and then changed
if (note.body.isNotBlank()) { // its mind.
Text( haptics.performHapticFeedback(HapticFeedbackType.LongPress)
text = note.body, menuOpen = true
style = MaterialTheme.typography.bodyMedium, },
maxLines = MAX_PREVIEW_LINES, onClick = onOpen,
overflow = TextOverflow.Ellipsis, )
)
} // The Box exists only to anchor the menu. A DropdownMenu is a popup and takes no
if (note.items.isNotEmpty()) { // space, so the card's size is still the Column's.
if (note.body.isNotBlank()) Spacer(Modifier.height(4.dp)) Box {
Checklist(items = note.items) Column(
modifier =
Modifier
.fillMaxWidth()
// Depth, not the boundary — the edge below is that. 1dp: enough to
// separate a white card from a #fafafa board, and the web's own
// `shadow-sm` is the value it is matching.
.shadow(CARD_ELEVATION, RoundedCornerShape(CARD_RADIUS))
// Clipped BEFORE the click modifier, so the ripple is bounded by the
// card's rounded corners instead of a rectangle overhanging them —
// and BEFORE padding, so the padded edge is still a tap target.
.clip(RoundedCornerShape(CARD_RADIUS))
.then(opening)
// ONE surface and ONE edge on every card, both neutral, neither
// asking the note anything. See noteCardSurface and CARD_EDGE_DARK.
.background(noteCardSurface(dark))
.border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS))
.padding(12.dp),
) {
// TAGS FIRST. They used to sit under everything else, which on a tall note put
// the one thing that says what a note IS below the fold of a glance. A board is
// scanned, not read, and the answer to "which of these is about the thing I am
// looking for" should be the first thing the eye lands on rather than the last.
//
// Above the body rather than beside it, because the body's first line is the
// note's NAME (M13 steps 3 and 4) and a chip floated next to it would compete
// with the thing that identifies the note. A row of its own costs one line and
// only on notes that have tags at all.
//
// ONLY the labels whose text is not still in the note. `via_tag` means exactly
// "backed by body text" since M311, so a chip for one printed the same tag
// twice — once where it was typed, once up here — and the card was carrying
// furniture for information it was already showing. A tag left in prose is
// tinted in place instead; see [tintTags]. What reaches this row is what the
// body cannot say: a tag lifted off its own line, and a label added by hand.
val chips = note.labels.filterNot { it.viaTag }
if (chips.isNotEmpty()) {
LabelChips(labels = chips)
Spacer(Modifier.height(8.dp))
}
// Body then checklist, in order — a note can carry both (M13 step 2). The
// first line of the body IS the note's name, at the same weight as the rest of
// it (M13 steps 3 and 4).
if (note.body.isNotBlank()) {
NoteBody(note = note, onToggleItem = onToggleItem)
}
// A note with nothing in it still has to occupy the board legibly — otherwise
// it reads as a rendering bug.
if (note.body.isBlank()) {
Text(
text = stringResource(R.string.board_empty_note),
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
note.remindAt?.let { at ->
Spacer(Modifier.height(8.dp))
ReminderChip(instant = at, recurrence = note.recurrence)
}
} }
// A note with no body and no items still has to occupy the board legibly — NoteMenu(
// otherwise it reads as a rendering bug. note = note,
if (note.body.isBlank() && note.items.isEmpty()) { expanded = menuOpen,
Text( onDismiss = { menuOpen = false },
text = stringResource(R.string.board_empty_note), onAction = onAction,
style = MaterialTheme.typography.bodyMedium, onConfirmDelete = onConfirmDelete,
fontStyle = FontStyle.Italic, )
color = MaterialTheme.colorScheme.onSurfaceVariant, }
) }
}
if (note.labels.isNotEmpty()) { /**
Spacer(Modifier.height(8.dp)) * What you can do to a note without opening it.
LabelChips(labels = note.labels) *
} * 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
note.remindAt?.let { at -> * app I have no way to delete notes."* Trash was three interactions deep (open, ⋮,
Spacer(Modifier.height(8.dp)) * Move to trash), and on a phone that is far enough from the gesture people reach
ReminderChip(instant = at, recurrence = note.recurrence) * 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 @Composable
private fun Checklist(items: List<ChecklistItem>) { 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)) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
items.take(MAX_CHECKLIST_ROWS).forEach { item -> lines.take(MAX_PREVIEW_LINES).forEachIndexed { n, line ->
Row(verticalAlignment = Alignment.Top) { val found = itemAtLine[n]
// A glyph rather than a real Checkbox: the card is a PREVIEW, and when {
// a live control here would invite taps that the board cannot yet found != null ->
// honour. It becomes interactive with the editor. ChecklistRow(note, found.second) { onToggleItem(found.first, !found.second.checked) }
Text( // Kept as a gap rather than dropped: it is the paragraph break
text = if (item.checked) "" else "", // somebody typed, and the card reads as a wall without it.
style = MaterialTheme.typography.bodyMedium, line.isBlank() -> Spacer(Modifier.height(4.dp))
modifier = Modifier.padding(end = 6.dp), else ->
) Text(
Text( text = tintTags(line, note),
text = item.text, style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodyMedium, maxLines = MAX_WRAPPED_LINES,
textDecoration = if (item.checked) TextDecoration.LineThrough else null, overflow = TextOverflow.Ellipsis,
color = )
if (item.checked) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
} }
} }
val hidden = items.size - MAX_CHECKLIST_ROWS if (lines.size > MAX_PREVIEW_LINES) {
if (hidden > 0) {
Text( Text(
text = pluralStringResource(R.plurals.board_more_items, hidden, hidden), text = "",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
) )
} }
} }
} }
/**
* 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 @Composable
private fun LabelChips(labels: List<NoteLabel>) { private fun LabelChips(labels: List<NoteLabel>) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
@@ -135,17 +367,22 @@ private fun LabelChips(labels: List<NoteLabel>) {
// not grow taller than its content. The editor shows the full set. // not grow taller than its content. The editor shows the full set.
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
labels.take(MAX_LABEL_CHIPS).forEach { label -> labels.take(MAX_LABEL_CHIPS).forEach { label ->
val tint = noteTint(label.color) val tint = labelTintFor(label.name, label.color)
Text( Text(
text = label.name, // 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, style = MaterialTheme.typography.labelSmall,
color = tint.chipForeground(dark), color = tint.tagInk(dark),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = modifier =
Modifier Modifier
.clip(RoundedCornerShape(CHIP_RADIUS)) .clip(RoundedCornerShape(CHIP_RADIUS))
.background(tint.chipBackground(dark)) .background(tint.chipBackground(dark))
.border(1.dp, tint.chipBorder(dark), RoundedCornerShape(CHIP_RADIUS))
.padding(horizontal = 6.dp, vertical = 2.dp), .padding(horizontal = 6.dp, vertical = 2.dp),
) )
} }
@@ -182,7 +419,91 @@ private fun ReminderChip(
} }
private const val MAX_PREVIEW_LINES = 8 private const val MAX_PREVIEW_LINES = 8
private const val MAX_CHECKLIST_ROWS = 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 const val MAX_LABEL_CHIPS = 3
private val CARD_RADIUS = 12.dp 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 private val CHIP_RADIUS = 6.dp
@@ -1,69 +1,89 @@
package com.fabledsword.thoughtsync.ui package com.fabledsword.thoughtsync.ui
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize 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.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll 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.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.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Surface
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Label import com.fabledsword.thoughtsync.core.Label
import com.fabledsword.thoughtsync.core.Note import com.fabledsword.thoughtsync.core.Note
import kotlinx.coroutines.delay
/** /**
* The note editor: a full screen, not a sheet. * The one writing surface: a new note and an existing one are the same screen.
* *
* A sheet works for capture, where the board behind it is reassurance that the * Shaped like the capture sheet it replaced — a rounded card that begins below the
* thought landed somewhere. Editing is different — a sustained task with the * status bar — so opening a note still reads as something rising over the board
* keyboard up — and a sheet would spend the whole time fighting the IME for the * rather than a place you navigated to. It is full height rather than a real
* bottom half of the display. Full screen also gives the actions a bottom bar, * `ModalBottomSheet`, and that is the whole trade: a sheet spends a writing session
* which is where a thumb already is. * 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 note's own colour paints the WHOLE screen rather than a card inside it, so * The card's surface paints the WHOLE sheet rather than a panel inside it, so opening
* opening a note reads as the same object growing to fill the display. * 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun NoteEditorScreen( fun NoteEditorScreen(
note: Note, note: Note,
sessionKey: Long,
labels: List<Label>, labels: List<Label>,
saving: Boolean, saving: Boolean,
error: String?, error: String?,
onAction: (EditorAction) -> Unit, onAction: (EditorAction) -> Unit,
) { ) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val tint = noteTint(note.color)
// Keyed by note id: the editor is reused across notes, and without the key the // Keyed by the SESSION, not by note.id: the editor is reused across notes, so it
// second note opened would show the first one's text. // needs a key — but a draft's id changes the moment it is first saved, and
var body by remember(note.id) { mutableStateOf(note.body) } // re-keying on that would reset this state to whatever the store just returned,
var picker by remember(note.id) { mutableStateOf(Picker.NONE) } // throwing away every character typed during the write.
var confirmingDelete by remember(note.id) { mutableStateOf(false) } //
// 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 // A note in the trash is a record, not a document: editing one would silently
// resurrect work that was meant to be thrown away. It renders read-only, with // resurrect work that was meant to be thrown away. It renders read-only, with
@@ -75,8 +95,8 @@ fun NoteEditorScreen(
// would bump `updated_at`, mark the note dirty for sync, and snapshot a // would bump `updated_at`, mark the note dirty for sync, and snapshot a
// revision identical to the one before it. // revision identical to the one before it.
val flush = { val flush = {
if (!readOnly && body != note.body) { if (!readOnly && bodyText != note.body) {
onAction(EditorAction.SaveText(body)) onAction(EditorAction.SaveText(bodyText))
} }
} }
val leave = { val leave = {
@@ -84,6 +104,32 @@ fun NoteEditorScreen(
onAction(EditorAction.Close) 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) BackHandler(onBack = leave)
// Leaving the APP is not closing the editor, so the text has to be saved // Leaving the APP is not closing the editor, so the text has to be saved
@@ -91,83 +137,113 @@ fun NoteEditorScreen(
// is exactly the failure that makes someone stop trusting a notes app. // is exactly the failure that makes someone stop trusting a notes app.
FlushOnStop(flush) FlushOnStop(flush)
Scaffold( // The sheet shape, kept. `windowInsetsPadding` both insets the card below the
containerColor = tint.background(dark), // status bar AND consumes that inset, so the bar inside adds no second gap of
topBar = { // its own — the strip above the rounded corner is what makes this read as a card
TopAppBar( // over the board rather than a screen that replaced it.
title = {}, Box(
navigationIcon = { modifier =
IconButton(onClick = leave) { Modifier
Icon( .fillMaxSize()
Icons.AutoMirrored.Filled.ArrowBack, .windowInsetsPadding(WindowInsets.statusBars),
contentDescription = stringResource(R.string.editor_back), ) {
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) },
) )
} }
},
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 // A note is its body; its NAME is that body's first line, so there
// banner, but a write that fails while the editor is open would // is nothing separate to type into and nothing rendered bolder than
// otherwise report itself only after the user had already left. // the line beneath it (M13 steps 3 and 4). What 2992 changed is only
error?.let { message -> // how the body is DRAWN — checklist items as boxes rather than as
ErrorBanner(message = message, onDismiss = { onAction(EditorAction.DismissError) }) // the markup for boxes.
} BlockBody(
blocks = blocks,
readOnly = readOnly,
focus = focus,
onChange = { blocks = it },
onFocus = { focus = it },
)
// One field. A note is its body; its NAME is that body's first line, so // No checklist section. The items ARE lines of the field above
// there is nothing separate to type into and nothing to render bolder // (M304) — rendering them again down here is what would put every
// than the line beneath it (M13 steps 3 and 4). // list on screen twice.
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 — if (note.labels.isNotEmpty()) {
// the toolbar's add-checklist action is what puts the first one there. EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction)
if (note.items.isNotEmpty()) { }
ChecklistEditor(note = note, readOnly = readOnly, onAction = onAction)
}
if (note.labels.isNotEmpty()) { note.remindAt?.let { at ->
EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction) EditorReminderRow(
} at = at,
recurrence = note.recurrence,
note.remindAt?.let { at -> readOnly = readOnly,
EditorReminderRow( onAction = onAction,
at = at, )
recurrence = note.recurrence, }
readOnly = readOnly, }
onAction = onAction,
)
} }
} }
} }
@@ -181,31 +257,18 @@ fun NoteEditorScreen(
) )
if (confirmingDelete) { if (confirmingDelete) {
// The only irreversible action in the app earns the only confirmation in ConfirmDeleteDialog(
// it. Everything else — archive, trash, even unlinking a server — undoes. onConfirm = {
AlertDialog( confirmingDelete = false
onDismissRequest = { confirmingDelete = false }, onAction(EditorAction.DeleteForever)
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))
}
}, },
onDismiss = { confirmingDelete = false },
) )
} }
} }
/** Which overlay is open. One at a time, so they cannot stack on a phone screen. */ /** Which overlay is open. One at a time, so they cannot stack on a phone screen. */
enum class Picker { NONE, COLOR, LABELS, REMINDER } enum class Picker { NONE, LABELS, REMINDER }
/** The pickers, hoisted out so the screen above reads as a layout rather than a switch. */ /** The pickers, hoisted out so the screen above reads as a layout rather than a switch. */
@Composable @Composable
@@ -219,15 +282,6 @@ private fun EditorOverlays(
val dismiss = { onPicker(Picker.NONE) } val dismiss = { onPicker(Picker.NONE) }
when (picker) { when (picker) {
Picker.NONE -> Unit Picker.NONE -> Unit
Picker.COLOR ->
ColorSheet(
selected = note.color,
onPick = {
onAction(EditorAction.SetColor(it))
dismiss()
},
onDismiss = dismiss,
)
Picker.LABELS -> Picker.LABELS ->
LabelSheet( LabelSheet(
note = note, note = note,
@@ -245,32 +299,16 @@ private fun EditorOverlays(
} }
/** /**
* The note's body field. * How long typing has to stop before the note is written.
* *
* Undecorated, via the shared [PlainTextField]: the screen is already painted in * Long enough that a normal sentence is one write, short enough that nothing
* the note's colour, and a filled field would draw a second surface over the first * meaningful is at risk if the app dies. The flush on close and [FlushOnStop] still
* and turn a note into a form. * cover the window between the last keystroke and this elapsing.
*
* 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 const val AUTOSAVE_IDLE_MS = 1_000L
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 /**
* 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
@@ -5,17 +5,23 @@ import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
/** /**
* The note colour palette, matching `frontend/src/notes/colors.ts` VALUE FOR VALUE. * The 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 * A colour is stored by the core as a key ("red", "teal", …) and every surface
* surface resolves it to its own tints. The web app resolves through Tailwind * resolves it to its own tints. The web app resolves through Tailwind classes; this
* classes; this table is those same Tailwind colours as literals, so a note that * table is those same Tailwind colours as literals, so a tag that is amber on the
* is amber on the desktop is the same amber on the phone rather than a near-miss. * desktop is the same amber on the phone rather than a near-miss. Generated from
* Generated from tailwindcss 3.4's palette rather than transcribed by eye. * 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 * Dark tints keep the web's ALPHA instead of a precomputed blend — Compose composites
* precomputed blend — Compose composites a translucent colour over what's beneath * a translucent colour over what's beneath exactly as CSS does, so a panel sits on the
* exactly as CSS does, so the card sits on the background the same way in both. * background the same way in both.
*
* This table is the palette of MEANINGFUL colours: a tag's. A NOTE no longer has one
* 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 * `yellow` maps to Tailwind's *amber*, matching colors.ts; plain yellow is too
* acid against the neutral surfaces. * acid against the neutral surfaces.
@@ -27,19 +33,102 @@ data class NoteTint(
val darkBackground: Color, val darkBackground: Color,
val darkBorder: Color, val darkBorder: Color,
val lightChipBackground: Color, val lightChipBackground: Color,
val lightChipForeground: Color,
val darkChipBackground: 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, 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 background(dark: Boolean): Color = if (dark) darkBackground else lightBackground
fun border(dark: Boolean): Color = if (dark) darkBorder else lightBorder fun border(dark: Boolean): Color = if (dark) darkBorder else lightBorder
fun chipBackground(dark: Boolean): Color = if (dark) darkChipBackground else lightChipBackground 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 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. */ /** Keyed by the core's colour vocabulary. Order matches the web's picker. */
val NOTE_TINTS: Map<String, NoteTint> = val NOTE_TINTS: Map<String, NoteTint> =
mapOf( mapOf(
@@ -54,6 +143,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF525252), lightChipForeground = Color(0xFF525252),
darkChipBackground = Color(0x1AFFFFFF), darkChipBackground = Color(0x1AFFFFFF),
darkChipForeground = Color(0xFFD4D4D4), darkChipForeground = Color(0xFFD4D4D4),
lightTagInk = Color(0xFF404040),
darkTagInk = Color(0xFFD4D4D4),
), ),
"red" to "red" to
NoteTint( NoteTint(
@@ -66,6 +157,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFFB91C1C), lightChipForeground = Color(0xFFB91C1C),
darkChipBackground = Color(0x80450A0A), darkChipBackground = Color(0x80450A0A),
darkChipForeground = Color(0xFFFCA5A5), darkChipForeground = Color(0xFFFCA5A5),
lightTagInk = Color(0xFF991B1B),
darkTagInk = Color(0xFFFCA5A5),
), ),
"orange" to "orange" to
NoteTint( NoteTint(
@@ -78,6 +171,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFFC2410C), lightChipForeground = Color(0xFFC2410C),
darkChipBackground = Color(0x80431407), darkChipBackground = Color(0x80431407),
darkChipForeground = Color(0xFFFDBA74), darkChipForeground = Color(0xFFFDBA74),
lightTagInk = Color(0xFF9A3412),
darkTagInk = Color(0xFFFDBA74),
), ),
"yellow" to "yellow" to
NoteTint( NoteTint(
@@ -90,6 +185,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF92400E), lightChipForeground = Color(0xFF92400E),
darkChipBackground = Color(0x80451A03), darkChipBackground = Color(0x80451A03),
darkChipForeground = Color(0xFFFCD34D), darkChipForeground = Color(0xFFFCD34D),
lightTagInk = Color(0xFF92400E),
darkTagInk = Color(0xFFFCD34D),
), ),
"green" to "green" to
NoteTint( NoteTint(
@@ -102,6 +199,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF15803D), lightChipForeground = Color(0xFF15803D),
darkChipBackground = Color(0x80052E16), darkChipBackground = Color(0x80052E16),
darkChipForeground = Color(0xFF86EFAC), darkChipForeground = Color(0xFF86EFAC),
lightTagInk = Color(0xFF166534),
darkTagInk = Color(0xFF86EFAC),
), ),
"teal" to "teal" to
NoteTint( NoteTint(
@@ -114,6 +213,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF0F766E), lightChipForeground = Color(0xFF0F766E),
darkChipBackground = Color(0x80042F2E), darkChipBackground = Color(0x80042F2E),
darkChipForeground = Color(0xFF5EEAD4), darkChipForeground = Color(0xFF5EEAD4),
lightTagInk = Color(0xFF115E59),
darkTagInk = Color(0xFF5EEAD4),
), ),
"blue" to "blue" to
NoteTint( NoteTint(
@@ -126,6 +227,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF1D4ED8), lightChipForeground = Color(0xFF1D4ED8),
darkChipBackground = Color(0x80172554), darkChipBackground = Color(0x80172554),
darkChipForeground = Color(0xFF93C5FD), darkChipForeground = Color(0xFF93C5FD),
lightTagInk = Color(0xFF1E40AF),
darkTagInk = Color(0xFF93C5FD),
), ),
"purple" to "purple" to
NoteTint( NoteTint(
@@ -138,6 +241,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF7E22CE), lightChipForeground = Color(0xFF7E22CE),
darkChipBackground = Color(0x803B0764), darkChipBackground = Color(0x803B0764),
darkChipForeground = Color(0xFFD8B4FE), darkChipForeground = Color(0xFFD8B4FE),
lightTagInk = Color(0xFF6B21A8),
darkTagInk = Color(0xFFD8B4FE),
), ),
"pink" to "pink" to
NoteTint( NoteTint(
@@ -150,6 +255,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFFBE185D), lightChipForeground = Color(0xFFBE185D),
darkChipBackground = Color(0x80500724), darkChipBackground = Color(0x80500724),
darkChipForeground = Color(0xFFF9A8D4), darkChipForeground = Color(0xFFF9A8D4),
lightTagInk = Color(0xFF9D174D),
darkTagInk = Color(0xFFF9A8D4),
), ),
"gray" to "gray" to
NoteTint( NoteTint(
@@ -162,6 +269,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF404040), lightChipForeground = Color(0xFF404040),
darkChipBackground = Color(0xFF404040), darkChipBackground = Color(0xFF404040),
darkChipForeground = Color(0xFFE5E5E5), darkChipForeground = Color(0xFFE5E5E5),
lightTagInk = Color(0xFF262626),
darkTagInk = Color(0xFFE5E5E5),
), ),
) )
@@ -175,3 +284,30 @@ val NOTE_TINTS: Map<String, NoteTint> =
@Composable @Composable
@ReadOnlyComposable @ReadOnlyComposable
fun noteTint(key: String): NoteTint = NOTE_TINTS[key] ?: NOTE_TINTS.getValue("default") 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,5 +1,6 @@
package com.fabledsword.thoughtsync.ui package com.fabledsword.thoughtsync.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -8,6 +9,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -79,6 +82,66 @@ 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. */ /** The three tones a panel or notice can take, mapped onto the note palette. */
enum class Tone { NEUTRAL, WARN, ERROR } enum class Tone { NEUTRAL, WARN, ERROR }
@@ -9,6 +9,7 @@ import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -20,12 +21,16 @@ import androidx.compose.ui.text.input.VisualTransformation
/** /**
* A text field with no box around it. * A text field with no box around it.
* *
* Every writing surface in the app — the capture sheet, the editor's title and * The search box, the label picker, the sync-pairing form: fields that sit on a
* body, each checklist row — sits on a surface that already has its own edges and * surface which already has its own edges and its own colour, where Material's filled
* its own colour. Material's filled field would draw a second, differently * field would draw a second, differently coloured box inside the first. Stripping the
* coloured box inside the first, which makes writing a note look like filling in a * container and the indicator at each site independently is how they drift apart, so
* form. Stripping the container and the indicator in four places independently is * it happens once, here.
* how they drift apart, so it happens once, here. *
* The note EDITOR no longer comes through this. It dropped to `BasicTextField`
* (see `EditorBlock.kt`) for density: Material's field puts 16dp above and below its
* text, which is right for a form and is the whole row height on a checklist. Nothing
* about "no box" was lost there — BasicTextField never had one.
* *
* The disabled colours are stripped too: a trashed note is shown through this * The disabled colours are stripped too: a trashed note is shown through this
* field read-only, and Material's disabled treatment would grey out text the user * field read-only, and Material's disabled treatment would grey out text the user
@@ -58,18 +63,26 @@ fun PlainTextField(
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
visualTransformation = visualTransformation, visualTransformation = visualTransformation,
colors = colors = plainFieldColors(),
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,6 +1,5 @@
package com.fabledsword.thoughtsync.ui package com.fabledsword.thoughtsync.ui
import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneId import java.time.ZoneId
@@ -67,9 +66,14 @@ 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. */ /** Whether a stored reminder has already passed, for showing it as overdue. */
fun isPast(raw: String): Boolean = fun isPast(raw: String): Boolean {
runCatching { OffsetDateTime.parse(raw).toInstant() < Instant.now() }.getOrDefault(false) val at = epochMillis(raw) ?: return false
return at < System.currentTimeMillis()
}
/** /**
* Whether a timestamp is older than [minutes] ago — or absent entirely. * Whether a timestamp is older than [minutes] ago — or absent entirely.
@@ -83,8 +87,8 @@ fun olderThan(
raw: String?, raw: String?,
minutes: Long, minutes: Long,
): Boolean { ): Boolean {
val at = raw?.let { runCatching { OffsetDateTime.parse(it).toInstant() }.getOrNull() } val at = raw?.let { epochMillis(it) }
return at == null || at < Instant.now().minusSeconds(minutes * SECONDS_PER_MINUTE) return at == null || at < System.currentTimeMillis() - minutes * MILLIS_PER_MINUTE
} }
private const val SECONDS_PER_MINUTE = 60L private const val MILLIS_PER_MINUTE = 60_000L
@@ -1,18 +1,27 @@
package com.fabledsword.thoughtsync.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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -113,6 +122,59 @@ 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. * The one line an unlinked device gets.
* *
@@ -24,10 +24,28 @@ data class UpdateState(
val available: ClientUpdate? = null, val available: ClientUpdate? = null,
/** A check completed and found nothing. Distinct from "not checked yet". */ /** A check completed and found nothing. Distinct from "not checked yet". */
val upToDate: Boolean = false, 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 working: Boolean = false,
val error: String? = null, 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 || working 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
} }
/** /**
@@ -53,28 +71,103 @@ class UpdateViewModel(
var state by mutableStateOf(UpdateState(installedVersion = AppUpdate.installedVersionCode(context))) var state by mutableStateOf(UpdateState(installedVersion = AppUpdate.installedVersionCode(context)))
private set private set
/** Ask the linked server what it has. */ /** When the last check ran, so coming back to the app twice in a minute is one. */
fun check() { 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 { viewModelScope.launch {
state = state.copy(checking = true, error = null, upToDate = false) state = state.copy(checking = true, error = null, upToDate = false)
state = state =
try { try {
val found = core.clientUpdate(state.installedVersion) val found = core.clientUpdate(state.installedVersion)
state.copy(checking = false, available = found, upToDate = found == null) 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) { } catch (e: Exception) {
// Broad by intent, as everywhere the core is called: it reports // Broad by intent, as everywhere the core is called: it reports
// every failure as one error type carrying a message written to // every failure as one error type carrying a message written to
// be read, and a failed check must not take the screen down. // be read, and a failed check must not take the screen down.
state.copy(checking = false, error = e.message ?: FALLBACK) state.copy(checking = false, error = e.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)
}
/** /**
* Download the update and hand it to the system installer. * Hand the update to the system installer, downloading first if it is not already
* in the cache.
* *
* One action rather than two buttons: nobody wants a downloaded APK sitting * Still one action from the outside. A downloaded APK is not a state anyone wants
* around as an intermediate state they have to think about. * to think about, so whether the fetch already happened in the background is this
* class's problem rather than the person's.
*/ */
fun downloadAndInstall() { fun downloadAndInstall() {
viewModelScope.launch { viewModelScope.launch {
@@ -83,7 +176,7 @@ class UpdateViewModel(
val failure = val failure =
try { try {
val target = AppUpdate.downloadTarget(context) val target = AppUpdate.downloadTarget(context)
core.downloadClientUpdate(target.absolutePath) if (!state.ready) core.downloadClientUpdate(target.absolutePath)
// Off the main thread: this streams ~55 MiB into the session. // Off the main thread: this streams ~55 MiB into the session.
withContext(Dispatchers.IO) { AppUpdate.install(context, target) } withContext(Dispatchers.IO) { AppUpdate.install(context, target) }
} catch (e: Exception) { } catch (e: Exception) {
@@ -126,3 +219,14 @@ class UpdateViewModel(
} }
private const val FALLBACK = "The update couldn't be checked." 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
+16 -9
View File
@@ -10,16 +10,17 @@
<!-- Compose sheet --> <!-- Compose sheet -->
<string name="compose_open">New note</string> <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 --> <!-- Board -->
<string name="board_empty_note">Empty note</string> <string name="board_empty_note">Empty note</string>
<plurals name="board_more_items">
<item quantity="one">+%d more item</item> <!-- The long-press menu. Its ITEMS are the editor_* strings, deliberately: a
<item quantity="other">+%d more items</item> note has one vocabulary of things you can do to it, and a board that said
</plurals> "Delete" where the editor says "Move to trash" would be describing two
different apps. Only the wrapper and the undo need words of their own. -->
<string name="board_note_actions">Note actions</string>
<string name="board_trashed">Moved to trash</string>
<string name="board_undo">Undo</string>
<!-- Empty states. Each destination says something true of ITSELF; a single <!-- 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. --> "nothing here" reads as encouragement on the board and as a fault in Trash. -->
@@ -38,12 +39,17 @@
<string name="board_open_note">Open note</string> <string name="board_open_note">Open note</string>
<string name="editor_back">Back to notes</string> <string name="editor_back">Back to notes</string>
<string name="editor_add_checklist">Add a checklist</string> <string name="editor_add_checklist">Add a checklist</string>
<string name="editor_body_hint">Note</string> <string name="editor_body_hint">Take a note</string>
<string name="editor_add_item">Add item</string> <string name="editor_add_item">Add item</string>
<string name="editor_remove_item">Remove item</string> <string name="editor_remove_item">Remove item</string>
<string name="editor_remove_label">Remove label</string> <string name="editor_remove_label">Remove label</string>
<string name="editor_reminder">Set a reminder</string> <string name="editor_reminder">Set a reminder</string>
<string name="editor_more">More actions</string> <string name="editor_more">More actions</string>
<string name="editor_saving">Saving…</string>
<string name="editor_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_pin">Pin</string>
<string name="editor_unpin">Unpin</string> <string name="editor_unpin">Unpin</string>
<string name="editor_labels">Labels…</string> <string name="editor_labels">Labels…</string>
@@ -61,7 +67,6 @@
<string name="editor_delete_forever_confirm">Delete</string> <string name="editor_delete_forever_confirm">Delete</string>
<!-- Pickers --> <!-- Pickers -->
<string name="color_picker_title">Color</string>
<string name="label_picker_title">Labels</string> <string name="label_picker_title">Labels</string>
<string name="label_new_hint">Type a label and press enter</string> <string name="label_new_hint">Type a label and press enter</string>
<string name="label_from_tag">from #tag</string> <string name="label_from_tag">from #tag</string>
@@ -122,6 +127,8 @@
<string name="update_current">You\'re on the newest build this server has.</string> <string name="update_current">You\'re on the newest build this server has.</string>
<string name="update_check">Check for an update</string> <string name="update_check">Check for an update</string>
<string name="update_install">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_failed_title">The update didn\'t install</string>
<string name="update_permission_title">Android needs your permission</string> <string name="update_permission_title">Android needs your permission</string>
<string name="update_permission_body">ThoughtSync has to be allowed to install apps before it can update itself. This is a one-time setting.</string> <string name="update_permission_body">ThoughtSync has to be allowed to install apps before it can update itself. This is a one-time setting.</string>
@@ -0,0 +1,137 @@
package com.fabledsword.thoughtsync.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
/**
* Pins the derived-colour rule against `frontend/src/notes/colors.ts`.
*
* These are not tests of Kotlin — they are the ONE mechanical guard the mirrored pair
* has. The web side is TypeScript with no test runner (its CI lane is `vue-tsc
* --noEmit` and nothing else), so if these values drift, nothing on that surface will
* say so and a tag will simply be a different colour on the phone than in the browser.
* The same names and hashes are written into colors.ts as a comment; changing either
* side means changing both and re-checking here.
*
* SMALLER SINCE M315. Half of what this file used to pin — the generated card fill, and
* the resolution order that chose between a picked colour, a tag's and a generated one
* — went with the code it guarded when the card became one neutral. The four UUID
* hashes stay because they are what the hash ITSELF is pinned by; nothing derives a
* colour from an id any more, only from a tag's name.
*/
class DerivedTintTest {
@Test
fun `hashes match the fixture shared with the web`() {
// Kotlin's Int is signed, so the two hashes above 0x7FFFFFFF are written as
// their negative literal. The unsigned value in the comment is what colors.ts
// records and what an implementation of FNV-1a will actually produce.
assertEquals(-0x41B8712F, tintHash("00000000-0000-0000-0000-000000000000")) // 0xbe478ed1
assertEquals(0x3D75CC01, tintHash("11111111-1111-1111-1111-111111111111"))
assertEquals(-0x0EF71AD0, tintHash("6ba7b810-9dad-11d1-80b4-00c04fd430c8")) // 0xf108e530
assertEquals(0x5B651540, tintHash("f47ac10b-58cc-4372-a567-0e02b2c3d479"))
}
@Test
fun `colours match the fixture shared with the web`() {
assertEquals("purple", derivedTint("00000000-0000-0000-0000-000000000000"))
assertEquals("blue", derivedTint("11111111-1111-1111-1111-111111111111"))
assertEquals("orange", derivedTint("6ba7b810-9dad-11d1-80b4-00c04fd430c8"))
assertEquals("orange", derivedTint("f47ac10b-58cc-4372-a567-0e02b2c3d479"))
}
/** Half of all 32-bit hashes are negative as Kotlin Ints; a signed remainder would
* index out of the list for those. The bug this catches is a crash, not a wrong
* colour, so it is worth more than one name's worth of coverage. */
@Test
fun `every derived colour is a real palette key, over many names`() {
for (n in 0 until 2000) {
assertEquals(true, derivedTint("tag-$n") in DERIVED_TINT_KEYS)
}
}
@Test
fun `the derived palette excludes default`() {
assertEquals(false, "default" in DERIVED_TINT_KEYS)
assertEquals(9, DERIVED_TINT_KEYS.size)
}
/** The order IS the mapping — reordering silently recolours every tag on one
* surface only. Written out longhand so a reorder fails here loudly. */
@Test
fun `key order matches colors ts`() {
assertEquals(
listOf("red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray"),
DERIVED_TINT_KEYS,
)
}
/** A tag with no colour of its own derives one from its NAME, which is what makes
* every `#todo` chip the same colour rather than nine different ones. */
@Test
fun `a label with no colour derives one from its name`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
val todo = resolvedLabelColor("todo", "default", known)
assertEquals(derivedTint("todo"), todo)
assertNotEquals("default", todo)
}
/** Tags dedupe case-insensitively, so `#Todo` and `#todo` are one tag and must not
* be two colours. This is the whole reason the name is lowercased first. */
@Test
fun `label colour ignores case`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
assertEquals(
resolvedLabelColor("todo", "default", known),
resolvedLabelColor("ToDo", "default", known),
)
}
/** `teal` deliberately, NOT the colour "todo" derives to (pink) — asserting the
* derived value here would pass even with the explicit branch deleted. */
@Test
fun `an explicitly picked label colour still wins`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
assertNotEquals("teal", derivedTint("todo"))
assertEquals("teal", resolvedLabelColor("todo", "teal", known))
}
/** An unreadable key is not a choice — it is data from a server newer than this
* client, and the tag should still be drawn as something. */
@Test
fun `an unknown colour key falls back to the derived colour`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
assertEquals(derivedTint("todo"), resolvedLabelColor("todo", "chartreuse", known))
}
/** A label with no name at all has nothing to hash. Neutral, not a random hue. */
@Test
fun `a nameless label stays default`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
assertEquals("default", resolvedLabelColor("", "", known))
assertEquals("default", resolvedLabelColor("", "default", known))
}
/**
* The spread is real, and collisions are real too.
*
* Nine keys means two tags sharing a colour is not a bug and cannot be designed
* out — in this very sample `home`/`reading` are both gray and `work`/`ideas` are
* both green. Colour is a hint that two chips are distinct, never a claim that two
* of one colour are the same tag; the chip's TEXT is what says which tag it is.
*/
@Test
fun `different tag names spread across the palette`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
val names = listOf("todo", "grocery", "work", "home", "ideas", "reading", "urgent")
val colours = names.map { resolvedLabelColor(it, "default", known) }
assertEquals(true, colours.toSet().size >= 5)
}
/** The reason the feature exists: two tags on one board should not look identical. */
@Test
fun `the derived colour spreads across the palette`() {
val seen = (0 until 500).map { derivedTint("spread-$it") }.toSet()
assertEquals(DERIVED_TINT_KEYS.size, seen.size)
}
}
+51 -7
View File
@@ -43,8 +43,8 @@ use thoughtsync_core::sync::blobs::BlobStore;
use thoughtsync_core::sync::{client, compat, engine, push, state}; use thoughtsync_core::sync::{client, compat, engine, push, state};
use models::{ use models::{
patch_from, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery, ProbeResult, patch_from, BodyItem, BodyTag, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit,
RevokeOutcome, SyncOutcome, SyncStatus, NoteQuery, ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus,
}; };
uniffi::setup_scaffolding!(); uniffi::setup_scaffolding!();
@@ -499,6 +499,52 @@ impl ThoughtSync {
} }
} }
// ── checklist text, as pure functions ───────────────────────────────────────
//
// The pair the block editor is built on: one to read a body apart, one to put a line
// back together. Between them, Kotlin can render a checklist as real checkboxes and
// write the markdown back without owning the grammar — which is the point. Three
// implementations of it is the price already being paid (Rust, Python, TypeScript);
// a fourth in Compose would be one more place for a checklist to change shape when
// it syncs.
//
// Free functions rather than methods, because they touch no database. The editor's
// body is LOCAL state — autosaved on an idle debounce, not written per keystroke —
// so editing a checklist there has to rewrite the text the editor is holding, not a
// row the store would hand back a moment later and overwrite the typing with.
/// One checklist item as the body line that stores it. For an editor that shows a
/// checkbox instead of the markup and has to write the markup back.
#[uniffi::export]
pub fn checklist_render(text: String, checked: bool) -> String {
local::derive::render_item(&text, checked)
}
/// Every checklist item in a body, with the line each one sits on — so a renderer
/// walking the body line by line knows which lines are boxes and what is in them.
#[uniffi::export]
pub fn checklist_items(body: String) -> Vec<BodyItem> {
local::derive::extract_items(&body)
.into_iter()
.map(BodyItem::from)
.collect()
}
/// Every `#tag` in a body, with the line and the UTF-16 span each one occupies — so
/// a card can colour the tag where it was typed instead of printing it twice.
///
/// The same argument as `checklist_items` above, and the same answer: the grammar for
/// what a `#tag` is already exists in Rust, Python and TypeScript. Matching it a
/// fourth time in Compose would be a fourth place for a tag to change shape when it
/// syncs — and this one would fail silently, as the wrong characters tinted.
#[uniffi::export]
pub fn body_tags(body: String) -> Vec<BodyTag> {
local::derive::extract_tag_spans(&body)
.into_iter()
.map(BodyTag::from)
.collect()
}
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]` /// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
/// block names, so these stay Rust-side. /// block names, so these stay Rust-side.
impl ThoughtSync { impl ThoughtSync {
@@ -571,7 +617,6 @@ mod tests {
fn draft(body: &str) -> NoteDraft { fn draft(body: &str) -> NoteDraft {
NoteDraft { NoteDraft {
body: body.to_string(), body: body.to_string(),
color: "default".to_string(),
items: None, items: None,
} }
} }
@@ -625,7 +670,6 @@ mod tests {
let created = app let created = app
.create_note(NoteDraft { .create_note(NoteDraft {
body: String::new(), body: String::new(),
color: "default".to_string(),
items: Some(vec!["milk".to_string(), "eggs".to_string()]), items: Some(vec!["milk".to_string(), "eggs".to_string()]),
}) })
.expect("create should succeed"); .expect("create should succeed");
@@ -661,7 +705,6 @@ mod tests {
let note = app let note = app
.create_note(NoteDraft { .create_note(NoteDraft {
body: "Packing".to_string(), body: "Packing".to_string(),
color: "default".to_string(),
items: Some(vec!["socks".to_string()]), items: Some(vec!["socks".to_string()]),
}) })
.expect("create"); .expect("create");
@@ -682,8 +725,9 @@ mod tests {
assert!(ticked.items[1].checked); assert!(ticked.items[1].checked);
assert_eq!( assert_eq!(
ticked.items[1].text, "charger", ticked.items[1].text, "charger",
"ticking a box must not disturb its text — the two setters write \ "ticking a box must not disturb its text — both setters rewrite the \
different columns and neither may clear the other" same line of the body now, so one clobbering the other is a live risk \
rather than a theoretical one"
); );
let renamed = app let renamed = app
+59 -12
View File
@@ -33,7 +33,6 @@ pub struct Note {
/// Always present. Derived by the core, never stored. /// Always present. Derived by the core, never stored.
pub display_title: String, pub display_title: String,
pub body: String, pub body: String,
pub color: String,
pub position: i64, pub position: i64,
pub pinned: bool, pub pinned: bool,
pub archived: bool, pub archived: bool,
@@ -49,6 +48,63 @@ pub struct Note {
pub updated_at: Option<String>, pub updated_at: Option<String>,
} }
/// A checklist item as it sits in a note's body.
///
/// Mirrors `derive::DerivedItem`. Carries the LINE because every renderer that walks
/// a body line by line needs the text, the state and the position together — the card
/// to draw a box in the right place, the block editor to know where one block ends.
#[derive(Debug, Clone, uniffi::Record)]
pub struct BodyItem {
pub line: u32,
pub text: String,
pub checked: bool,
}
impl From<thoughtsync_core::local::derive::DerivedItem> for BodyItem {
fn from(i: thoughtsync_core::local::derive::DerivedItem) -> Self {
let thoughtsync_core::local::derive::DerivedItem {
text,
checked,
line,
} = i;
BodyItem {
line,
text,
checked,
}
}
}
/// One `#tag` and where it sits in a note's body.
///
/// Mirrors `derive::DerivedTag`. The card colours the tag where it was typed rather
/// than repeating it as a chip, so it needs the SPAN — and the offsets are UTF-16
/// code units precisely because Kotlin's `AnnotatedString` counts that way.
#[derive(Debug, Clone, uniffi::Record)]
pub struct BodyTag {
pub line: u32,
pub start: u32,
pub end: u32,
pub name: String,
}
impl From<thoughtsync_core::local::derive::DerivedTag> for BodyTag {
fn from(t: thoughtsync_core::local::derive::DerivedTag) -> Self {
let thoughtsync_core::local::derive::DerivedTag {
line,
start,
end,
name,
} = t;
BodyTag {
line,
start,
end,
name,
}
}
}
/// An Android build the linked server is offering, already judged to be newer. /// 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 /// A mirror rather than a re-export of `client::ClientRelease`, for the same
@@ -130,7 +186,6 @@ impl From<core_models::Note> for Note {
id, id,
display_title, display_title,
body, body,
color,
position, position,
pinned, pinned,
archived, archived,
@@ -149,7 +204,6 @@ impl From<core_models::Note> for Note {
id, id,
display_title, display_title,
body, body,
color,
position, position,
pinned, pinned,
archived, archived,
@@ -287,7 +341,6 @@ pub struct NoteQuery {
#[derive(Debug, Clone, uniffi::Record)] #[derive(Debug, Clone, uniffi::Record)]
pub struct NoteFacets { pub struct NoteFacets {
pub q: Option<String>, pub q: Option<String>,
pub color: Option<String>,
pub label: Option<Vec<String>>, pub label: Option<Vec<String>>,
pub has_reminder: Option<bool>, pub has_reminder: Option<bool>,
pub has_attachment: Option<bool>, pub has_attachment: Option<bool>,
@@ -316,7 +369,6 @@ impl From<NoteFacets> for core_models::Facets {
fn from(value: NoteFacets) -> Self { fn from(value: NoteFacets) -> Self {
let NoteFacets { let NoteFacets {
q, q,
color,
label, label,
has_reminder, has_reminder,
has_attachment, has_attachment,
@@ -325,7 +377,6 @@ impl From<NoteFacets> for core_models::Facets {
} = value; } = value;
core_models::Facets { core_models::Facets {
q, q,
color,
label, label,
has_reminder, has_reminder,
has_attachment, has_attachment,
@@ -339,8 +390,6 @@ impl From<NoteFacets> for core_models::Facets {
#[derive(Debug, Clone, uniffi::Record)] #[derive(Debug, Clone, uniffi::Record)]
pub struct NoteDraft { pub struct NoteDraft {
pub body: String, pub body: String,
/// "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 /// 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. /// is not an alternative to `body` — it is an addition to it.
pub items: Option<Vec<String>>, pub items: Option<Vec<String>>,
@@ -348,8 +397,8 @@ pub struct NoteDraft {
impl From<NoteDraft> for core_models::NoteCreateInput { impl From<NoteDraft> for core_models::NoteCreateInput {
fn from(value: NoteDraft) -> Self { fn from(value: NoteDraft) -> Self {
let NoteDraft { body, color, items } = value; let NoteDraft { body, items } = value;
core_models::NoteCreateInput { body, color, items } core_models::NoteCreateInput { body, items }
} }
} }
@@ -364,7 +413,6 @@ impl From<NoteDraft> for core_models::NoteCreateInput {
#[derive(Debug, Clone, uniffi::Enum)] #[derive(Debug, Clone, uniffi::Enum)]
pub enum NoteEdit { pub enum NoteEdit {
Body { value: String }, Body { value: String },
Color { value: String },
Pinned { value: bool }, Pinned { value: bool },
Archived { value: bool }, Archived { value: bool },
RemindAt { value: String }, RemindAt { value: String },
@@ -384,7 +432,6 @@ impl NoteEdit {
use serde_json::Value; use serde_json::Value;
match self { match self {
NoteEdit::Body { value } => ("body", Value::String(value)), NoteEdit::Body { value } => ("body", Value::String(value)),
NoteEdit::Color { value } => ("color", Value::String(value)),
NoteEdit::Pinned { value } => ("pinned", Value::Bool(value)), NoteEdit::Pinned { value } => ("pinned", Value::Bool(value)),
NoteEdit::Archived { value } => ("archived", Value::Bool(value)), NoteEdit::Archived { value } => ("archived", Value::Bool(value)),
NoteEdit::RemindAt { value } => ("remind_at", Value::String(value)), NoteEdit::RemindAt { value } => ("remind_at", Value::String(value)),
+707 -9
View File
@@ -1,19 +1,31 @@
//! Deriving `#tags` from a note's body — the local mirror of what the server computes //! Deriving structure from a note's body — the local mirror of what the server
//! on save. Pure string scanning (no regex dependency), kept in lockstep with the //! computes on save. Pure string scanning (no regex dependency), kept in lockstep
//! frontend's inline rules (see frontend notes/markdown.ts): //! with the frontend's inline rules (see frontend notes/markdown.ts):
//! //!
//! - `#tag`: `#` at a word boundary followed by tag characters (letter first). //! - `#tag`: `#` at a word boundary followed by tag characters (letter first).
//! On save these become labels attached with `via_tag = true`. //! 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. //! Dedupes case-insensitively, preserving first-seen order.
//! //!
//! Also derived `[[wiki-links]]` until they were removed (note 2897) — this is a //! Also derived `[[wiki-links]]` until they were removed (note 2897) — this is a
//! capture-and-recall surface, and a linking system is organization. //! capture-and-recall surface, and a linking system is organization.
/// Extract every `#tag` name (without the leading `#`) from `body`. /// Every `#tag` in ONE line, as `(start, end, name)` in char indices.
pub fn extract_tags(body: &str) -> Vec<String> { ///
let chars: Vec<char> = body.chars().collect(); /// Char indices rather than byte offsets so the spans can be used to cut the tags
let mut out: Vec<String> = Vec::new(); /// back out of the line without ever landing mid-codepoint — see
/// [`lift_standalone_tags`], which is the only reason the spans exist.
fn line_tags(chars: &[char]) -> Vec<(usize, usize, String)> {
let mut out: Vec<(usize, usize, String)> = Vec::new();
let mut i = 0; let mut i = 0;
while i < chars.len() { while i < chars.len() {
if chars[i] == '#' { if chars[i] == '#' {
@@ -24,8 +36,7 @@ pub fn extract_tags(body: &str) -> Vec<String> {
while j < chars.len() && is_tag_char(chars[j]) { while j < chars.len() && is_tag_char(chars[j]) {
j += 1; j += 1;
} }
let tag: String = chars[i + 1..j].iter().collect(); out.push((i, j, chars[i + 1..j].iter().collect()));
push_unique(&mut out, &tag);
i = j; i = j;
continue; continue;
} }
@@ -35,6 +46,183 @@ pub fn extract_tags(body: &str) -> Vec<String> {
out out
} }
/// Extract every `#tag` name (without the leading `#`) from `body`.
///
/// Line by line, which changes nothing: a line start and a `\n` are both boundaries,
/// so the same tags come out. It means there is ONE scanner rather than two — this and
/// [`lift_standalone_tags`] cannot disagree about what a tag is.
pub fn extract_tags(body: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for line in body.split('\n') {
let chars: Vec<char> = line.chars().collect();
for (_, _, name) in line_tags(&chars) {
push_unique(&mut out, &name);
}
}
out
}
/// One `#tag` and exactly where it sits, for a renderer drawing the body itself.
///
/// 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 { fn is_tag_char(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '-' c.is_alphanumeric() || c == '_' || c == '-'
} }
@@ -45,6 +233,239 @@ 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -72,4 +493,281 @@ mod tests {
fn empty_body() { fn empty_body() {
assert!(extract_tags("").is_empty()); 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,7 +13,6 @@ pub struct Note {
/// never stored. /// never stored.
pub display_title: String, pub display_title: String,
pub body: String, pub body: String,
pub color: String,
pub position: i64, pub position: i64,
pub pinned: bool, pub pinned: bool,
pub archived: bool, pub archived: bool,
@@ -121,16 +120,10 @@ pub struct User {
pub is_admin: bool, pub is_admin: bool,
} }
fn default_color() -> String {
"default".to_string()
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct NoteCreateInput { pub struct NoteCreateInput {
#[serde(default)] #[serde(default)]
pub body: String, pub body: String,
#[serde(default = "default_color")]
pub color: String,
#[serde(default)] #[serde(default)]
pub items: Option<Vec<String>>, pub items: Option<Vec<String>>,
} }
@@ -154,8 +147,6 @@ pub struct Facets {
#[serde(default)] #[serde(default)]
pub q: Option<String>, pub q: Option<String>,
#[serde(default)] #[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub label: Option<Vec<String>>, pub label: Option<Vec<String>>,
#[serde(default)] #[serde(default)]
pub has_reminder: Option<bool>, pub has_reminder: Option<bool>,
+285 -2
View File
@@ -6,14 +6,16 @@
//! //!
//! Migrations are gated on `PRAGMA user_version`; bump it and add a block per change. //! Migrations are gated on `PRAGMA user_version`; bump it and add a block per change.
use rusqlite::Connection; use rusqlite::{params, Connection, OptionalExtension};
use crate::local::derive;
const SCHEMA_V1: &str = r#" const SCHEMA_V1: &str = r#"
CREATE TABLE notes ( CREATE TABLE notes (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
title TEXT, title TEXT,
body TEXT NOT NULL DEFAULT '', body TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'default', color TEXT NOT NULL DEFAULT 'default', -- dropped in v9; kept so DROP COLUMN has something to drop
kind TEXT NOT NULL DEFAULT 'text', -- dropped in v6; kept so DROP COLUMN has something to drop kind TEXT NOT NULL DEFAULT 'text', -- dropped in v6; kept so DROP COLUMN has something to drop
position INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0,
@@ -54,6 +56,8 @@ CREATE TABLE checklist_items (
position INTEGER NOT NULL DEFAULT 0 position INTEGER NOT NULL DEFAULT 0
); );
CREATE INDEX idx_items_note ON checklist_items (note_id); 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 ( CREATE TABLE attachments (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -180,7 +184,96 @@ ALTER TABLE notes DROP COLUMN title;
ALTER TABLE note_revisions DROP COLUMN title; ALTER TABLE note_revisions DROP COLUMN title;
"#; "#;
// v8 (M304): `checklist_items` is gone. The body IS the checklist — a `- [ ] milk`
// 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. /// 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<()> { pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch("PRAGMA foreign_keys = ON;")?; conn.execute_batch("PRAGMA foreign_keys = ON;")?;
let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?; let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
@@ -212,5 +305,195 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(SCHEMA_V7)?; conn.execute_batch(SCHEMA_V7)?;
conn.execute_batch("PRAGMA user_version = 7;")?; 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(()) 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");
}
}
+206 -102
View File
@@ -9,7 +9,7 @@
use chrono::{DateTime, Duration, SecondsFormat, Utc}; use chrono::{DateTime, Duration, SecondsFormat, Utc};
use rusqlite::{params, params_from_iter, Connection, OptionalExtension}; use rusqlite::{params, params_from_iter, Connection, OptionalExtension};
use serde_json::Value; use serde_json::{json, Value};
use uuid::Uuid; use uuid::Uuid;
use crate::local::derive; use crate::local::derive;
@@ -24,24 +24,25 @@ fn new_id() -> String {
Uuid::new_v4().to_string() Uuid::new_v4().to_string()
} }
/// The note's NAME: its first non-blank body line, else its first checklist item. /// The note's NAME: the first line of its body that says anything.
/// ///
/// Mirrors `derive_display_title` in the server's notes/helpers.py — one rule written /// 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 /// twice, and they have to agree or a synced note is called different things on either
/// side of the wire. /// side of the wire.
/// ///
/// Pure, and given the items rather than fetching them: every caller has already /// It no longer needs the items, because the items ARE lines of the body now (M304).
/// loaded them, so a query here would be a second trip for something already in hand. /// What it needs instead is to strip the task marker off: a list-only note is still
fn display_title(body: &str, items: &[ChecklistItem]) -> String { /// named by its first item, and calling that note "- [ ] milk" would be showing
if let Some(line) = body.lines().map(str::trim).find(|l| !l.is_empty()) { /// someone the storage rather than the note. An empty item is skipped rather than
return line.to_string(); /// 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();
}
} }
items String::new()
.iter()
.map(|i| i.text.trim())
.find(|t| !t.is_empty())
.unwrap_or("")
.to_string()
} }
fn escape_like(s: &str) -> String { fn escape_like(s: &str) -> String {
@@ -69,19 +70,24 @@ fn load_labels(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<NoteLab
rows.collect() rows.collect()
} }
fn load_items(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<ChecklistItem>> { /// The note's checklist, read out of its body. No query, because there is no table.
let mut stmt = conn.prepare( ///
"SELECT id, text, checked, position FROM checklist_items WHERE note_id = ?1 ORDER BY position ASC", /// 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
let rows = stmt.query_map([note_id], |r| { /// checked and never an id, and both sides replaced the whole list on every sync. It
Ok(ChecklistItem { /// is also exactly what the rewriters in `derive` take, so a UI holding an id can act
id: r.get(0)?, /// on it directly.
text: r.get(1)?, fn items_of(body: &str) -> Vec<ChecklistItem> {
checked: r.get(2)?, derive::extract_items(body)
position: r.get(3)?, .into_iter()
.enumerate()
.map(|(i, item)| ChecklistItem {
id: i.to_string(),
text: item.text,
checked: item.checked,
position: i as i64,
}) })
})?; .collect()
rows.collect()
} }
fn load_attachments(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<Attachment>> { fn load_attachments(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<Attachment>> {
@@ -135,7 +141,7 @@ fn load_previews(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<LinkP
fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> { fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
let mut note = conn.query_row( let mut note = conn.query_row(
"SELECT id, body, color, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at "SELECT id, body, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
FROM notes WHERE id = ?1", FROM notes WHERE id = ?1",
[id], [id],
|r| { |r| {
@@ -144,14 +150,13 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
id: r.get(0)?, id: r.get(0)?,
display_title: String::new(), // filled below — it may need a query display_title: String::new(), // filled below — it may need a query
body, body,
color: r.get(2)?, position: r.get(2)?,
position: r.get(3)?, pinned: r.get(3)?,
pinned: r.get(4)?, archived: r.get(4)?,
archived: r.get(5)?, trashed: r.get(5)?,
trashed: r.get(6)?, deleted_at: r.get(10)?,
deleted_at: r.get(11)?, remind_at: r.get(6)?,
remind_at: r.get(7)?, recurrence: r.get(7)?,
recurrence: r.get(8)?,
labels: Vec::new(), labels: Vec::new(),
items: Vec::new(), items: Vec::new(),
attachments: Vec::new(), attachments: Vec::new(),
@@ -162,11 +167,10 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
}, },
)?; )?;
note.labels = load_labels(conn, id)?; note.labels = load_labels(conn, id)?;
note.items = load_items(conn, id)?; note.items = items_of(&note.body);
note.attachments = load_attachments(conn, id)?; note.attachments = load_attachments(conn, id)?;
note.previews = load_previews(conn, id)?; note.previews = load_previews(conn, id)?;
// After the items, because a body-only-empty note is named by its first one. note.display_title = display_title(&note.body);
note.display_title = display_title(&note.body, &note.items);
Ok(note) Ok(note)
} }
@@ -200,34 +204,89 @@ fn find_or_create_label(conn: &Connection, name: &str) -> rusqlite::Result<Strin
Ok(id) Ok(id)
} }
/// Re-sync the note's `via_tag` labels to exactly the `#tags` in its body. /// Attach the note's tag labels, LIFT its standalone tags out of the body, and write
fn sync_tags(conn: &Connection, note_id: &str, body: &str) -> rusqlite::Result<()> { /// the shortened body back.
let tags = derive::extract_tags(body); ///
let mut desired: Vec<String> = Vec::with_capacity(tags.len()); /// NAMED FOR THE MUTATION. It used to be `sync_tags` and only touched label rows; it
for t in &tags { /// now rewrites `notes.body`, and every caller writes the body just before calling —
desired.push(find_or_create_label(conn, t)?); /// so this overwrites what they wrote, on purpose.
///
/// `display_title` needs no attention here, unlike on the server: the core derives it
/// on READ (see `display_title` above, called from `load_note`) rather than storing
/// it, so there is no persisted copy to go stale.
///
/// The two kinds of tag are handled differently, and that difference IS what `via_tag`
/// means from here on — backed by text still in the body:
///
/// standalone lifted out, attached as an ORDINARY label. Nothing derives it any
/// more, and the way to remove it becomes the chip's ×.
/// inline left in place, attached via_tag = 1, still detached when its text
/// goes. Unchanged from before.
///
/// Mirrors `_lift_and_reconcile_tags` in the server's `notes/tags.py`.
fn lift_and_sync_tags(conn: &Connection, note_id: &str, body: &str) -> rusqlite::Result<()> {
let (standalone, inline, lifted) = derive::lift_standalone_tags(body);
let mut standalone_ids: Vec<String> = Vec::with_capacity(standalone.len());
for name in &standalone {
standalone_ids.push(find_or_create_label(conn, name)?);
}
let mut inline_ids: Vec<String> = Vec::with_capacity(inline.len());
for name in &inline {
inline_ids.push(find_or_create_label(conn, name)?);
} }
let current: Vec<String> = { let current: Vec<(String, bool)> = {
let mut stmt = let mut stmt =
conn.prepare("SELECT label_id FROM note_labels WHERE note_id = ?1 AND via_tag = 1")?; conn.prepare("SELECT label_id, via_tag FROM note_labels WHERE note_id = ?1")?;
let rows = stmt.query_map([note_id], |r| r.get::<_, String>(0))?; let rows = stmt.query_map([note_id], |r| {
rows.collect::<rusqlite::Result<Vec<String>>>()? Ok((r.get::<_, String>(0)?, r.get::<_, bool>(1)?))
})?;
rows.collect::<rusqlite::Result<Vec<(String, bool)>>>()?
}; };
for lid in &current {
if !desired.contains(lid) { for (lid, via_tag) in &current {
if !*via_tag {
continue; // manual already: a #tag of the same name changes nothing
}
if standalone_ids.contains(lid) {
// It GRADUATED. The text backing it is about to go, so the row has to
// become the record instead — and BEFORE the delete below, or the same row
// is dropped for no longer being in the body. That is the bug a naive lift
// has, and it silently loses the tag.
conn.execute(
"UPDATE note_labels SET via_tag = 0 WHERE note_id = ?1 AND label_id = ?2",
params![note_id, lid],
)?;
} else if !inline_ids.contains(lid) {
conn.execute( conn.execute(
"DELETE FROM note_labels WHERE note_id = ?1 AND label_id = ?2 AND via_tag = 1", "DELETE FROM note_labels WHERE note_id = ?1 AND label_id = ?2 AND via_tag = 1",
params![note_id, lid], params![note_id, lid],
)?; )?;
} }
} }
for lid in &desired {
// OR IGNORE leaves a label already attached in ANY form alone, which is what keeps
// a manually-added label of the same name manual.
for lid in &standalone_ids {
conn.execute(
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 0)",
params![note_id, lid],
)?;
}
for lid in &inline_ids {
conn.execute( conn.execute(
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 1)", "INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 1)",
params![note_id, lid], params![note_id, lid],
)?; )?;
} }
if lifted != body {
conn.execute(
"UPDATE notes SET body = ?1 WHERE id = ?2",
params![lifted, note_id],
)?;
}
Ok(()) Ok(())
} }
@@ -267,10 +326,6 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note
binds.push(pat.clone()); binds.push(pat.clone());
binds.push(pat); 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) { if f.has_reminder == Some(true) {
sql.push_str(" AND remind_at IS NOT NULL"); sql.push_str(" AND remind_at IS NOT NULL");
} }
@@ -358,23 +413,62 @@ pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Resu
[], [],
|r| r.get(0), |r| r.get(0),
)?; )?;
conn.execute( // Items fold into the body rather than into rows of their own. Callers still hand
"INSERT INTO notes (id, body, color, position, created_at, updated_at, dirty) // them over separately — the importer has a list, not a blob — but where they end
VALUES (?1, ?2, ?3, ?4, ?5, ?5, 1)", // up is one place.
params![id, input.body, input.color, position, ts], let mut body = input.body.clone();
)?;
if let Some(items) = &input.items { if let Some(items) = &input.items {
for (i, text) in items.iter().enumerate() { for text in items {
conn.execute( body = derive::append_item(&body, text, false);
"INSERT INTO checklist_items (id, note_id, text, position) VALUES (?1, ?2, ?3, ?4)",
params![new_id(), id, text, i as i64],
)?;
} }
} }
sync_tags(conn, &id, &input.body)?; conn.execute(
"INSERT INTO notes (id, body, position, created_at, updated_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?4, 1)",
params![id, body, position, ts],
)?;
// The FOLDED body, not the input one: an item can carry a #tag too.
lift_and_sync_tags(conn, &id, &body)?;
load_note(conn, &id) 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<()> { fn snapshot_revision(conn: &Connection, id: &str) -> rusqlite::Result<()> {
let body: String = let body: String =
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?; conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?;
@@ -391,9 +485,12 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
.as_object() .as_object()
.ok_or_else(|| rusqlite::Error::InvalidParameterName("changes must be an object".into()))?; .ok_or_else(|| rusqlite::Error::InvalidParameterName("changes must be an object".into()))?;
// Snapshot the pre-edit body before changing it (version history). // Snapshot the pre-edit body before changing it (version history) — but only
if obj.contains_key("body") { // once per editing session, and only if it actually changed. See should_snapshot.
snapshot_revision(conn, id)?; if let Some(body) = obj.get("body").and_then(|v| v.as_str()) {
if should_snapshot(conn, id, body)? {
snapshot_revision(conn, id)?;
}
} }
for (k, v) in obj { for (k, v) in obj {
@@ -404,12 +501,7 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
"UPDATE notes SET body = ?1 WHERE id = ?2", "UPDATE notes SET body = ?1 WHERE id = ?2",
params![body, id], params![body, id],
)?; )?;
sync_tags(conn, id, body)?; lift_and_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" => { "pinned" => {
if let Some(b) = v.as_bool() { if let Some(b) = v.as_bool() {
@@ -508,18 +600,32 @@ pub fn set_labels(conn: &Connection, id: &str, label_ids: &[String]) -> rusqlite
load_note(conn, id) 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> { pub fn add_item(conn: &Connection, id: &str, text: &str) -> rusqlite::Result<Note> {
let pos: i64 = conn.query_row( let body = note_body(conn, id)?;
"SELECT COALESCE(MAX(position), -1) + 1 FROM checklist_items WHERE note_id = ?1", set_body(conn, id, derive::append_item(&body, text, false))
[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( pub fn update_item(
@@ -528,29 +634,27 @@ pub fn update_item(
item_id: &str, item_id: &str,
changes: &Value, changes: &Value,
) -> rusqlite::Result<Note> { ) -> 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) { if let Some(text) = changes.get("text").and_then(Value::as_str) {
conn.execute( body = derive::set_item_text(&body, index, text);
"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) { if let Some(checked) = changes.get("checked").and_then(Value::as_bool) {
conn.execute( body = derive::set_item_checked(&body, index, checked);
"UPDATE checklist_items SET checked = ?1 WHERE id = ?2 AND note_id = ?3",
params![checked, item_id, id],
)?;
} }
touch(conn, id)?; set_body(conn, id, body)
load_note(conn, id)
} }
pub fn delete_item(conn: &Connection, id: &str, item_id: &str) -> rusqlite::Result<Note> { pub fn delete_item(conn: &Connection, id: &str, item_id: &str) -> rusqlite::Result<Note> {
conn.execute( let index = match item_index(item_id) {
"DELETE FROM checklist_items WHERE id = ?1 AND note_id = ?2", Some(i) => i,
params![item_id, id], None => return load_note(conn, id),
)?; };
touch(conn, id)?; let body = note_body(conn, id)?;
load_note(conn, id) set_body(conn, id, derive::remove_item(&body, index))
} }
pub fn delete_attachment(conn: &Connection, id: &str, att_id: &str) -> rusqlite::Result<Note> { pub fn delete_attachment(conn: &Connection, id: &str, att_id: &str) -> rusqlite::Result<Note> {
@@ -668,7 +772,7 @@ pub fn restore_revision(conn: &Connection, id: &str, rev_id: &str) -> rusqlite::
"UPDATE notes SET body = ?1 WHERE id = ?2", "UPDATE notes SET body = ?1 WHERE id = ?2",
params![body, id], params![body, id],
)?; )?;
sync_tags(conn, id, &body)?; lift_and_sync_tags(conn, id, &body)?;
touch(conn, id)?; touch(conn, id)?;
load_note(conn, id) load_note(conn, id)
} }
+16 -2
View File
@@ -19,11 +19,25 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// The sync wire protocol this client speaks. /// The sync wire protocol this client speaks.
pub const CLIENT_PROTOCOL_VERSION: u32 = 2; ///
/// v4 (M315): `color` left the note. NOT a floor raise on either side — see the note
/// on [`MIN_SERVER_PROTOCOL_VERSION`].
pub const CLIENT_PROTOCOL_VERSION: u32 = 4;
/// The oldest server protocol this client can drive — the symmetric half of the /// The oldest server protocol this client can drive — the symmetric half of the
/// server's `min_client_protocol_version`. /// server's `min_client_protocol_version`.
pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 2; ///
/// STAYS AT 3 ACROSS v4, and the v2 precedent is the reason to say why rather than
/// leave it looking like an oversight. v2 dropped `kind` and `title` and DID move both
/// floors, on the rule that "dropping a field a client sends and expects back is
/// breaking". `color` fails that test on the second half: a v3 client reading a v4
/// server gets `"default"` from serde's default and draws the colour it derives
/// locally, which is a board that looks exactly like the one it drew yesterday. A v3
/// client PUSHING `color` to a v4 server has the key ignored — the server reads its
/// payload key by key and never validates the shape. Neither direction errors, and
/// neither loses anything a person can see; `title` was the note's NAME, and this is a
/// field that no longer renders anywhere.
pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 3;
/// Capabilities without which syncing is meaningless, so their absence BLOCKS the /// Capabilities without which syncing is meaningless, so their absence BLOCKS the
/// link rather than degrading it. /// link rather than degrading it.
+23 -76
View File
@@ -240,13 +240,12 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
// `created_at` is deliberately absent from the UPDATE clause: a note's birth time // `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. // never changes, and the server's copy is the same value anyway.
conn.execute( conn.execute(
"INSERT INTO notes (id, body, color, position, pinned, archived, "INSERT INTO notes (id, body, position, pinned, archived,
trashed, remind_at, recurrence, created_at, updated_at, trashed, remind_at, recurrence, created_at, updated_at,
sync_revision, trashed_at, dirty) sync_revision, trashed_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 0) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 0)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
body = excluded.body, body = excluded.body,
color = excluded.color,
position = excluded.position, position = excluded.position,
pinned = excluded.pinned, pinned = excluded.pinned,
archived = excluded.archived, archived = excluded.archived,
@@ -260,7 +259,6 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
params![ params![
note.id, note.id,
note.body, note.body,
note.color,
note.position, note.position,
note.pinned, note.pinned,
note.archived, note.archived,
@@ -277,34 +275,12 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
// Children are replaced wholesale: a delta carries the note's FULL current state, // 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 // 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. // could leave behind a row the server no longer has.
replace_items(conn, note)?;
replace_attachments(conn, note)?; replace_attachments(conn, note)?;
replace_previews(conn, note)?; replace_previews(conn, note)?;
replace_labels(conn, note)?; replace_labels(conn, note)?;
Ok(()) 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<()> { fn replace_attachments(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
conn.execute( conn.execute(
"DELETE FROM attachments WHERE note_id = ?1", "DELETE FROM attachments WHERE note_id = ?1",
@@ -393,16 +369,6 @@ fn ensure_label_stub(conn: &Connection, label: &wire::NoteLabel) -> rusqlite::Re
Ok(()) 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. /// 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 /// NOTE ON ORDERING: the full cycle is push-then-pull (docs/sync.md). Running this
@@ -495,7 +461,6 @@ mod tests {
wire::Note { wire::Note {
id: id.to_string(), id: id.to_string(),
body: "Body".into(), body: "Body".into(),
color: "default".into(),
position: 0, position: 0,
pinned: false, pinned: false,
archived: false, archived: false,
@@ -508,12 +473,22 @@ mod tests {
sync_revision: revision, sync_revision: revision,
purged_at: None, purged_at: None,
labels: vec![], labels: vec![],
items: vec![],
attachments: vec![], attachments: vec![],
previews: 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 { fn page(notes: Vec<wire::Note>, labels: Vec<wire::Label>, cursor: i64) -> wire::ChangesPage {
wire::ChangesPage { wire::ChangesPage {
notes, notes,
@@ -612,35 +587,20 @@ mod tests {
#[test] #[test]
fn children_are_replaced_not_merged() { 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 conn = db();
let mut first = note("n1", 1); let mut first = note("n1", 1);
first.items = vec![ first.attachments = vec![attachment("a1"), attachment("a2")];
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"); apply_page(&conn, &page(vec![first], vec![], 1)).expect("apply");
assert_eq!(count(&conn, "SELECT COUNT(*) FROM checklist_items"), 2); assert_eq!(count(&conn, "SELECT COUNT(*) FROM attachments"), 2);
// The server dropped an item; the local copy must drop it too.
let mut second = note("n1", 2); let mut second = note("n1", 2);
second.items = vec![wire::Item { second.attachments = vec![attachment("a1")];
id: "i1".into(),
text: "one".into(),
checked: true,
position: 0,
}];
apply_page(&conn, &page(vec![second], vec![], 2)).expect("apply"); apply_page(&conn, &page(vec![second], vec![], 2)).expect("apply");
assert_eq!(count(&conn, "SELECT COUNT(*) FROM checklist_items"), 1); assert_eq!(count(&conn, "SELECT COUNT(*) FROM attachments"), 1);
} }
#[test] #[test]
@@ -810,23 +770,10 @@ mod tests {
fn a_page_that_fails_leaves_the_cursor_untouched() { fn a_page_that_fails_leaves_the_cursor_untouched() {
// Atomicity is the whole resumability story: a cursor committed ahead of its // Atomicity is the whole resumability story: a cursor committed ahead of its
// data would skip those rows forever. Force a failure with a duplicate // data would skip those rows forever. Force a failure with a duplicate
// checklist-item id inside one page. // attachment id inside one page.
let conn = db(); let conn = db();
let mut n = note("n1", 3); let mut n = note("n1", 3);
n.items = vec![ n.attachments = vec![attachment("dup"), attachment("dup")];
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!(apply_page(&conn, &page(vec![n], vec![], 3)).is_err());
assert_eq!(state::read(&conn).expect("state").last_cursor, 0); assert_eq!(state::read(&conn).expect("state").last_cursor, 0);
assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 0); assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 0);
+15 -38
View File
@@ -64,6 +64,8 @@ pub struct Change {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>, 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")] #[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>, pub color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -79,8 +81,6 @@ pub struct Change {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub position: Option<i64>, pub position: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")] #[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>>, pub label_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>, pub created_at: Option<String>,
@@ -103,7 +103,6 @@ impl Change {
remind_at: None, remind_at: None,
recurrence: None, recurrence: None,
position: None, position: None,
items: None,
label_ids: None, label_ids: None,
created_at: None, created_at: None,
name: None, name: None,
@@ -111,12 +110,6 @@ impl Change {
} }
} }
#[derive(Debug, Serialize)]
pub struct ItemOut {
pub text: String,
pub checked: bool,
}
// --- incoming results -------------------------------------------------------- // --- incoming results --------------------------------------------------------
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -201,7 +194,6 @@ fn collect_labels(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rus
remind_at: None, remind_at: None,
recurrence: None, recurrence: None,
position: None, position: None,
items: None,
label_ids: None, label_ids: None,
created_at: None, created_at: None,
}) })
@@ -230,7 +222,6 @@ fn collect_notes(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rusq
/// field-to-column mapping stays readable at the call site. /// field-to-column mapping stays readable at the call site.
struct NoteRow { struct NoteRow {
body: String, body: String,
color: String,
position: i64, position: i64,
pinned: bool, pinned: bool,
archived: bool, archived: bool,
@@ -243,22 +234,21 @@ struct NoteRow {
fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> { fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
conn.query_row( conn.query_row(
"SELECT body, color, position, pinned, archived, trashed, "SELECT body, position, pinned, archived, trashed,
remind_at, recurrence, created_at, updated_at remind_at, recurrence, created_at, updated_at
FROM notes WHERE id = ?1", FROM notes WHERE id = ?1",
params![id], params![id],
|r| { |r| {
Ok(NoteRow { Ok(NoteRow {
body: r.get(0)?, body: r.get(0)?,
color: r.get(1)?, position: r.get(1)?,
position: r.get(2)?, pinned: r.get::<_, i64>(2)? != 0,
pinned: r.get::<_, i64>(3)? != 0, archived: r.get::<_, i64>(3)? != 0,
archived: r.get::<_, i64>(4)? != 0, trashed: r.get::<_, i64>(4)? != 0,
trashed: r.get::<_, i64>(5)? != 0, remind_at: r.get(5)?,
remind_at: r.get(6)?, recurrence: r.get(6)?,
recurrence: r.get(7)?, created_at: r.get(7)?,
created_at: r.get(8)?, updated_at: r.get(8)?,
updated_at: r.get(9)?,
}) })
}, },
) )
@@ -267,19 +257,6 @@ fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> { fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> {
let row = note_row(conn, id)?; 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 // 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 // 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. // assignments that no longer disappear when the #tag is removed from the text.
@@ -298,14 +275,14 @@ fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> {
// server's last-write-wins comparison runs against. // server's last-write-wins comparison runs against.
edited_at: row.updated_at, edited_at: row.updated_at,
body: Some(row.body), body: Some(row.body),
color: Some(row.color), // A note has no colour to send. See the field on `Change`.
color: None,
pinned: Some(row.pinned), pinned: Some(row.pinned),
archived: Some(row.archived), archived: Some(row.archived),
trashed: Some(row.trashed), trashed: Some(row.trashed),
remind_at: row.remind_at, remind_at: row.remind_at,
recurrence: row.recurrence, recurrence: row.recurrence,
position: Some(row.position), position: Some(row.position),
items: Some(items),
label_ids: Some(label_ids), label_ids: Some(label_ids),
created_at: Some(row.created_at), created_at: Some(row.created_at),
name: None, name: None,
@@ -521,9 +498,9 @@ mod tests {
fn seed_note(conn: &Connection, id: &str, dirty: i64) { fn seed_note(conn: &Connection, id: &str, dirty: i64) {
conn.execute( conn.execute(
"INSERT INTO notes (id, body, color, position, pinned, archived, "INSERT INTO notes (id, body, position, pinned, archived,
trashed, created_at, updated_at, sync_revision, dirty) trashed, created_at, updated_at, sync_revision, dirty)
VALUES (?1, 'B', 'default', 0, 0, 0, 0, VALUES (?1, 'B', 0, 0, 0, 0,
'2026-07-26T00:00:00.000Z', '2026-07-26T00:00:00.000Z', 3, ?2)", '2026-07-26T00:00:00.000Z', '2026-07-26T00:00:00.000Z', 3, ?2)",
params![id, dirty], params![id, dirty],
) )
-15
View File
@@ -25,8 +25,6 @@ pub struct Note {
pub id: String, pub id: String,
#[serde(default)] #[serde(default)]
pub body: String, pub body: String,
#[serde(default = "default_color")]
pub color: String,
#[serde(default)] #[serde(default)]
pub position: i64, pub position: i64,
#[serde(default)] #[serde(default)]
@@ -58,8 +56,6 @@ pub struct Note {
#[serde(default)] #[serde(default)]
pub labels: Vec<NoteLabel>, pub labels: Vec<NoteLabel>,
#[serde(default)] #[serde(default)]
pub items: Vec<Item>,
#[serde(default)]
pub attachments: Vec<Attachment>, pub attachments: Vec<Attachment>,
#[serde(default)] #[serde(default)]
pub previews: Vec<Preview>, pub previews: Vec<Preview>,
@@ -87,17 +83,6 @@ pub struct NoteLabel {
pub via_tag: bool, 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)] #[derive(Debug, Clone, Deserialize)]
pub struct Attachment { pub struct Attachment {
pub id: String, pub id: String,
+1 -1
View File
@@ -18,7 +18,7 @@ pacman system:
curl -fsSL https://git.fabledsword.com/bvandeusen/thoughtsync/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 tagged release. To follow the rolling development That installs the newest build from `main`. To follow the rolling development
channel instead, pass the flag through the pipe: channel instead, pass the flag through the pipe:
```sh ```sh
+3 -1
View File
@@ -64,7 +64,9 @@ DEPENDS=(webkit2gtk-4.1 gtk3)
# this?" question unanswerable. # this?" question unanswerable.
# `|| true` so a miss falls through to the explicit error below rather than # `|| true` so a miss falls through to the explicit error below rather than
# aborting on pipefail with no explanation. # aborting on pipefail with no explanation.
PKGVER="$(sh "$SCRIPT_DIR/../build-version.sh" || true)" # 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)"
[ -n "$PKGVER" ] || { echo "ERROR: could not determine the build version" >&2; exit 1; } [ -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 # Reproducible-ish: prefer the commit date over "now" so rebuilding the same
-32
View File
@@ -1,32 +0,0 @@
#!/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
+24 -20
View File
@@ -5,8 +5,12 @@
# curl -fsSL https://git.fabledsword.com/bvandeusen/thoughtsync/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): # Two channels, the SAME two the app's own updater offers (src-tauri/src/update.rs):
# stable (default) — the newest tagged v* release. # stable (default) — the rolling build from every merge to `main`.
# dev — the rolling build from every green push to `dev`. # 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.
# Pick one with `--channel dev` or `TS_CHANNEL=dev`. Through a pipe the options go # Pick one with `--channel dev` or `TS_CHANNEL=dev`. Through a pipe the options go
# after a `--`: curl -fsSL <url> | sh -s -- --channel dev # after a `--`: curl -fsSL <url> | sh -s -- --channel dev
# #
@@ -41,7 +45,7 @@ ThoughtSync desktop installer.
install.sh [--channel stable|dev] install.sh [--channel stable|dev]
--channel stable newest tagged release (default) --channel stable newest build from main (default)
--channel dev rolling build from the latest green push to `dev` --channel dev rolling build from the latest green push to `dev`
-h, --help this text -h, --help this text
@@ -82,20 +86,23 @@ esac
# --- resolve the release for this channel ----------------------------------- # --- resolve the release for this channel -----------------------------------
say "Finding the latest ThoughtSync build on the $channel channel…" say "Finding the latest ThoughtSync build on the $channel channel…"
if [ "$channel" = "dev" ]; then # ONE lookup for both channels now. Each is a release whose tag never moves and whose
# A release whose tag never moves and whose assets are pruned to the current # assets are pruned to the current build, so the tag alone names the newest build on
# build — so the tag alone always names the newest dev build. # that channel — which is exactly what an installer wants and what the in-app updater
json="$(curl -fsSL "$API/releases/tags/dev" 2>/dev/null)" || # already reads.
die "the dev channel has nothing published yet." json="$(curl -fsSL "$API/releases/tags/$channel" 2>/dev/null)" ||
else die "the $channel channel has nothing published yet."
# 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 # TRANSITIONAL — delete with the rest of the old scheme (M314 step 7).
# and the updater can never disagree about what `stable` means. #
# # `stable` existed before this as a manifest-ONLY pointer: `latest.json` naming a
# Not `/releases/latest`: that returns the newest non-prerelease release by date, # version whose bundles lived on a separate `v<version>` release. Between this commit
# and the `stable` pointer release (manifest only, no bundles — see # and the first merge to `main` it still looks like that, and `stable` is the DEFAULT
# write-manifest.sh) is itself a non-prerelease created moments after the # channel — so without this fallback `curl … | sh` is broken for everyone in that
# versioned one. It would win, and it carries nothing installable. # window. It costs nothing once main has published: the grep finds the bundles and
# this branch never runs again.
if [ "$channel" = "stable" ] && ! printf '%s' "$json" | grep -q "releases/download/stable/[^\"]*\.\(AppImage\|deb\|pkg\.tar\)"; then
say "stable has no bundles of its own yet — falling back to the version its manifest names."
manifest="$(curl -fsSL "$INSTANCE/$REPO/releases/download/stable/latest.json" 2>/dev/null || true)" manifest="$(curl -fsSL "$INSTANCE/$REPO/releases/download/stable/latest.json" 2>/dev/null || true)"
stable_version="$(printf '%s' "$manifest" | stable_version="$(printf '%s' "$manifest" |
grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 | grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 |
@@ -104,11 +111,8 @@ else
json="$(curl -fsSL "$API/releases/tags/v$stable_version" 2>/dev/null)" || 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." die "the stable channel names $stable_version, but there is no v$stable_version release to install."
else 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)" || 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." die "no stable build published yet — try --channel dev, or merge to main."
fi fi
fi fi
+24
View File
@@ -118,6 +118,11 @@ first_id() { grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE
# install.sh defaults to stable, so the rolling dev release must opt in explicitly # install.sh defaults to stable, so the rolling dev release must opt in explicitly
# — otherwise someone following the instructions here lands on a tagged build and # — otherwise someone following the instructions here lands on a tagged build and
# wonders why the version they were sent isn't what they got. # wonders why the version they were sent isn't what they got.
#
# Both CHANNELS are rolling pointer releases (M314 step 3): `dev` republishes on
# every green push to dev, `stable` on every merge to main. Each says so, because a
# release that prunes its own assets behaves differently from a versioned one and a
# reader deserves to know which they are looking at.
if [ "$TAG" = "dev" ]; then if [ "$TAG" = "dev" ]; then
INSTALL_TAIL='sh -s -- --channel dev' INSTALL_TAIL='sh -s -- --channel dev'
# Backticks BARE, not `\``. The heredoc below is unquoted, so there the backslash # Backticks BARE, not `\``. The heredoc below is unquoted, so there the backslash
@@ -125,6 +130,10 @@ if [ "$TAG" = "dev" ]; then
# Here single quotes already do that job, so a backslash would survive into the # Here single quotes already do that job, so a backslash would survive into the
# body as `\``, which is not a legal JSON escape: Forgejo answers 422. # body as `\``, which is not a legal JSON escape: Forgejo answers 422.
CHANNEL_NOTE='\n\nThis is the rolling **dev** channel: republished on every green push to `dev`, and pruned to the current build.' CHANNEL_NOTE='\n\nThis is the rolling **dev** channel: republished on every green push to `dev`, and pruned to the current build.'
elif [ "$TAG" = "stable" ]; then
# install.sh defaults to stable, so no flag.
INSTALL_TAIL='sh'
CHANNEL_NOTE='\n\nThis is the rolling **stable** channel: republished on every merge to `main`, and pruned to the current build. No tag is required for a build to arrive here.'
else else
INSTALL_TAIL='sh' INSTALL_TAIL='sh'
CHANNEL_NOTE='' CHANNEL_NOTE=''
@@ -136,6 +145,21 @@ BODY=$(cat <<JSON
"body":"ThoughtSync $TAG.\n\n**Desktop**\n\n- **Debian / Ubuntu** — native \`.deb\`\n- **Arch / CachyOS** — native \`.pkg.tar.*\`\n- **everything else** — \`.AppImage\` (de-bundled graphics: renders on any GPU/Wayland setup)\n\nInstall / update — picks the right one for your system:\n\`\`\`\ncurl -fsSL $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/branch/dev/desktop/packaging/install.sh | $INSTALL_TAIL\n\`\`\`\n\n**Android** — \`thoughtsync.apk\`. Copy it and \`thoughtsync-android.json\` into your server's \`/var/thoughtsync/client/\` and the server will offer it to your devices; see docs/android-distribution.md.$CHANNEL_NOTE"} "body":"ThoughtSync $TAG.\n\n**Desktop**\n\n- **Debian / Ubuntu** — native \`.deb\`\n- **Arch / CachyOS** — native \`.pkg.tar.*\`\n- **everything else** — \`.AppImage\` (de-bundled graphics: renders on any GPU/Wayland setup)\n\nInstall / update — picks the right one for your system:\n\`\`\`\ncurl -fsSL $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/branch/dev/desktop/packaging/install.sh | $INSTALL_TAIL\n\`\`\`\n\n**Android** — \`thoughtsync.apk\`. Copy it and \`thoughtsync-android.json\` into your server's \`/var/thoughtsync/client/\` and the server will offer it to your devices; see docs/android-distribution.md.$CHANNEL_NOTE"}
JSON JSON
) )
# An explicit body, replacing the install instructions above.
#
# Used by the RELEASE lane, whose job is a changelog rather than artifacts (M314
# step 7). It goes through this script rather than making its own API calls so that
# the create-or-PATCH-on-409 path is shared: a fixed-tag release that only ever
# POSTs keeps whatever text its FIRST build wrote, which is #2182 exactly, and
# re-implementing that correctly in a second place is how it comes back.
#
# CONTRACT: already JSON-escaped, without surrounding quotes. The caller knows
# whether it has a JSON encoder; this script cannot assume python3 is on PATH in
# every image that sources it.
if [ -n "${RELEASE_BODY_JSON:-}" ]; then
BODY="{\"tag_name\":\"$TAG\",\"name\":\"ThoughtSync $TAG\",\"draft\":false,\"prerelease\":$RELEASE_PRERELEASE,\"body\":\"$RELEASE_BODY_JSON\"}"
fi
# 409 = a release for this tag already exists (re-run) — fall through to lookup. # 409 = a release for this tag already exists (re-run) — fall through to lookup.
release="$(ALLOW_CODES=409 api POST "$API/releases" -H "Content-Type: application/json" -d "$BODY")" release="$(ALLOW_CODES=409 api POST "$API/releases" -H "Content-Type: application/json" -d "$BODY")"
RELEASE_ID="$(printf '%s' "$release" | first_id || true)" RELEASE_ID="$(printf '%s' "$release" | first_id || true)"
+15 -28
View File
@@ -25,14 +25,15 @@ set -euo pipefail
: "${RELEASE_TAG:?RELEASE_TAG is required (the release holding the bundles)}" : "${RELEASE_TAG:?RELEASE_TAG is required (the release holding the bundles)}"
: "${APP_VERSION:?APP_VERSION is required (the version the bundles carry)}" : "${APP_VERSION:?APP_VERSION is required (the version the bundles carry)}"
# Where the manifest is PUBLISHED, which need not be where the bundles live. # The manifest is published to the release that HOLDS the bundles. There is no
# second place any more.
# #
# That split is what makes the stable channel work at all. A versioned release # There used to be: `MANIFEST_TAG` let the manifest live on a `stable` pointer
# (`v0.2.0`) holds the real assets, but the app can only read a URL that never # release while the bundles sat on a versioned `v*` one, because the app can only
# changes — so the same manifest is also attached to a `stable` release whose tag is # read a URL that never changes and a versioned tag is not that. M314 step 3 made
# permanent and whose only content is this file. It points back at the versioned # `stable` a rolling release that holds its own bundles, exactly like `dev`, so the
# assets, so nothing is duplicated. # split had nothing left to bridge — and a parameter that can only ever be passed
MANIFEST_TAG="${MANIFEST_TAG:-$RELEASE_TAG}" # its own default is a branch nobody exercises and a comment that goes stale.
API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY" API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
AUTH=(-H "Authorization: token $GITHUB_TOKEN") AUTH=(-H "Authorization: token $GITHUB_TOKEN")
@@ -115,25 +116,11 @@ pub_date="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
echo "==> Manifest:" echo "==> Manifest:"
cat "$work/latest.json" cat "$work/latest.json"
# --- resolve the release the manifest is published TO ------------------------ # The manifest goes on the same release the bundles were just read from — which is
if [ "$MANIFEST_TAG" = "$RELEASE_TAG" ]; then # also the one `publish-release.sh` created or refreshed moments earlier, so it is
target_id="$release_id" # guaranteed to exist by the time this runs.
target_assets="$assets" target_id="$release_id"
else target_assets="$assets"
echo "==> Resolving the $MANIFEST_TAG channel release"
target="$(curl -sS "${AUTH[@]}" "$API/releases/tags/$MANIFEST_TAG")"
target_id="$(printf '%s' "$target" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+' || true)"
if [ -z "${target_id:-}" ]; then
# First publish to this channel. A pointer release: no bundles of its own, just
# a permanent tag for the manifest to live under.
echo " creating it (pointer release, manifest only)"
body="{\"tag_name\":\"$MANIFEST_TAG\",\"name\":\"ThoughtSync ($MANIFEST_TAG channel)\",\"draft\":false,\"prerelease\":false,\"body\":\"Update channel pointer. The installable builds live on the versioned releases; this holds only the updater manifest.\"}"
target="$(curl -sS -X POST "${AUTH[@]}" -H "Content-Type: application/json" -d "$body" "$API/releases")"
target_id="$(printf '%s' "$target" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')"
fi
[ -n "${target_id:-}" ] || { echo "ERROR: could not resolve the $MANIFEST_TAG release" >&2; exit 1; }
target_assets="$(curl -sS "${AUTH[@]}" "$API/releases/$target_id/assets")"
fi
# Replace rather than duplicate: Forgejo rejects a second asset with the same name, # Replace rather than duplicate: Forgejo rejects a second asset with the same name,
# and this file is rewritten on every publish by design. # and this file is rewritten on every publish by design.
@@ -145,11 +132,11 @@ if [ -n "${old_id:-}" ]; then
curl -fsS -X DELETE "${AUTH[@]}" "$API/releases/$target_id/assets/$old_id" >/dev/null curl -fsS -X DELETE "${AUTH[@]}" "$API/releases/$target_id/assets/$old_id" >/dev/null
fi fi
echo "==> Uploading latest.json to $MANIFEST_TAG" echo "==> Uploading latest.json to $RELEASE_TAG"
curl -fsS -X POST "${AUTH[@]}" "$API/releases/$target_id/assets?name=latest.json" \ curl -fsS -X POST "${AUTH[@]}" "$API/releases/$target_id/assets?name=latest.json" \
-F "attachment=@$work/latest.json" >/dev/null -F "attachment=@$work/latest.json" >/dev/null
echo "==> Done. $MANIFEST_TAG now advertises $APP_VERSION for ${#entries[@]} platform(s)." echo "==> Done. $RELEASE_TAG now advertises $APP_VERSION for ${#entries[@]} platform(s)."
# --- prune superseded builds from a rolling channel --------------------------- # --- prune superseded builds from a rolling channel ---------------------------
# #
+12
View File
@@ -1,5 +1,17 @@
[package] [package]
name = "thoughtsync-desktop" name = "thoughtsync-desktop"
# NOT THE SHIPPED VERSION, and bumping it has no effect on anything a user sees.
#
# Cargo requires a version here, and Tauri reads one from `tauri.conf.json` — both
# are overridden per build by `cargo tauri build --config '{"version": ...}'` with
# the value `packaging/version.sh key desktop` derives. See #3144.
#
# It used to matter: the old scheme took its base from this line and appended the CI
# run number on dev, so `0.2.<run>` on dev sat against a bare `0.2.0` on main and
# every dev build outranked every stable one. The remedy was "remember to bump the
# minor before tagging" — documented in a comment, enforced nowhere, and #2183 is
# what that looked like in the field. A scheme needing a human to remember something
# before each release has not removed the decision, only hidden it.
version = "0.2.0" version = "0.2.0"
description = "ThoughtSync desktop — local-first Keep-style thought capture" description = "ThoughtSync desktop — local-first Keep-style thought capture"
authors = ["bvandeusen"] authors = ["bvandeusen"]
+11 -5
View File
@@ -1,10 +1,16 @@
//! In-app updates (M10.9). //! In-app updates (M10.9).
//! //!
//! Two channels, because two audiences: `stable` follows tagged `v*` releases, //! Two channels, because two audiences: `stable` follows every merge to `main`,
//! `dev` follows every green push. Each reads a `latest.json` published as an asset //! `dev` follows every green push to `dev`. Each reads a `latest.json` published as
//! on a release whose TAG NEVER CHANGES — verified necessary, because Forgejo has no //! an asset on a release whose TAG NEVER CHANGES — verified necessary, because
//! `/releases/latest/download/<asset>` route (it 404s), so "newest" cannot be named //! Forgejo has no `/releases/latest/download/<asset>` route (it 404s), so "newest"
//! in a URL. A fixed tag can. //! cannot be named in a URL. A fixed tag can.
//!
//! `stable` followed tagged `v*` releases until M314 step 3, and its manifest pointed
//! at bundles living on a different release. It holds its own bundles now, exactly as
//! `dev` always has — so a build reaches stable users with no tag cut anywhere, which
//! is the whole point of the change. NOTHING HERE MOVED: this code only ever read
//! `<channel>/latest.json`, and that is still where the manifest lands.
//! //!
//! The feed lives on Fabled-Git rather than on a ThoughtSync server, deliberately: //! The feed lives on Fabled-Git rather than on a ThoughtSync server, deliberately:
//! this app is usable having never linked a server, and an install that can't reach //! this app is usable having never linked a server, and an install that can't reach
+12 -6
View File
@@ -15,13 +15,19 @@ cannot talk to.
**Normally: nowhere. It is already in the image.** **Normally: nowhere. It is already in the image.**
CI fetches the newest published Android build into every server image it builds, CI fetches the published Android build into every server image it builds, so
so `:dev`, `:latest` and `:<version>` all ship a client. `docker compose pull && `:dev` and `:latest` both ship a client. `docker compose pull && docker compose
docker compose up -d` delivers a new server and a new client together, and there up -d` delivers a new server and a new client together, and there is nothing to
is nothing to copy. copy.
A versioned image therefore carries the *newest* client rather than one pinned to **The channel is a property of the image you run.** A `:dev` image bakes in the
that version. That is deliberate: the two negotiate a sync protocol version dev-channel APK, `:latest` the stable one — so pointing a phone at a stable
server gets it a stable client, with no second place holding that decision. (Until
M314 step 3 the fetch was hard-wired to the dev release on every branch, so a
stable server served a dev client.)
An image therefore carries the *newest* client on its channel rather than one
pinned to a version. That is deliberate: the two negotiate a sync protocol version
before they link, so a mismatch is caught by the handshake rather than by before they link, so a mismatch is caught by the handshake rather than by
pinning. pinning.
+20 -5
View File
@@ -52,6 +52,15 @@ syncs everything else.
### The policy ### The policy
- **Any wire change** → bump `SYNC_PROTOCOL_VERSION`. - **Any wire change** → bump `SYNC_PROTOCOL_VERSION`.
- v2 (M13): `kind` and `title` left the wire; **floor raised**, because a v1
client kept pushing both and read back notes carrying neither — and `title` was
the note's NAME, so an old client showed nameless notes.
- v3: attachments/tombstones/revisions.
- v4 (M315): `color` left the note; **floor NOT raised**. Both directions degrade
in silence and neither loses anything visible — an old client reading a v4 note
falls back to the colour it derives locally, and one pushing `color` has the key
ignored. The test is not "did a field leave" but "does either side end up
showing something wrong".
- **Additive change** (a new field, a new capability) → add a `sync_features` - **Additive change** (a new field, a new capability) → add a `sync_features`
name. Do **not** raise a minimum. Old clients keep working. name. Do **not** raise a minimum. Old clients keep working.
- **Breaking change only** → raise `MIN_CLIENT_PROTOCOL_VERSION` (or the client's - **Breaking change only** → raise `MIN_CLIENT_PROTOCOL_VERSION` (or the client's
@@ -190,18 +199,24 @@ Body: `{ "changes": [ ... ] }` (max 1000 per batch). Each change:
```json ```json
{ "entity": "note", "id": "<uuid>", "op": "upsert", "edited_at": "<iso8601>", { "entity": "note", "id": "<uuid>", "op": "upsert", "edited_at": "<iso8601>",
"title": "...", "body": "...", "color": "blue", "kind": "text", "body": "...",
"pinned": false, "archived": false, "trashed": false, "remind_at": null, "pinned": false, "archived": false, "trashed": false, "remind_at": null,
"position": 0, "items": [ {"text": "...", "checked": false} ], "recurrence": null, "position": 0,
"label_ids": ["<uuid>", ...], "created_at": "<iso8601, on create>" } "label_ids": ["<uuid>", ...], "created_at": "<iso8601, on create>" }
``` ```
- **Client-generated ids.** Notes/labels are UUIDs; the client mints the id when - **Client-generated ids.** Notes/labels are UUIDs; the client mints the id when
it creates the row offline and sends it here. Create-if-absent, else update. it creates the row offline and sends it here. Create-if-absent, else update.
- **Whole-note semantics.** A note upsert carries the client's *full* current - **Whole-note semantics.** A note upsert carries the client's *full* current
state (not a partial patch) — the server overwrites all scalar fields, replaces state (not a partial patch) — the server overwrites all scalar fields and sets
items, and sets manual label memberships from `label_ids` (tag-sourced labels manual label memberships from `label_ids` (tag-sourced labels are re-derived
are re-derived from the body). `#tags` are recomputed server-side. from the body). `#tags` are recomputed server-side. A checklist is `- [ ] ` lines
inside `body` (M304), so there is no separate `items` array.
- **Fields a change may still carry, and the server reads past.** `title` and
`kind` (removed in v2), `items` (M304) and `color` (v4, M315). The server reads
its payload key by key and never validates the shape, which is exactly what lets
an older client keep pushing a field this one has stopped storing — see the
version policy above for why none of those needed a floor raise on their own.
- **`op: "delete"`** purges (tombstones) the row. Trashing is just an upsert with - **`op: "delete"`** purges (tombstones) the row. Trashing is just an upsert with
`trashed: true`. `trashed: true`.
- **Labels:** `{entity: "label", op: "upsert"|"delete", id, edited_at, name, - **Labels:** `{entity: "label", op: "upsert"|"delete", id, edited_at, name,
+1 -3
View File
@@ -9,7 +9,6 @@
// consume. Client-side logic (list reconciliation, optimistic updates, toasts) // consume. Client-side logic (list reconciliation, optimistic updates, toasts)
// stays in the stores — the repo is data access only. // stays in the stores — the repo is data access only.
import type { NoteColor } from "../notes/colors";
import type { Note, NoteFacets, NoteView, NoteRevision } from "../stores/notes"; import type { Note, NoteFacets, NoteView, NoteRevision } from "../stores/notes";
import type { Label } from "../stores/labels"; import type { Label } from "../stores/labels";
import type { SavedFilter } from "../stores/savedFilters"; import type { SavedFilter } from "../stores/savedFilters";
@@ -33,13 +32,12 @@ export interface NoteListQuery {
export interface NoteCreateInput { export interface NoteCreateInput {
body: string; body: string;
color: NoteColor;
items?: string[]; items?: string[];
} }
// The mutable subset of a note (PATCH /api/notes/:id). // The mutable subset of a note (PATCH /api/notes/:id).
export type NoteChanges = Partial< export type NoteChanges = Partial<
Pick<Note, "body" | "color" | "pinned" | "archived" | "remind_at" | "recurrence"> Pick<Note, "body" | "pinned" | "archived" | "remind_at" | "recurrence">
>; >;
export interface ChecklistItemChanges { export interface ChecklistItemChanges {
-1
View File
@@ -31,7 +31,6 @@ function notesQuery(q: NoteListQuery): string {
if (q.labelId) params.append("label", q.labelId); if (q.labelId) params.append("label", q.labelId);
for (const id of q.facets?.label ?? []) if (id) params.append("label", id); for (const id of q.facets?.label ?? []) if (id) params.append("label", id);
if (q.facets?.q) params.set("q", q.facets.q); if (q.facets?.q) params.set("q", q.facets.q);
if (q.facets?.color) params.set("color", q.facets.color);
if (q.facets?.has_reminder) params.set("has_reminder", "true"); if (q.facets?.has_reminder) params.set("has_reminder", "true");
if (q.facets?.has_attachment) params.set("has_attachment", "true"); if (q.facets?.has_attachment) params.set("has_attachment", "true");
if (q.facets?.created_after) params.set("created_after", q.facets.created_after); if (q.facets?.created_after) params.set("created_after", q.facets.created_after);
+6 -4
View File
@@ -14,7 +14,7 @@ import ImportNotes from "./ImportNotes.vue";
import LabelsModal from "./LabelsModal.vue"; import LabelsModal from "./LabelsModal.vue";
import { isDesktop } from "../desktop/bridge"; import { isDesktop } from "../desktop/bridge";
import { facetsToQuery } from "../notes/facets"; import { facetsToQuery } from "../notes/facets";
import { NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors"; import { NOTE_SWATCH_CLASSES, resolveLabelColor } from "../notes/colors";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -173,8 +173,10 @@ onBeforeUnmount(() => {
const currentLabelId = computed(() => (route.name === "label" ? String(route.params.id) : null)); const currentLabelId = computed(() => (route.name === "label" ? String(route.params.id) : null));
function labelDot(color: string): string { // The drawer's tag list. Same resolution as every other chip and dot — a tag that is
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default; // green on a card must be green here, or the sidebar stops being a way to find it.
function labelDot(label: { name: string; color: string }): string {
return NOTE_SWATCH_CLASSES[resolveLabelColor(label)] ?? NOTE_SWATCH_CLASSES.default;
} }
// The board lenses — the routes a search can happen *within*. Searching while looking // The board lenses — the routes a search can happen *within*. Searching while looking
@@ -443,7 +445,7 @@ async function signOut() {
> >
<span <span
class="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10 dark:border-white/15" class="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10 dark:border-white/15"
:class="labelDot(lb.color)" :class="labelDot(lb)"
></span> ></span>
<span class="truncate">{{ lb.name }}</span> <span class="truncate">{{ lb.name }}</span>
</RouterLink> </RouterLink>
-24
View File
@@ -1,24 +0,0 @@
<script setup lang="ts">
import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
defineProps<{ modelValue: NoteColor }>();
defineEmits<{ (e: "update:modelValue", value: NoteColor): void }>();
</script>
<template>
<div class="flex flex-wrap items-center gap-1.5">
<button
v-for="key in NOTE_COLOR_KEYS"
:key="key"
type="button"
:title="NOTE_COLOR_LABELS[key]"
:aria-label="NOTE_COLOR_LABELS[key]"
:aria-pressed="modelValue === key"
class="h-6 w-6 rounded-full border border-black/10 transition hover:scale-110
focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-1
focus-visible:ring-offset-white dark:focus-visible:ring-offset-neutral-900"
:class="[NOTE_SWATCH_CLASSES[key], modelValue === key ? 'ring-2 ring-brand ring-offset-1' : '']"
@click="$emit('update:modelValue', key)"
/>
</div>
</template>
-18
View File
@@ -7,7 +7,6 @@ import { useUiStore } from "../stores/ui";
import type { NoteFacets } from "../stores/notes"; import type { NoteFacets } from "../stores/notes";
import { facetCount, facetsFromQuery, facetsToQuery } from "../notes/facets"; import { facetCount, facetsFromQuery, facetsToQuery } from "../notes/facets";
import { addLocalDays, formatLocalDay, parseLocalDate } from "../notes/datetime"; import { addLocalDays, formatLocalDay, parseLocalDate } from "../notes/datetime";
import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
// A dead-simple facet bar over the board: color + labels + has-reminder // A dead-simple facet bar over the board: color + labels + has-reminder
@@ -32,9 +31,6 @@ function patch(p: Partial<NoteFacets>) {
function clearAll() { function clearAll() {
void router.replace({ path: "/", query: {} }); void router.replace({ path: "/", query: {} });
} }
function setColor(c: NoteColor) {
patch({ color: facets.value.color === c ? undefined : c });
}
function toggleLabel(id: string) { function toggleLabel(id: string) {
const cur = facets.value.label ?? []; const cur = facets.value.label ?? [];
const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id]; const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
@@ -111,20 +107,6 @@ const chipOff = "border-neutral-300 text-neutral-600 hover:bg-neutral-100 dark:b
v-if="open" v-if="open"
class="mt-2 flex flex-col gap-3 rounded-xl border border-neutral-200 p-3 dark:border-neutral-800" class="mt-2 flex flex-col gap-3 rounded-xl border border-neutral-200 p-3 dark:border-neutral-800"
> >
<div class="flex flex-wrap items-center gap-1.5">
<span class="w-16 shrink-0 text-xs text-neutral-400">Color</span>
<button
v-for="c in NOTE_COLOR_KEYS"
:key="c"
type="button"
:title="NOTE_COLOR_LABELS[c]"
:aria-label="NOTE_COLOR_LABELS[c]"
class="h-6 w-6 rounded-full border border-black/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-white/10"
:class="[NOTE_SWATCH_CLASSES[c], facets.color === c ? 'ring-2 ring-brand ring-offset-1' : '']"
@click="setColor(c)"
/>
</div>
<div v-if="labels.items.length" class="flex flex-wrap items-center gap-1.5"> <div v-if="labels.items.length" class="flex flex-wrap items-center gap-1.5">
<span class="w-16 shrink-0 text-xs text-neutral-400">Labels</span> <span class="w-16 shrink-0 text-xs text-neutral-400">Labels</span>
<button <button
+17 -7
View File
@@ -1,7 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { useLabelsStore, type Label } from "../stores/labels"; import { useLabelsStore, type Label } from "../stores/labels";
import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors"; import {
NOTE_COLOR_KEYS,
NOTE_COLOR_LABELS,
NOTE_SWATCH_CLASSES,
resolveLabelColor,
type NoteColor,
} from "../notes/colors";
import BaseModal from "./BaseModal.vue"; import BaseModal from "./BaseModal.vue";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
@@ -25,8 +31,12 @@ async function rename(id: string, value: string) {
if (name) await labels.rename(id, name); if (name) await labels.rename(id, name);
} }
function labelDot(color: string): string { // Shows the colour the tag ACTUALLY wears, derived from its name when nobody has
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default; // picked one — so this screen agrees with the chips everywhere else. The ring in the
// swatch grid below follows the same resolution, so opening the picker highlights
// what you can already see rather than nothing at all.
function labelDot(label: { name: string; color: string }): string {
return NOTE_SWATCH_CLASSES[resolveLabelColor(label)] ?? NOTE_SWATCH_CLASSES.default;
} }
function openColor(id: string) { function openColor(id: string) {
@@ -75,8 +85,8 @@ async function doMerge(sourceId: string, targetId: string) {
<button <button
type="button" type="button"
class="h-4 w-4 shrink-0 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-white/15" class="h-4 w-4 shrink-0 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-white/15"
:class="labelDot(lb.color)" :class="labelDot(lb)"
:title="`Color: ${NOTE_COLOR_LABELS[(lb.color as NoteColor)] ?? lb.color}`" :title="`Color: ${NOTE_COLOR_LABELS[resolveLabelColor(lb)]}`"
aria-label="Change label color" aria-label="Change label color"
@click="openColor(lb.id)" @click="openColor(lb.id)"
/> />
@@ -120,7 +130,7 @@ async function doMerge(sourceId: string, targetId: string) {
:title="NOTE_COLOR_LABELS[key]" :title="NOTE_COLOR_LABELS[key]"
:aria-label="NOTE_COLOR_LABELS[key]" :aria-label="NOTE_COLOR_LABELS[key]"
class="h-6 w-6 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand" class="h-6 w-6 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
:class="[NOTE_SWATCH_CLASSES[key], lb.color === key ? 'ring-2 ring-brand' : '']" :class="[NOTE_SWATCH_CLASSES[key], resolveLabelColor(lb) === key ? 'ring-2 ring-brand' : '']"
@click="pickColor(lb.id, key)" @click="pickColor(lb.id, key)"
/> />
</div> </div>
@@ -140,7 +150,7 @@ async function doMerge(sourceId: string, targetId: string) {
> >
<span <span
class="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10 dark:border-white/15" class="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10 dark:border-white/15"
:class="labelDot(t.color)" :class="labelDot(t)"
></span> ></span>
<span class="truncate">{{ t.name }}</span> <span class="truncate">{{ t.name }}</span>
</button> </button>
+10 -2
View File
@@ -1,10 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import type { InlineToken } from "../notes/markdown"; import type { InlineToken } from "../notes/markdown";
import { tagTextClasses } from "../notes/colors";
// Emphasis and code only. `[[wiki-links]]` were the one token type that needed a // Emphasis, code, and `#tags`. `[[wiki-links]]` were the one token type that needed a
// router, a store and a resolver behind it; they are gone (note 2897), and so is all // router, a store and a resolver behind it; they are gone (note 2897), and so is all
// of that. // of that.
defineProps<{ tokens: InlineToken[] }>(); //
// `tagColors` maps a lowercased tag name to the colour stored on that label. Threaded
// down from the card rather than looked up here, because this component renders text
// and has no idea which note the text belongs to — and a tag the operator recoloured
// must read the same here as it does on a chip.
defineProps<{ tokens: InlineToken[]; tagColors?: Record<string, string> }>();
</script> </script>
<!-- Rendered tightly (no whitespace between tokens) so a token's own leading/trailing <!-- Rendered tightly (no whitespace between tokens) so a token's own leading/trailing
@@ -17,6 +23,8 @@ defineProps<{ tokens: InlineToken[] }>();
v-else-if="t.type === 'code'" v-else-if="t.type === 'code'"
class="rounded bg-black/5 px-1 py-0.5 font-mono text-[0.85em] dark:bg-white/10" class="rounded bg-black/5 px-1 py-0.5 font-mono text-[0.85em] dark:bg-white/10"
>{{ t.value }}</code >{{ t.value }}</code
><span v-else-if="t.type === 'tag'" class="font-medium" :class="tagTextClasses(t.value, tagColors)"
>#{{ t.value }}</span
><template v-else>{{ t.value }}</template></template ><template v-else>{{ t.value }}</template></template
></template ></template
> >
+47 -8
View File
@@ -3,34 +3,73 @@ import { computed } from "vue";
import { parseMarkdown } from "../notes/markdown"; import { parseMarkdown } from "../notes/markdown";
import MarkdownInline from "./MarkdownInline.vue"; import MarkdownInline from "./MarkdownInline.vue";
const props = defineProps<{ text: string }>(); // `tagColors` is passed straight through to MarkdownInline — see there for why the
// card owns the lookup rather than the renderer.
const props = defineProps<{
text: string;
toggleable?: boolean;
tagColors?: Record<string, string>;
}>();
// Ticking a box rewrites a line of the note's body, which is a thing only the owner
// of that note can do — so this renders the checkbox and hands the intent up rather
// than reaching for the store itself. The card wires it; a read-only render does not
// pass `toggleable` and the boxes are inert.
const emit = defineEmits<{ toggle: [index: number, checked: boolean] }>();
const blocks = computed(() => parseMarkdown(props.text)); const blocks = computed(() => parseMarkdown(props.text));
</script> </script>
<template> <template>
<div class="space-y-1.5 break-words"> <div class="space-y-1.5 break-words">
<template v-for="(b, i) in blocks" :key="i"> <template v-for="(b, i) in blocks" :key="i">
<h3 v-if="b.type === 'h1'" class="text-base font-bold"><MarkdownInline :tokens="b.inline ?? []" /></h3> <h3 v-if="b.type === 'h1'" class="text-base font-bold">
<h4 v-else-if="b.type === 'h2'" class="text-sm font-bold"><MarkdownInline :tokens="b.inline ?? []" /></h4> <MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
<h5 v-else-if="b.type === 'h3'" class="text-sm font-semibold"><MarkdownInline :tokens="b.inline ?? []" /></h5> </h3>
<h4 v-else-if="b.type === 'h2'" class="text-sm font-bold">
<MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
</h4>
<h5 v-else-if="b.type === 'h3'" class="text-sm font-semibold">
<MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
</h5>
<blockquote <blockquote
v-else-if="b.type === 'quote'" v-else-if="b.type === 'quote'"
class="whitespace-pre-wrap border-l-2 border-neutral-300 pl-2 text-neutral-600 dark:border-neutral-600 dark:text-neutral-400" class="whitespace-pre-wrap border-l-2 border-neutral-300 pl-2 text-neutral-600 dark:border-neutral-600 dark:text-neutral-400"
> >
<MarkdownInline :tokens="b.inline ?? []" /> <MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
</blockquote> </blockquote>
<div v-else-if="b.type === 'task'" class="flex flex-col gap-1">
<div v-for="(it, j) in b.items ?? []" :key="j" class="flex items-start gap-2">
<!-- Not wrapped in a <label>: on a card the text is the note's own words and
clicking it opens the note, so only the box itself toggles. `.stop` for
the same reason — the card is a click target underneath. -->
<input
type="checkbox"
class="mt-0.5 h-4 w-4 shrink-0 accent-brand"
:checked="b.tasks?.[j]?.checked ?? false"
:disabled="!toggleable"
:aria-label="toggleable ? 'Toggle item' : undefined"
@click.stop
@change="emit('toggle', b.tasks?.[j]?.index ?? 0, ($event.target as HTMLInputElement).checked)"
/>
<span
class="min-w-0 flex-1"
:class="b.tasks?.[j]?.checked ? 'text-neutral-400 line-through' : ''"
>
<MarkdownInline :tokens="it" :tag-colors="tagColors" />
</span>
</div>
</div>
<ul v-else-if="b.type === 'ul'" class="list-disc space-y-0.5 pl-5"> <ul v-else-if="b.type === 'ul'" class="list-disc space-y-0.5 pl-5">
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li> <li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" :tag-colors="tagColors" /></li>
</ul> </ul>
<ol v-else-if="b.type === 'ol'" class="list-decimal space-y-0.5 pl-5"> <ol v-else-if="b.type === 'ol'" class="list-decimal space-y-0.5 pl-5">
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li> <li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" :tag-colors="tagColors" /></li>
</ol> </ol>
<pre <pre
v-else-if="b.type === 'pre'" v-else-if="b.type === 'pre'"
class="overflow-x-auto whitespace-pre-wrap rounded-md bg-black/5 p-2 font-mono text-xs dark:bg-white/10" class="overflow-x-auto whitespace-pre-wrap rounded-md bg-black/5 p-2 font-mono text-xs dark:bg-white/10"
>{{ b.value ?? "" }}</pre >{{ b.value ?? "" }}</pre
> >
<p v-else class="whitespace-pre-wrap"><MarkdownInline :tokens="b.inline ?? []" /></p> <p v-else class="whitespace-pre-wrap"><MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" /></p>
</template> </template>
</div> </div>
</template> </template>
+88 -88
View File
@@ -1,19 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import { useNotesStore } from "../stores/notes"; import { useNotesStore } from "../stores/notes";
import { import { NOTE_CARD_SURFACE, labelChipClasses } from "../notes/colors";
LABEL_CHIP_CLASSES,
NOTE_CARD_CLASSES,
NOTE_COLOR_KEYS,
NOTE_COLOR_LABELS,
NOTE_SWATCH_CLASSES,
type NoteColor,
} from "../notes/colors";
import type { Note } from "../stores/notes"; import type { Note } from "../stores/notes";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
import LinkPreview from "./LinkPreview.vue"; import LinkPreview from "./LinkPreview.vue";
import MarkdownText from "./MarkdownText.vue"; import MarkdownText from "./MarkdownText.vue";
import NoteChecklist from "./NoteChecklist.vue";
import { import {
cardIdAt, cardIdAt,
draggingId, draggingId,
@@ -94,6 +86,15 @@ const bodyPreview = computed(() => {
return lines.slice(0, PREVIEW_LINES).join("\n") + "\n…"; return lines.slice(0, PREVIEW_LINES).join("\n") + "\n…";
}); });
/** Tick a box without opening the note — the common gesture on a board.
*
* The index is the item's ordinal in the WHOLE body, which survives the preview
* clamp above because that only ever drops lines from the end. `updateItem` takes
* it as the item id, which is exactly what an id is now (M304). */
function toggleTask(index: number, checked: boolean) {
void notes.updateItem(props.note.id, String(index), { checked });
}
const root = ref<HTMLElement | null>(null); const root = ref<HTMLElement | null>(null);
// --- Drag-to-reorder. Pointer Events, gated behind an explicit grip handle so a // --- Drag-to-reorder. Pointer Events, gated behind an explicit grip handle so a
@@ -181,43 +182,31 @@ async function snoozeReminder(minutes: number): Promise<void> {
emit("reminder-changed"); emit("reminder-changed");
} }
function cardClass(color: NoteColor): string { // Only the tags the BODY is not already showing. `via_tag` means exactly "backed by
return NOTE_CARD_CLASSES[color] ?? NOTE_CARD_CLASSES.default; // text still in the note" since M311, so a chip for one printed the same tag twice —
} // once where it was typed, once in this row — and the loud copy was the duplicate. A
// tag left in prose is tinted where it sits instead (MarkdownInline). What survives
// here is what the body cannot say: a tag lifted off its own line, and a label added
// through the picker.
const chipLabels = computed(() => props.note.labels.filter((lb) => !lb.via_tag));
function labelChip(color: string): string { // The colour the operator stored for each of this note's tags, keyed by lowercased
return LABEL_CHIP_CLASSES[color as NoteColor] ?? LABEL_CHIP_CLASSES.default; // name — what MarkdownInline needs to tint a `#tag` the same as its chip would be.
} // Lowercased because tags dedupe case-insensitively, so `#Todo` and `#todo` are one.
const tagColors = computed<Record<string, string>>(() => {
// Per-card color popover (recolor without opening the editor). const map: Record<string, string> = {};
const colorOpen = ref(false); for (const lb of props.note.labels) map[lb.name.toLowerCase()] = lb.color;
return map;
function swatch(color: string): string {
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default;
}
function pickColor(color: NoteColor) {
colorOpen.value = false;
void notes.setColor(props.note.id, color);
}
function onDocMousedown(e: MouseEvent) {
if (colorOpen.value && root.value && !root.value.contains(e.target as Node)) colorOpen.value = false;
}
// Only listen for outside clicks while the popover is actually open.
watch(colorOpen, (open) => {
if (open) document.addEventListener("mousedown", onDocMousedown);
else document.removeEventListener("mousedown", onDocMousedown);
}); });
onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown));
</script> </script>
<template> <template>
<div <div
ref="root" ref="root"
class="group relative mb-4 break-inside-avoid rounded-xl border p-3 shadow-sm transition hover:shadow-md" class="group relative mb-4 break-inside-avoid rounded-xl border border-[#b8b8b8] p-3 shadow-sm transition hover:shadow-md dark:border-[#404040]"
:class="[ :class="[
cardClass(note.color), NOTE_CARD_SURFACE,
dragging ? 'opacity-40' : '', dragging ? 'opacity-40' : '',
dragOver dragOver
? 'scale-[1.02] shadow-lg ring-2 ring-brand ring-offset-2 ring-offset-white dark:ring-offset-neutral-950' ? 'scale-[1.02] shadow-lg ring-2 ring-brand ring-offset-2 ring-offset-white dark:ring-offset-neutral-950'
@@ -226,6 +215,61 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
]" ]"
:data-note-id="note.id" :data-note-id="note.id"
> >
<!-- THE EDGE IS THE CARD'S BOUNDARY, and since M315 it is the ONLY thing that
varies from the board: the fill is one neutral (NOTE_CARD_SURFACE) and no
longer says anything about the note. That makes this line load-bearing rather
than decorative — 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 was the
loudest element on the card (1.56-2.09 against its own fill, where the fill
managed 1.03-1.05 against the board) AND carried the same information the fill
did, so a board of them read as a grid of outlines. A neutral line carries no
information at all, which is exactly what lets it be structure. The fill is
the same argument one size up, made two milestones later.
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-looking way to do this
and was measured and rejected: a border composites over what is under it, so
`border-white/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 the
Android side must also write, and two surfaces stating the same hex is how
they stay the same card.
`shadow-sm`, back down from `shadow`: the border is the boundary again, so the
shadow is only depth. -->
<!-- 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 image and the body rather than beside them, 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. -->
<div v-if="chipLabels.length" class="mb-2 flex flex-wrap gap-1">
<!-- Every chip carries the `#`, not just the ones derived from body text. That
branch used to distinguish a `#tag` from a picker label; it cannot any more,
because a tag whose text is still in the body no longer reaches this row at
all. What is left is all the same thing to the eye and to the vocabulary —
and the hash is what keeps a lifted chip reading as the `#todo` somebody
typed. Android's row says the same, which it did not before. -->
<span
v-for="lb in chipLabels"
:key="lb.id"
class="rounded-full px-2 py-0.5 text-xs"
:class="labelChipClasses(lb)"
>#{{ lb.name }}</span
>
</div>
<img <img
v-if="firstImage" v-if="firstImage"
:src="firstImage.url" :src="firstImage.url"
@@ -263,7 +307,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
blank and the link is never unreachable. --> blank and the link is never unreachable. -->
<LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" /> <LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" />
<div v-else-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300"> <div v-else-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
<MarkdownText :text="bodyPreview" /> <MarkdownText :text="bodyPreview" :tag-colors="tagColors" toggleable @toggle="toggleTask" />
</div> </div>
<p <p
v-if="!note.body && !note.items.length && !note.attachments.length" v-if="!note.body && !note.items.length && !note.attachments.length"
@@ -279,23 +323,10 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
<LinkPreview v-for="p in note.previews" :key="p.id" :preview="p" compact /> <LinkPreview v-for="p in note.previews" :key="p.id" :preview="p" compact />
</div> </div>
<NoteChecklist <!-- No separate checklist block any more. A checklist is lines of the body (M304),
v-if="note.items.length" so MarkdownText above draws it in place — which is what lets a list sit between
:class="note.body ? 'mt-2' : ''" two paragraphs instead of always after them. Rendering both would have shown
:note-id="note.id" every list twice. -->
:items="note.items"
@click="emit('open', note)"
/>
<div v-if="note.labels.length" class="mt-2 flex flex-wrap gap-1">
<span
v-for="lb in note.labels"
:key="lb.id"
class="rounded-full px-2 py-0.5 text-xs"
:class="labelChip(lb.color)"
>{{ lb.via_tag ? "#" + lb.name : lb.name }}</span
>
</div>
<div v-if="note.remind_at" class="mt-2 flex flex-wrap items-center gap-1.5"> <div v-if="note.remind_at" class="mt-2 flex flex-wrap items-center gap-1.5">
<span <span
@@ -410,19 +441,6 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
</button> </button>
</template> </template>
<template v-else> <template v-else>
<button
type="button"
class="icon-btn"
title="Change color"
aria-label="Change color"
:aria-expanded="colorOpen"
@click.stop="colorOpen = !colorOpen"
>
<span
class="h-4 w-4 rounded-full border border-black/10 dark:border-white/20"
:class="swatch(note.color)"
></span>
</button>
<button <button
type="button" type="button"
class="icon-btn" class="icon-btn"
@@ -453,24 +471,6 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
<Icon name="trash" /> <Icon name="trash" />
</button> </button>
</template> </template>
<!-- Inside the action set rather than beside it, so it follows the set to
whichever corner or footer the device put it in. -->
<div
v-if="colorOpen"
class="note-swatches flex w-40 flex-wrap gap-1.5 rounded-lg border border-neutral-200 bg-white p-2 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
<button
v-for="key in NOTE_COLOR_KEYS"
:key="key"
type="button"
:title="NOTE_COLOR_LABELS[key]"
:aria-label="NOTE_COLOR_LABELS[key]"
class="h-6 w-6 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
:class="[NOTE_SWATCH_CLASSES[key], note.color === key ? 'ring-2 ring-brand' : '']"
@click.stop="pickColor(key)"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
-75
View File
@@ -1,75 +0,0 @@
<script setup lang="ts">
import { ref } from "vue";
import { useNotesStore, type ChecklistItem } from "../stores/notes";
const props = defineProps<{ noteId: string; items: ChecklistItem[]; editable?: boolean }>();
const notes = useNotesStore();
const newItem = ref("");
async function addItem() {
const text = newItem.value.trim();
if (!text) return;
await notes.addItem(props.noteId, text);
newItem.value = "";
}
function toggle(item: ChecklistItem) {
void notes.updateItem(props.noteId, item.id, { checked: !item.checked });
}
function editText(item: ChecklistItem, value: string) {
if (value !== item.text) void notes.updateItem(props.noteId, item.id, { text: value });
}
function remove(item: ChecklistItem) {
void notes.deleteItem(props.noteId, item.id);
}
</script>
<template>
<div class="flex flex-col gap-1">
<div v-for="item in items" :key="item.id" class="group/item flex items-center gap-2">
<input
type="checkbox"
class="h-4 w-4 shrink-0 accent-brand"
:checked="item.checked"
@change="toggle(item)"
@click.stop
/>
<input
v-if="editable"
:value="item.text"
class="min-w-0 flex-1 bg-transparent text-sm outline-none"
:class="item.checked ? 'text-neutral-400 line-through' : ''"
@change="editText(item, ($event.target as HTMLInputElement).value)"
/>
<span
v-else
class="min-w-0 flex-1 truncate text-sm"
:class="item.checked ? 'text-neutral-400 line-through' : 'text-neutral-700 dark:text-neutral-300'"
>{{ item.text }}</span
>
<button
v-if="editable"
type="button"
class="hover-reveal text-neutral-300 opacity-0 hover:text-neutral-600 group-hover/item:opacity-100 dark:hover:text-neutral-200"
aria-label="Delete item"
@click="remove(item)"
>
×
</button>
</div>
<form v-if="editable" class="mt-1 flex items-center gap-2" @submit.prevent="addItem">
<span class="h-4 w-4 shrink-0" />
<input
v-model="newItem"
type="text"
placeholder="+ List item"
class="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-neutral-400"
/>
</form>
<p v-if="!editable && items.length === 0" class="text-sm italic text-neutral-400">Empty checklist</p>
</div>
</template>
+200 -77
View File
@@ -1,16 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue"; import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useNotesStore } from "../stores/notes"; import { useNotesStore } from "../stores/notes";
import ColorPicker from "./ColorPicker.vue";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
import LabelPicker from "./LabelPicker.vue"; import LabelPicker from "./LabelPicker.vue";
import LinkPreview from "./LinkPreview.vue"; import LinkPreview from "./LinkPreview.vue";
import NoteChecklist from "./NoteChecklist.vue";
import { fromLocalInput, toLocalInput } from "../notes/datetime"; import { fromLocalInput, toLocalInput } from "../notes/datetime";
import { takeMorphOrigin } from "../composables/useEditorMorph"; import { takeMorphOrigin } from "../composables/useEditorMorph";
import { prefersReducedMotion } from "../composables/useReducedMotion"; import { prefersReducedMotion } from "../composables/useReducedMotion";
import type { Note, NoteLabel, NoteRevision } from "../stores/notes"; import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
import { LABEL_CHIP_CLASSES, type NoteColor } from "../notes/colors"; import { labelChipClasses } from "../notes/colors";
import {
afterEnter,
type EditorBlock,
joinBlocks,
plusTask,
promoteTasks,
splitBlocks,
withoutIndex,
} from "../notes/blocks";
// One modal editor for BOTH composing and editing — a single surface (the board's // One modal editor for BOTH composing and editing — a single surface (the board's
// "Take a note…" bar just opens this in compose mode). `note` = the note being edited, // "Take a note…" bar just opens this in compose mode). `note` = the note being edited,
@@ -25,24 +32,53 @@ const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void
const notes = useNotesStore(); const notes = useNotesStore();
const noteId = ref<string | null>(props.note?.id ?? null); const noteId = ref<string | null>(props.note?.id ?? null);
const body = ref(props.note?.body ?? props.initialBody); // BLOCKS rather than one string, because a checklist item is drawn as a real checkbox
const color = ref<NoteColor>(props.note?.color ?? "default"); // and a widget cannot live inside a <textarea>. The note is still one markdown body —
// see notes/blocks.ts — and `body` is what every save, baseline and draft still reads.
const blocks = ref<EditorBlock[]>(splitBlocks(props.note?.body ?? props.initialBody));
const body = computed(() => joinBlocks(blocks.value));
function setBody(text: string): void {
blocks.value = splitBlocks(text);
}
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []); const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
// Whether this editor is showing the checklist. A note HAS a checklist (M13 step 2) // Whether this editor is showing the checklist. A note HAS a checklist (M13 step 2)
// rather than BEING one, so this is a view flag, not a property of the note: it turns // rather than BEING one, so this is a view flag, not a property of the note: it turns
// on when the note already carries items, and when someone asks for one. // on when the note already carries items, and when someone asks for one.
const checklistOpen = ref(false);
const saving = ref(false); const saving = ref(false);
const root = ref<HTMLElement | null>(null); const root = ref<HTMLElement | null>(null);
const bodyInput = ref<HTMLTextAreaElement | null>(null); // One element per block, keyed by the block's id — the only thing about a block that
// survives one being inserted above it. Focus is asked for by id and honoured after
// the render that created the field, since an element that does not exist yet cannot
// take it.
const blockEls = new Map<number, HTMLTextAreaElement | HTMLInputElement>();
function setBlockEl(id: number, el: unknown): void {
if (el) blockEls.set(id, el as HTMLTextAreaElement);
else blockEls.delete(id);
}
async function focusBlock(id: number | null): Promise<void> {
if (id === null) return;
await nextTick();
const el = blockEls.get(id);
el?.focus();
if (el) el.selectionStart = el.selectionEnd = el.value.length;
}
/** A prose field sized to its text. `rows="1"` plus this beats guessing a row count,
* which is wrong the moment a line wraps. */
function grow(el: HTMLTextAreaElement): void {
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}
function growAll(): void {
for (const el of blockEls.values()) {
if (el instanceof HTMLTextAreaElement) grow(el);
}
}
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
const uploadError = ref(""); const uploadError = ref("");
// Baseline for edit-mode change detection (save only when text actually changed). // Baseline for edit-mode change detection (save only when text actually changed).
const baseline = ref<{ body: string; color: NoteColor }>({ const baseline = ref<{ body: string }>({ body: props.note?.body ?? "" });
body: props.note?.body ?? "",
color: (props.note?.color ?? "default") as NoteColor,
});
const isCreate = computed(() => noteId.value === null); const isCreate = computed(() => noteId.value === null);
const hasContent = computed(() => body.value.trim() !== ""); const hasContent = computed(() => body.value.trim() !== "");
@@ -55,7 +91,6 @@ const draftNote = computed<Note>(() => ({
id: "", id: "",
display_title: "", display_title: "",
body: body.value, body: body.value,
color: color.value,
position: 0, position: 0,
pinned: false, pinned: false,
archived: false, archived: false,
@@ -75,15 +110,6 @@ const liveNote = computed<Note>(() =>
? (notes.items.find((n) => n.id === noteId.value) ?? props.note ?? draftNote.value) ? (notes.items.find((n) => n.id === noteId.value) ?? props.note ?? draftNote.value)
: draftNote.value, : draftNote.value,
); );
// The checklist renders once the note has items, or once someone has asked for one.
// It sits BELOW the body rather than instead of it — a note can carry both, which is
// the whole point of the merge.
//
// Items need a persisted note to hang off, so this is a rich action like attaching a
// file: in compose it waits for the draft to be saved.
const showChecklist = computed(
() => !isCreate.value && (liveNote.value.items.length > 0 || checklistOpen.value),
);
const bodyPlaceholder = "Take a note…"; const bodyPlaceholder = "Take a note…";
// Keep local state in sync when the edited note changes (modal reused for another note). // Keep local state in sync when the edited note changes (modal reused for another note).
@@ -91,18 +117,17 @@ watch(
() => props.note, () => props.note,
(n) => { (n) => {
noteId.value = n?.id ?? null; noteId.value = n?.id ?? null;
body.value = n?.body ?? ""; setBody(n?.body ?? "");
color.value = (n?.color ?? "default") as NoteColor;
labelList.value = n ? [...n.labels] : []; labelList.value = n ? [...n.labels] : [];
baseline.value = { body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor }; baseline.value = { body: n?.body ?? "" };
}, },
); );
// ---- persistence ---- // ---- persistence ----
async function createFromFields(): Promise<void> { async function createFromFields(): Promise<void> {
const created = await notes.create({ body: body.value, color: color.value }); const created = await notes.create({ body: body.value });
noteId.value = created.id; noteId.value = created.id;
baseline.value = { body: created.body, color: created.color as NoteColor }; baseline.value = { body: created.body };
} }
// Ensure a persisted note exists (for rich actions mid-compose). Returns its id, or // Ensure a persisted note exists (for rich actions mid-compose). Returns its id, or
@@ -129,12 +154,12 @@ async function flush(): Promise<void> {
} }
const b = baseline.value; const b = baseline.value;
const nextBody = body.value; const nextBody = body.value;
const changed = nextBody !== b.body || color.value !== b.color; const changed = nextBody !== b.body;
if (!changed) return; if (!changed) return;
saving.value = true; saving.value = true;
try { try {
await notes.saveEdit(noteId.value as string, { body: nextBody, color: color.value }); await notes.saveEdit(noteId.value as string, { body: nextBody });
baseline.value = { body: nextBody, color: color.value }; baseline.value = { body: nextBody };
} finally { } finally {
saving.value = false; saving.value = false;
} }
@@ -142,11 +167,9 @@ async function flush(): Promise<void> {
function resetCompose(): void { function resetCompose(): void {
noteId.value = null; noteId.value = null;
body.value = ""; setBody("");
color.value = "default";
labelList.value = []; labelList.value = [];
checklistOpen.value = false; baseline.value = { body: "" };
baseline.value = { body: "", color: "default" };
uploadError.value = ""; uploadError.value = "";
} }
@@ -157,7 +180,9 @@ async function commitAndContinue(): Promise<void> {
await flush(); await flush();
resetCompose(); resetCompose();
await nextTick(); await nextTick();
bodyInput.value?.focus(); growAll();
const first = blocks.value[0];
await focusBlock(first ? first.id : null);
} }
// ---- open/close animation (M7) ---- // ---- open/close animation (M7) ----
// //
@@ -227,21 +252,85 @@ function onBackdropMousedown(): void {
onMounted(async () => { onMounted(async () => {
await nextTick(); await nextTick();
const el = bodyInput.value; growAll();
el?.focus(); // The LAST block, with the caret after its text: opening a note means continuing it,
// Put the caret after any seeded text (type-to-compose) so typing continues cleanly. // and type-to-compose seeds text that should be typed straight on from.
if (el) el.selectionStart = el.selectionEnd = el.value.length; const last = blocks.value[blocks.value.length - 1];
await focusBlock(last ? last.id : null);
}); });
function onBodyKeydown(e: KeyboardEvent) { /** Editing one block: replace its text, leave every other block alone. */
// Compose: Shift+Enter saves the note and starts a fresh one (rapid capture). function setText(index: number, text: string): void {
const out = [...blocks.value];
out[index] = { ...out[index], text };
blocks.value = out;
}
function setChecked(index: number, checked: boolean): void {
const out = [...blocks.value];
out[index] = { ...out[index], checked };
blocks.value = out;
}
function onProseInput(index: number, e: Event): void {
const el = e.target as HTMLTextAreaElement;
setText(index, el.value);
grow(el);
}
/**
* Leaving a prose block is when a `- [ ] ` typed by hand becomes a real item.
*
* See notes/blocks.ts for why blur is the only safe moment. The identity check is the
* contract `promoteTasks` offers: an untouched array back means nothing to promote, and
* reassigning the ref anyway would re-key every field below this one for no reason.
*
* `growAll` after the DOM settles, because the textarea being left is now shorter by
* however many lines became checkboxes and would otherwise keep its old height.
*/
function onProseBlur(index: number): void {
const promoted = promoteTasks(blocks.value, index);
if (promoted === blocks.value) return;
blocks.value = promoted;
void nextTick().then(growAll);
}
/** Compose: Shift+Enter saves the note and starts a fresh one (rapid capture). */
function onProseKeydown(e: KeyboardEvent): void {
if (isCreate.value && e.key === "Enter" && e.shiftKey) { if (isCreate.value && e.key === "Enter" && e.shiftKey) {
e.preventDefault(); e.preventDefault();
void commitAndContinue(); void commitAndContinue();
return;
} }
} }
/** Enter on an item makes the next one; on an EMPTY item it ends the list. */
function onTaskEnter(index: number): void {
const next = afterEnter(blocks.value, index);
blocks.value = next.blocks;
void focusBlock(next.focus);
}
/**
* Backspace at the very start of an EMPTY item removes it.
*
* Worth having on the web where Android's is not: a browser sends a real keydown for
* Backspace, while an Android soft keyboard sends an IME delete that never surfaces as
* one. Enter-on-empty ends a list on both surfaces; this is the extra way out that
* only one of them can offer.
*/
function onTaskBackspace(index: number, e: KeyboardEvent): void {
const el = e.target as HTMLInputElement;
if (el.value !== "" || el.selectionStart !== 0) return;
e.preventDefault();
removeBlock(index);
}
function removeBlock(index: number): void {
const next = withoutIndex(blocks.value, index);
blocks.value = next.blocks;
void focusBlock(next.focus);
}
// ---- reminder ---- // ---- reminder ----
const reminderLocal = computed(() => toLocalInput(liveNote.value.remind_at)); const reminderLocal = computed(() => toLocalInput(liveNote.value.remind_at));
async function onReminderChange(e: Event) { async function onReminderChange(e: Event) {
@@ -279,20 +368,19 @@ async function onLabelsChange(next: NoteLabel[]) {
async function removeLabel(id: string) { async function removeLabel(id: string) {
await onLabelsChange(labelList.value.filter((lb) => lb.id !== id)); await onLabelsChange(labelList.value.filter((lb) => lb.id !== id));
} }
function labelChip(c: string): string {
return LABEL_CHIP_CLASSES[c as NoteColor] ?? LABEL_CHIP_CLASSES.default;
}
// ---- add a checklist ---- // ---- add a checklist ----
// //
// Not a conversion any more. Nothing is moved, nothing is swapped: the note keeps its // Appends an empty item and puts the caret in it. Unlike every other toolbar button
// body and gains a place to put items. Persists the draft first for the same reason // this one needs NO persisted note to hang anything off — a checklist is part of the
// attaching a file does — an item needs a note to belong to. // body (M304), so it works on an empty compose box the moment it opens.
async function addChecklist() { //
if (checklistOpen.value) return; // Appends rather than inserting at the caret because a block editor has no single
const id = await ensureDraft(); // caret to insert at: the field that had focus may not be the one being looked at by
if (!id) return; // the time this runs.
checklistOpen.value = true; function addChecklist(): void {
const next = plusTask(blocks.value);
blocks.value = next.blocks;
void focusBlock(next.focus);
} }
// ---- attachments ---- // ---- attachments ----
@@ -371,9 +459,8 @@ async function restoreRevisionAt(revId: string) {
const id = noteId.value; const id = noteId.value;
if (!id) return; if (!id) return;
const updated = await notes.restoreRevision(id, revId); const updated = await notes.restoreRevision(id, revId);
body.value = updated.body; setBody(updated.body);
color.value = updated.color; baseline.value = { body: updated.body };
baseline.value = { body: updated.body, color: updated.color };
void loadRevisions(); // the pre-restore state became a new revision void loadRevisions(); // the pre-restore state became a new revision
} }
function revLabel(iso: string | null): string { function revLabel(iso: string | null): string {
@@ -474,31 +561,65 @@ function revPreview(rev: NoteRevision): string {
/> />
</div> </div>
<textarea <!-- The body, as fields and checkboxes rather than as markup. A checklist
ref="bodyInput" item is a real input; a run of prose is one textarea, so typing a
v-model="body" paragraph still feels like typing a paragraph. -->
rows="8" <div class="flex flex-col gap-1">
:placeholder="bodyPlaceholder" <template v-for="(block, i) in blocks" :key="block.id">
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400" <div v-if="block.checked !== null" class="group/item flex items-center gap-2">
@keydown="onBodyKeydown" <input
/> type="checkbox"
<!-- Below the body, not instead of it. --> class="h-4 w-4 shrink-0 accent-brand"
<NoteChecklist :checked="block.checked"
v-if="showChecklist" :aria-label="block.text || 'Checklist item'"
class="py-1" @change="setChecked(i, ($event.target as HTMLInputElement).checked)"
:note-id="liveNote.id" />
:items="liveNote.items" <input
editable :ref="(el) => setBlockEl(block.id, el)"
/> :value="block.text"
type="text"
class="min-w-0 flex-1 bg-transparent text-sm leading-relaxed outline-none"
:class="block.checked ? 'text-neutral-400 line-through' : ''"
@input="setText(i, ($event.target as HTMLInputElement).value)"
@keydown.enter.prevent="onTaskEnter(i)"
@keydown.backspace="onTaskBackspace(i, $event)"
/>
<button
type="button"
class="hover-reveal shrink-0 text-neutral-300 opacity-0 hover:text-neutral-600 focus:opacity-100 group-hover/item:opacity-100 dark:hover:text-neutral-200"
aria-label="Delete item"
@click="removeBlock(i)"
>
×
</button>
</div>
<textarea
v-else
:ref="(el) => setBlockEl(block.id, el)"
:value="block.text"
rows="1"
:placeholder="i === 0 ? bodyPlaceholder : ''"
class="w-full resize-none overflow-hidden bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
@input="onProseInput(i, $event)"
@keydown="onProseKeydown"
@blur="onProseBlur(i)"
/>
</template>
</div>
<!-- No checklist component. The items are lines of the textarea above, which
is what lets a list sit between two paragraphs (M304). -->
<div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1"> <div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1">
<span <span
v-for="lb in labelList" v-for="lb in labelList"
:key="lb.id" :key="lb.id"
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs" class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
:class="labelChip(lb.color)" :class="labelChipClasses(lb)"
> >
{{ lb.via_tag ? "#" + lb.name : lb.name }} <!-- `#` on every chip, matching the card. This row still lists the tags
the BODY owns too — it is the control surface, and `via_tag` is what
decides whether there is a cross to remove one with. -->
#{{ lb.name }}
<button <button
v-if="!lb.via_tag" v-if="!lb.via_tag"
type="button" type="button"
@@ -583,8 +704,10 @@ function revPreview(rev: NoteRevision): string {
</div> </div>
</div> </div>
<div class="flex items-center justify-between gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800"> <!-- `justify-end`, not `justify-between`: the colour picker sat on the left of
<ColorPicker v-model="color" /> this row until M315 and the row was balanced around it. With one child left,
`between` would push the actions to the far left of a full-width bar. -->
<div class="flex items-center justify-end gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
<div class="flex items-center gap-0.5"> <div class="flex items-center gap-0.5">
<button <button
v-if="richEnabled && !liveNote.trashed" v-if="richEnabled && !liveNote.trashed"
@@ -598,7 +721,7 @@ function revPreview(rev: NoteRevision): string {
</button> </button>
<input ref="fileInput" type="file" class="hidden" @change="onFileChange" /> <input ref="fileInput" type="file" class="hidden" @change="onFileChange" />
<button <button
v-if="richEnabled && !liveNote.trashed && !showChecklist" v-if="!liveNote.trashed"
type="button" type="button"
class="icon-btn" class="icon-btn"
title="Add a checklist" title="Add a checklist"
+144
View File
@@ -0,0 +1,144 @@
// The editor's shape for a note body: a list of blocks rather than one string.
//
// The note is still one markdown body 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, and for a body already in canonical form it
// returns exactly what `splitBlocks` was handed.
//
// Why blocks at all: a checklist item has to be a real `<input type="checkbox">`, and
// a widget cannot live inside a `<textarea>`. Only a `contenteditable` could hold one,
// and that is a different editor with a different set of problems.
//
// A run of prose lines is ONE block, not one per line. Typing a paragraph has to feel
// like typing a paragraph, and a separate field under every sentence would break the
// caret mid-sentence. Only a checklist item earns a block, because only a checklist
// item needs a widget.
//
// The mirror of android/.../ui/EditorBlock.kt, deliberately: the two editors should
// behave the same, and the cheapest way to keep them that way is for the shapes to
// read alike.
import { parseTaskLine, renderTaskLine } from "./markdown";
export interface EditorBlock {
/** Stable across edits, so Vue keeps a field's caret when a block is inserted above
* it. Content cannot serve as the key — two empty items are identical and neither
* is the other. */
id: number;
text: string;
/** null for prose; ticked-or-not for a checklist item. */
checked: boolean | null;
}
/** Split a body into blocks, numbering them from `firstId`. */
export function splitBlocks(body: string, firstId = 0): EditorBlock[] {
const out: EditorBlock[] = [];
const prose: string[] = [];
let id = firstId;
const flushProse = () => {
if (prose.length) {
out.push({ id: id++, text: prose.join("\n"), checked: null });
prose.length = 0;
}
};
for (const line of (body ?? "").split("\n")) {
const task = parseTaskLine(line);
if (task) {
flushProse();
out.push({ id: id++, text: task.text, checked: task.checked });
} else {
prose.push(line);
}
}
flushProse();
// Never empty: an empty note still needs one field to type into.
return out.length ? out : [{ id, text: "", checked: null }];
}
/** The body those blocks stand for. */
export function joinBlocks(blocks: EditorBlock[]): string {
return blocks.map((b) => (b.checked === null ? b.text : renderTaskLine(b.text, b.checked))).join("\n");
}
/** An id nothing else is using. Monotonic within a session, which is all it has to be. */
export function nextId(blocks: EditorBlock[]): number {
return blocks.reduce((max, b) => Math.max(max, b.id), -1) + 1;
}
/**
* What Enter does on a checklist item, and which block should hold the caret after.
*
* On an item 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 only way to get a paragraph after
* one. Without that half a list is impossible to get out of.
*
* Appends rather than splitting at the caret: splitting an item in two is a rarity,
* and the caret is at the end for every ordinary use of that key.
*/
export function afterEnter(blocks: EditorBlock[], index: number): { blocks: EditorBlock[]; focus: number } {
const block = blocks[index];
const out = [...blocks];
if (!block.text.trim()) {
out[index] = { ...block, text: "", checked: null };
return { blocks: out, focus: block.id };
}
const id = nextId(blocks);
out.splice(index + 1, 0, { id, text: "", checked: false });
return { blocks: out, focus: id };
}
/**
* Drop a block, leaving at least one field to type into.
*
* Focus goes to the block above — or, when the first one was removed, to whichever
* takes its place. `index - 1` alone is -1 there, which would leave nothing focused.
*/
export function withoutIndex(blocks: EditorBlock[], index: number): { blocks: EditorBlock[]; focus: number | null } {
const kept = blocks.filter((_, i) => i !== index);
const fallback: EditorBlock[] = [{ id: nextId(blocks), text: "", checked: null }];
const remaining = kept.length ? kept : fallback;
return { blocks: remaining, focus: remaining[Math.max(0, index - 1)]?.id ?? null };
}
/** One more empty checklist item at the end, and the id to put the caret in. */
export function plusTask(blocks: EditorBlock[]): { blocks: EditorBlock[]; focus: number } {
const id = nextId(blocks);
return { blocks: [...blocks, { id, text: "", checked: false }], focus: 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 THE SAME ARRAY, not an equal copy, when there was nothing to promote — the
* caller leans on that to leave the ref 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.
*/
export function promoteTasks(blocks: EditorBlock[], index: number): EditorBlock[] {
const block = blocks[index];
if (!block || block.checked !== null) return blocks;
const split = splitBlocks(block.text, nextId(blocks));
// A single prose block back means there was nothing to promote. `splitBlocks` never
// returns an empty array, so `split[0]` is safe.
const changed = split.length > 1 || split[0].checked !== null;
return changed ? [...blocks.slice(0, index), ...split, ...blocks.slice(index + 1)] : blocks;
}
+252 -37
View File
@@ -17,18 +17,10 @@ export const NOTE_COLOR_KEYS = [
export type NoteColor = (typeof NOTE_COLOR_KEYS)[number]; export type NoteColor = (typeof NOTE_COLOR_KEYS)[number];
export const NOTE_CARD_CLASSES: Record<NoteColor, string> = { /** Membership test for a colour key arriving from the server, which may be newer
default: "bg-white border-neutral-200 dark:bg-neutral-900 dark:border-neutral-700", * than this client. Once a lookup in a card-fill table; since M315 there is no such
red: "bg-red-50 border-red-200 dark:bg-red-950/40 dark:border-red-900", * table, and this guards a LABEL's stored colour on its way into the palette. */
orange: "bg-orange-50 border-orange-200 dark:bg-orange-950/40 dark:border-orange-900", const KNOWN_COLORS = new Set<string>(NOTE_COLOR_KEYS);
yellow: "bg-amber-50 border-amber-200 dark:bg-amber-950/40 dark:border-amber-900",
green: "bg-green-50 border-green-200 dark:bg-green-950/40 dark:border-green-900",
teal: "bg-teal-50 border-teal-200 dark:bg-teal-950/40 dark:border-teal-900",
blue: "bg-blue-50 border-blue-200 dark:bg-blue-950/40 dark:border-blue-900",
purple: "bg-purple-50 border-purple-200 dark:bg-purple-950/40 dark:border-purple-900",
pink: "bg-pink-50 border-pink-200 dark:bg-pink-950/40 dark:border-pink-900",
gray: "bg-neutral-100 border-neutral-300 dark:bg-neutral-800 dark:border-neutral-700",
};
export const NOTE_SWATCH_CLASSES: Record<NoteColor, string> = { export const NOTE_SWATCH_CLASSES: Record<NoteColor, string> = {
default: "bg-white dark:bg-neutral-600", default: "bg-white dark:bg-neutral-600",
@@ -43,35 +35,108 @@ export const NOTE_SWATCH_CLASSES: Record<NoteColor, string> = {
gray: "bg-neutral-400 dark:bg-neutral-500", gray: "bg-neutral-400 dark:bg-neutral-500",
}; };
// Label chip tints (bg + readable text), keyed by the same color vocabulary. // A chip's SHELL: its fill and its hairline edge, keyed by the colour vocabulary. The
export const LABEL_CHIP_CLASSES: Record<NoteColor, string> = { // INK is not here — see TAG_TEXT_CLASSES, which one table now serves both a chip's text
default: "bg-black/5 text-neutral-600 dark:bg-white/10 dark:text-neutral-300", // and a `#tag` left in the prose. Compose the two with `labelChipClasses`.
red: "bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300", //
orange: "bg-orange-100 text-orange-700 dark:bg-orange-950/50 dark:text-orange-300", // THE RING IS NOT DECORATION. A chip's fill measures 1.02-1.26 against the card in
yellow: "bg-amber-100 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300", // light and 1.02-1.73 in dark — that is to say, very nearly nothing. The pill's shape
green: "bg-green-100 text-green-700 dark:bg-green-950/50 dark:text-green-300", // is the edge; the fill only tints it. (Dark red is the extreme at 1.02, which is
teal: "bg-teal-100 text-teal-700 dark:bg-teal-950/50 dark:text-teal-300", // invisible: without the ring that chip would be loose text.) An edge holds the shape
blue: "bg-blue-100 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300", // against any background, where shifting the fill only moves which card it collides
purple: "bg-purple-100 text-purple-700 dark:bg-purple-950/50 dark:text-purple-300", // with.
pink: "bg-pink-100 text-pink-700 dark:bg-pink-950/50 dark:text-pink-300", //
gray: "bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-200", // AT 65%, NOT 60%, AND SOLVED FOR RATHER THAN GUESSED. 0.60 was chosen against a worst
// case that no longer exists — a chip sitting on a card of its own colour, back when a
// note took its first tag's fill. Against the one card surface (M315) the ring is the
// ink at alpha over a known fill, so the alpha that clears the 3:1 of WCAG 1.4.11 for
// all ten hues can simply be solved: 0.60 gives 2.75-3.82 in light and misses for six
// of them, 0.65 gives 3.03-4.36 and misses for none. Dark is 4.52-5.76. 0.80 was the
// number the old comment named as the fallback and it is more than is needed — it
// draws a hard outline where a hairline does the job.
//
// `default`'s ring was `black/10 dark:white/15` here and the ink at alpha on the phone —
// 1.36 against its own fill in light against Compose's 3.21, so the two surfaces were
// drawing visibly different pills for the same chip. It is the ink at 65% on both now,
// like every other key: one rule for ten hues, not nine and an exception.
//
// Mirrored in NoteTint.kt as `chipBackground` and `chipBorder` / CHIP_EDGE_ALPHA.
export const LABEL_CHIP_SHELL: Record<NoteColor, string> = {
default: "bg-black/5 dark:bg-white/10 ring-1 ring-inset ring-neutral-700/65 dark:ring-neutral-300/65",
red: "bg-red-100 dark:bg-red-950/50 ring-1 ring-inset ring-red-800/65 dark:ring-red-300/65",
orange: "bg-orange-100 dark:bg-orange-950/50 ring-1 ring-inset ring-orange-800/65 dark:ring-orange-300/65",
yellow: "bg-amber-100 dark:bg-amber-950/50 ring-1 ring-inset ring-amber-800/65 dark:ring-amber-300/65",
green: "bg-green-100 dark:bg-green-950/50 ring-1 ring-inset ring-green-800/65 dark:ring-green-300/65",
teal: "bg-teal-100 dark:bg-teal-950/50 ring-1 ring-inset ring-teal-800/65 dark:ring-teal-300/65",
blue: "bg-blue-100 dark:bg-blue-950/50 ring-1 ring-inset ring-blue-800/65 dark:ring-blue-300/65",
purple: "bg-purple-100 dark:bg-purple-950/50 ring-1 ring-inset ring-purple-800/65 dark:ring-purple-300/65",
pink: "bg-pink-100 dark:bg-pink-950/50 ring-1 ring-inset ring-pink-800/65 dark:ring-pink-300/65",
gray: "bg-neutral-200 dark:bg-neutral-700 ring-1 ring-inset ring-neutral-800/65 dark:ring-neutral-200/65",
}; };
// Solid fills for graph nodes (SVG needs concrete colors, not Tailwind bg classes). // THE INK A TAG IS DRAWN IN — one table, for a `#tag` left in the prose AND for a
// Mid-tone hues read on both the light and dark graph background. // chip's text. It was two, and the split was real while it lasted: a chip carried its
export const NOTE_NODE_FILL: Record<NoteColor, string> = { // own `-100` fill and could afford `-700`, while inline text sat on whatever the card
default: "#9ca3af", // was, which included a gray-tagged card at `neutral-200` where `-700` measured 3.98
red: "#ef4444", // (green), 4.11 (orange) and 4.34 (teal) — all under the 4.5 body text needs. One step
orange: "#f97316", // deeper cleared every fill at once, so inline got `-800` and the chip kept `-700`.
yellow: "#f59e0b", //
green: "#22c55e", // M315 removed the twenty card fills the split was solving for, and this is the payoff:
teal: "#14b8a6", // against ONE card surface both jobs can take the same value. `-800`/`-300` is the one
blue: "#3b82f6", // they take, and the direction is deliberate — the inline token is the common case
purple: "#a855f7", // (since M311 a tag whose text is in the body is drawn where it was typed and NOT
pink: "#ec4899", // repeated as a chip), so collapsing onto the inline column leaves what is seen most
gray: "#6b7280", // exactly as it was, and moves only the chip. The chip is strictly better for it:
//
// inline, on the card as a chip, on its own fill
// light `-800` 7.09 - 15.13 6.37 - 12.01 (was 4.52 - 8.23)
// dark `-300` 9.45 - 14.23 8.23 - 11.88 (unchanged)
//
// Dark needed no decision at all: the two tables were already the same value for all
// ten hues, which is on its own most of the argument that one table was always enough.
//
// Mirrored in NoteTint.kt as `lightTagInk` / `darkTagInk`.
export const TAG_TEXT_CLASSES: Record<NoteColor, string> = {
default: "text-neutral-700 dark:text-neutral-300",
red: "text-red-800 dark:text-red-300",
orange: "text-orange-800 dark:text-orange-300",
yellow: "text-amber-800 dark:text-amber-300",
green: "text-green-800 dark:text-green-300",
teal: "text-teal-800 dark:text-teal-300",
blue: "text-blue-800 dark:text-blue-300",
purple: "text-purple-800 dark:text-purple-300",
pink: "text-pink-800 dark:text-pink-300",
gray: "text-neutral-800 dark:text-neutral-200",
}; };
/**
* The classes for one `#tag` in a note's own words.
*
* `picked` maps a lowercased tag name to the colour stored on that label, so a tag the
* operator has recoloured reads the same inline as it does on a chip. A tag the note
* does not carry as a label yet — just typed, not yet derived — is not in the map, and
* `resolveLabelColor` derives one from the name exactly as the chip would have.
*/
export function tagTextClasses(name: string, picked?: Record<string, string>): string {
return TAG_TEXT_CLASSES[resolveLabelColor({ name, color: picked?.[name.toLowerCase()] })];
}
/**
* The whole class list for a label CHIP: the shell and the ink, composed.
*
* One function rather than the composition written out at each call site, because the
* board's chip and the editor's chip have to be the same pill — the same tag changing
* colour on opening a note would be worse than both being grey. That was a comment
* asking two files to stay in step; it is one call now.
*
* Takes the LABEL, not its colour: a tag nobody has coloured derives one from its name,
* so chips carry the tag's identity rather than all being the same grey.
*/
export function labelChipClasses(label: { name: string; color?: string | null }): string {
const color = resolveLabelColor(label);
return `${LABEL_CHIP_SHELL[color]} ${TAG_TEXT_CLASSES[color]}`;
}
export const NOTE_COLOR_LABELS: Record<NoteColor, string> = { export const NOTE_COLOR_LABELS: Record<NoteColor, string> = {
default: "Default", default: "Default",
red: "Red", red: "Red",
@@ -84,3 +149,153 @@ export const NOTE_COLOR_LABELS: Record<NoteColor, string> = {
pink: "Pink", pink: "Pink",
gray: "Gray", gray: "Gray",
}; };
// ---------------------------------------------------------------------------
// Derived colour — the hue 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. `android/.../ui/DerivedTint.kt` 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.
//
// The Kotlin side has a unit test pinning the fixture below. THIS SIDE HAS NO
// MECHANICAL GUARD — the frontend has no test runner, only `vue-tsc --noEmit`.
// If you change anything here, check it against the fixture by hand.
export const DERIVED_TINT_KEYS: readonly NoteColor[] = NOTE_COLOR_KEYS.filter(
(key) => key !== "default",
);
/**
* 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.
*
* `& 0xff` is a no-op for the ASCII of a UUID, and is kept because it states the
* intent — this hashes BYTES, so the Kotlin side reading `id[i].code and 0xFF`
* is the same function rather than a coincidence.
*/
export function tintHash(id: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < id.length; i++) {
hash ^= id.charCodeAt(i) & 0xff;
// Math.imul, not `*`: JS numbers are doubles and a 32-bit overflow would be
// silently kept as precision instead of wrapping the way Kotlin's Int does.
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash >>> 0;
}
/** 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. */
export function derivedTint(id: string): NoteColor {
return DERIVED_TINT_KEYS[tintHash(id) % DERIVED_TINT_KEYS.length];
}
/**
* The colour to paint a LABEL — its chip, and its `#tag` where it sits in the prose.
*
* Derived from the tag's NAME, not stored, when nobody has picked one. Every `#tag`
* ever typed is `default`: `notes/tags.py` mints one as `Label(owner_id=…, name=name)`
* with no colour, so it takes the column default. Without deriving, a board of tags
* would be a board of identical grey chips.
*
* DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing the original plan in #2965.
* 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", because most tags are born from typing `#grocery`, not from a
* management screen. Deriving has no mint points to miss, needs no backfill for the
* tags that already exist, and reuses a hash that is already written twice. The cost is
* that renaming a tag recolours it, which is defensible: the name IS the tag.
*
* An explicitly-picked colour is still stored and still wins, so tag colours stay
* editable exactly as asked.
*
* Lowercased because tags dedupe case-insensitively — `#Todo` renamed to `#todo` is
* the same tag and should not change colour. Both `toLowerCase` here and Kotlin's
* `lowercase()` are locale-independent, so the mirror holds.
*/
export function resolveLabelColor(label: { name: string; color?: string | null }): NoteColor {
const picked = label.color as NoteColor | undefined | null;
if (picked && picked !== "default" && KNOWN_COLORS.has(picked)) return picked;
if (!label.name) return "default";
return derivedTint(label.name.toLowerCase());
}
// Fixture — the same names and expected keys the Kotlin test asserts. Kept here as
// prose because there is nowhere on this side to assert it. If you change the hash or
// the key order, these must still hold on BOTH surfaces.
//
// The raw hash, over four UUIDs — ids no longer pick a colour, but they are what the
// hash itself is pinned by and the Kotlin test still asserts them:
//
// 00000000-0000-0000-0000-000000000000 0xbe478ed1 purple
// 11111111-1111-1111-1111-111111111111 0x3d75cc01 blue
// 6ba7b810-9dad-11d1-80b4-00c04fd430c8 0xf108e530 orange
// f47ac10b-58cc-4372-a567-0e02b2c3d479 0x5b651540 orange
//
// And the live path — a label, hashing its lowercased NAME:
//
// todo -> pink grocery -> blue work -> green home -> gray
// ideas -> green reading -> gray urgent -> red
//
// Note `work`/`ideas` and `home`/`reading` collide. Nine keys makes that unavoidable
// and it is not a bug: colour hints that two tags are distinct, it never claims two
// chips of one colour are the same tag. The chip's text is what says which tag it is.
// ---------------------------------------------------------------------------
// THE CARD SURFACE — one neutral, no hue, no note involved (M315).
//
// This used to be a function of the note: a palette class for a tagged one, a fill
// generated from the id for the rest. Both are gone. The operator's verdict after
// four passes at it — "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 underneath 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 NOW LIVES ONLY ON THE TAG — the chip and the inline `#tag`, both of which sit
// on this one known background from here on. That is a smaller job done properly
// instead of a larger one done four times.
//
// The codebase had already made this argument about the card's EDGE, one level down:
// the hue-coded border came out as "a neutral line carries no information at all,
// which is exactly what lets it be structure instead of content". The fill is the same
// argument at the next size up.
//
// THE VALUES. `bg-white dark:bg-neutral-900` — which is exactly what `default` always
// was, and exactly what the note EDITOR panel has always been (NoteEditor.vue), so
// this is a collapse onto a surface both other surfaces already used rather than a
// new colour anybody has to like.
//
// Measured against the operator's constraint, "not the same color as their background
// but close to it":
//
// 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
//
// The fill is deliberately the WEAKEST of those numbers. 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.
export const NOTE_CARD_SURFACE = "bg-white dark:bg-neutral-900";
-4
View File
@@ -15,8 +15,6 @@ export function facetsFromQuery(q: LocationQuery): NoteFacets {
const f: NoteFacets = {}; const f: NoteFacets = {};
const text = one(q.q); const text = one(q.q);
if (text) f.q = text; if (text) f.q = text;
const color = one(q.color);
if (color) f.color = color;
if (labels.length) f.label = labels; if (labels.length) f.label = labels;
if (one(q.has_reminder) === "true") f.has_reminder = true; if (one(q.has_reminder) === "true") f.has_reminder = true;
if (one(q.has_attachment) === "true") f.has_attachment = true; if (one(q.has_attachment) === "true") f.has_attachment = true;
@@ -30,7 +28,6 @@ export function facetsFromQuery(q: LocationQuery): NoteFacets {
export function facetsToQuery(f: NoteFacets): LocationQueryRaw { export function facetsToQuery(f: NoteFacets): LocationQueryRaw {
const q: LocationQueryRaw = {}; const q: LocationQueryRaw = {};
if (f.q) q.q = f.q; if (f.q) q.q = f.q;
if (f.color) q.color = f.color;
if (f.label?.length) q.label = f.label; if (f.label?.length) q.label = f.label;
if (f.has_reminder) q.has_reminder = "true"; if (f.has_reminder) q.has_reminder = "true";
if (f.has_attachment) q.has_attachment = "true"; if (f.has_attachment) q.has_attachment = "true";
@@ -43,7 +40,6 @@ export function facetsToQuery(f: NoteFacets): LocationQueryRaw {
export function facetCount(f: NoteFacets): number { export function facetCount(f: NoteFacets): number {
let n = 0; let n = 0;
if (f.q) n++; if (f.q) n++;
if (f.color) n++;
n += f.label?.length ?? 0; n += f.label?.length ?? 0;
if (f.has_reminder) n++; if (f.has_reminder) n++;
if (f.has_attachment) n++; if (f.has_attachment) n++;
+86 -3
View File
@@ -7,16 +7,30 @@
// a heading. // a heading.
export interface InlineToken { export interface InlineToken {
type: "text" | "bold" | "italic" | "code"; /** `tag` carries the NAME, without the leading `#` — it is both what gets looked up
* for a colour and what is rendered, so the renderer puts the `#` back. */
type: "text" | "bold" | "italic" | "code" | "tag";
value: string; value: string;
} }
// Flat (non-discriminated) shape on purpose — keeps template type-checking simple. // Flat (non-discriminated) shape on purpose — keeps template type-checking simple.
export interface Block { export interface Block {
type: "p" | "h1" | "h2" | "h3" | "quote" | "ul" | "ol" | "pre"; type: "p" | "h1" | "h2" | "h3" | "quote" | "ul" | "ol" | "pre" | "task";
inline?: InlineToken[]; inline?: InlineToken[];
items?: InlineToken[][]; items?: InlineToken[][];
value?: string; value?: string;
/** `task` only: one entry per `items` entry, parallel by position. */
tasks?: TaskMeta[];
}
export interface TaskMeta {
/** This item's ordinal among ALL task lines in the body, in document order.
* That is the id the server and the native clients address an item by, so a
* checkbox can be toggled straight from it. Counted across blocks, not within
* one, and unaffected by the card truncating the body — the card only ever
* drops lines from the END. */
index: number;
checked: boolean;
} }
// Order matters: code is matched before emphasis so its contents aren't re-parsed; // Order matters: code is matched before emphasis so its contents aren't re-parsed;
@@ -25,7 +39,22 @@ export interface Block {
// `[[wiki-links]]` used to lead this alternation. They are gone (note 2897) — this is // `[[wiki-links]]` used to lead this alternation. They are gone (note 2897) — this is
// a capture-and-recall surface, and a linking system is organization. `[[text]]` now // a capture-and-recall surface, and a linking system is organization. `[[text]]` now
// renders as the literal characters someone typed, which is what it always was. // renders as the literal characters someone typed, which is what it always was.
const INLINE_RE = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)/g; // `#tag` is LAST in the alternation and that is load-bearing twice over. JS tries
// alternatives left to right, so a `#tag` inside backticks is claimed by `code` first
// and stays literal — matching the core, where a fenced block's contents are code.
// And a tag is the one token here that is not delimiter-based, so it must not get a
// chance to start inside `**bold #x**`.
//
// The grammar MIRRORS `line_tags` in core/src/local/derive.rs, which is the definition:
// a `#` at a word boundary (the preceding character is neither a tag character nor
// another `#`, so `a#b` and `##x` are not tags), a letter immediately after it, then
// alphanumerics, `_` and `-`. Rust's `is_alphanumeric` is `Alphabetic | N`, hence the
// property escapes rather than `\w` — and hence the `u` flag.
//
// A heading cannot collide with this: `parseMarkdown` requires a space after the `#`s,
// which `#tag` by definition does not have.
const INLINE_RE =
/(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)|((?<![\p{Alphabetic}\p{N}_#-])#\p{Alphabetic}[\p{Alphabetic}\p{N}_-]*)/gu;
export function parseInline(text: string): InlineToken[] { export function parseInline(text: string): InlineToken[] {
const tokens: InlineToken[] = []; const tokens: InlineToken[] = [];
@@ -37,6 +66,7 @@ export function parseInline(text: string): InlineToken[] {
const raw = m[0]; const raw = m[0];
if (m[1]) tokens.push({ type: "code", value: raw.slice(1, -1) }); if (m[1]) tokens.push({ type: "code", value: raw.slice(1, -1) });
else if (m[2]) tokens.push({ type: "bold", value: raw.slice(2, -2) }); else if (m[2]) tokens.push({ type: "bold", value: raw.slice(2, -2) });
else if (m[5]) tokens.push({ type: "tag", value: raw.slice(1) });
else tokens.push({ type: "italic", value: raw.slice(1, -1) }); else tokens.push({ type: "italic", value: raw.slice(1, -1) });
last = m.index + raw.length; last = m.index + raw.length;
} }
@@ -44,11 +74,45 @@ export function parseInline(text: string): InlineToken[] {
return tokens; return tokens;
} }
// A checklist item: the third implementation of one grammar, alongside
// core/src/local/derive.rs and src/thoughtsync/notes/checklist.py. A difference
// between any two of them is a checklist that changes shape when it syncs (M304).
//
// `-` and `*` only, deliberately, even though the `ul` matcher below also takes `+`.
// The other two implementations do not take `+`, and one grammar in three places has
// to be one grammar; a `+ [ ] x` line renders as an ordinary bullet everywhere,
// which is at least consistent.
const TASK_RE = /^\s*[-*] +\[([ xX])\](?: +(.*))?$/;
export interface TaskLine {
checked: boolean;
text: string;
}
/** One task line's parts, or null when the line is prose.
*
* Exported so the EDITOR's block split (notes/blocks.ts) and this read-view parser
* agree by construction rather than by comment. One grammar, one matcher. */
export function parseTaskLine(line: string): TaskLine | null {
const m = TASK_RE.exec(line);
return m ? { checked: m[1] !== " ", text: m[2] ?? "" } : null;
}
/** One item as the body line that stores it, in canonical form — `- [x] `, lowercase,
* and no trailing space when the item is empty so a round trip does not grow it. */
export function renderTaskLine(text: string, checked: boolean): string {
const mark = checked ? "x" : " ";
return text ? `- [${mark}] ${text}` : `- [${mark}]`;
}
export function parseMarkdown(text: string): Block[] { export function parseMarkdown(text: string): Block[] {
const lines = (text ?? "").split("\n"); const lines = (text ?? "").split("\n");
const blocks: Block[] = []; const blocks: Block[] = [];
let paragraph: string[] = []; let paragraph: string[] = [];
let i = 0; let i = 0;
// Runs across the whole document, not per block, because that is what the item's
// id means everywhere else.
let taskIndex = 0;
const flushPara = () => { const flushPara = () => {
if (paragraph.length) { if (paragraph.length) {
@@ -96,6 +160,25 @@ export function parseMarkdown(text: string): Block[] {
continue; continue;
} }
// Task list, BEFORE the plain bullet below — which would otherwise swallow
// `- [ ] x` as an ordinary list item and leave the brackets showing. Same
// ordering reason as `code` being matched before emphasis in INLINE_RE.
if (parseTaskLine(line)) {
flushPara();
const items: InlineToken[][] = [];
const tasks: TaskMeta[] = [];
while (i < lines.length) {
const task = parseTaskLine(lines[i]);
if (!task) break;
items.push(parseInline(task.text));
tasks.push({ index: taskIndex, checked: task.checked });
taskIndex++;
i++;
}
blocks.push({ type: "task", items, tasks });
continue;
}
// Unordered list: -, *, or + then a space. // Unordered list: -, *, or + then a space.
if (/^\s*[-*+]\s+/.test(line)) { if (/^\s*[-*+]\s+/.test(line)) {
flushPara(); flushPara();
+10 -16
View File
@@ -2,14 +2,12 @@ import { defineStore } from "pinia";
import { ref } from "vue"; import { ref } from "vue";
import { repo } from "../adapters"; import { repo } from "../adapters";
import { useUiStore } from "./ui"; import { useUiStore } from "./ui";
import type { NoteColor } from "../notes/colors";
export type NoteView = "active" | "archived" | "trash"; export type NoteView = "active" | "archived" | "trash";
// Combinable facet filters for the board (mirrors the GET /api/notes query + a saved // Combinable facet filters for the board (mirrors the GET /api/notes query + a saved
// view's stored params). All optional; empty = the plain, unfiltered board. // view's stored params). All optional; empty = the plain, unfiltered board.
export interface NoteFacets { export interface NoteFacets {
q?: string; q?: string;
color?: string;
label?: string[]; label?: string[];
has_reminder?: boolean; has_reminder?: boolean;
has_attachment?: boolean; has_attachment?: boolean;
@@ -21,8 +19,13 @@ export interface NoteLabel {
id: string; id: string;
name: string; name: string;
color: string; color: string;
// True when this label is attached because of a #tag in the note body (kept in // True when the label is backed by text STILL IN THE BODY — a `#tag` written
// sync with the text); false = added manually via the picker. // mid-sentence, kept in sync with those words. False covers both a label added
// through the picker and a tag lifted off a line of its own (M311), which is why
// it is also what decides whether a chip can be removed with a cross.
//
// The card reads it the other way round: a true here means the body is already
// showing this tag, so the chip would be the second copy and is not drawn.
via_tag: boolean; via_tag: boolean;
} }
@@ -65,7 +68,6 @@ export interface Note {
// (server-derived). Every note has one, so every note has something to be called. // (server-derived). Every note has one, so every note has something to be called.
display_title: string; display_title: string;
body: string; body: string;
color: NoteColor;
position: number; position: number;
pinned: boolean; pinned: boolean;
archived: boolean; archived: boolean;
@@ -131,11 +133,7 @@ export const useNotesStore = defineStore("notes", () => {
} }
} }
async function create(input: { async function create(input: { body: string; items?: string[] }): Promise<Note> {
body: string;
color: NoteColor;
items?: string[];
}): Promise<Note> {
const note = await repo.notes.create(input); const note = await repo.notes.create(input);
reconcile(note); reconcile(note);
return note; return note;
@@ -143,9 +141,7 @@ export const useNotesStore = defineStore("notes", () => {
async function mutate( async function mutate(
id: string, id: string,
changes: Partial< changes: Partial<Pick<Note, "body" | "pinned" | "archived" | "remind_at" | "recurrence">>,
Pick<Note, "body" | "color" | "pinned" | "archived" | "remind_at" | "recurrence">
>,
): Promise<void> { ): Promise<void> {
reconcile(await repo.notes.update(id, changes)); reconcile(await repo.notes.update(id, changes));
} }
@@ -156,10 +152,9 @@ export const useNotesStore = defineStore("notes", () => {
if (archived) if (archived)
useUiStore().showToast("Note archived", { label: "Undo", run: () => void setArchived(id, false) }); useUiStore().showToast("Note archived", { label: "Undo", run: () => void setArchived(id, false) });
}; };
const setColor = (id: string, color: NoteColor) => mutate(id, { color });
const setReminder = (id: string, remindAt: string | null) => mutate(id, { remind_at: remindAt }); const setReminder = (id: string, remindAt: string | null) => mutate(id, { remind_at: remindAt });
const setRecurrence = (id: string, recurrence: string | null) => mutate(id, { recurrence }); const setRecurrence = (id: string, recurrence: string | null) => mutate(id, { recurrence });
const saveEdit = (id: string, changes: { body: string; color: NoteColor }) => mutate(id, changes); const saveEdit = (id: string, changes: { body: string }) => mutate(id, changes);
async function completeReminder(id: string): Promise<void> { async function completeReminder(id: string): Promise<void> {
reconcile(await repo.notes.completeReminder(id)); reconcile(await repo.notes.completeReminder(id));
@@ -274,7 +269,6 @@ export const useNotesStore = defineStore("notes", () => {
create, create,
setPinned, setPinned,
setArchived, setArchived,
setColor,
setReminder, setReminder,
setRecurrence, setRecurrence,
completeReminder, completeReminder,
-19
View File
@@ -143,25 +143,6 @@ body {
} }
} }
/* The per-card colour popover, anchored to whichever end of the card the action set
* currently occupies: it opens DOWNWARD from a floating top-corner pill, and UPWARD
* from a footer row, so in both cases it grows into the card rather than off it. */
.note-swatches {
position: absolute;
right: 0;
bottom: 100%;
margin-bottom: 0.375rem;
z-index: 20;
}
@media (hover: hover) {
.note-swatches {
top: 100%;
bottom: auto;
margin-top: 0.375rem;
margin-bottom: 0;
}
}
/* Board motion (M7). Defined once here rather than three times in BoardView's /* Board motion (M7). Defined once here rather than three times in BoardView's
* markup, because "how the board moves" is one idea even though the pinned, other * markup, because "how the board moves" is one idea even though the pinned, other
* and non-board grids are three TransitionGroups. * and non-board grids are three TransitionGroups.
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env sh
#
# Refuse to publish a version lower than the one already on the channel.
#
# guard-forward.sh <desktop|android> <dev|stable>
# guard-forward.sh compare <a> <b> exit 0 iff a sorts below b
# guard-forward.sh published <artifact> <channel> print what the channel serves
#
# Note 3127 §6.3. Everything else in this milestone derives a number and trusts it;
# this is the one thing that checks the answer against reality before a user gets it.
#
# WHAT IT CATCHES that nothing else does:
#
# * A SQUASH OR REBASE MERGE (§6.2). Both rewrite the committer date, so `main`
# could stamp a value unrelated to the dev commit it merged. Rule 153 mandates
# plain merge commits — but that rule governs people, and a forge UI's squash
# button does not read it.
# * A REBUILD OF AN OLDER COMMIT. Commit time can go backwards; this is the entire
# mitigation for the desktop key's clock choice (step 4), and the thing to
# revisit first if this repo ever starts rebuilding old commits routinely.
# * CLOCK SKEW between runners, for a build-time key.
#
# What it does NOT catch, because something better does: a shallow clone. That is
# tested directly in `version.sh` via `--is-shallow-repository`, which needs no
# network and covers artifacts that have no published value to compare against.
#
# TOO-LOW IS THE UNRECOVERABLE DIRECTION. A version below what is published means
# every installed client reports "up to date" forever and there is no build you can
# ship to fix it — you have to get back ABOVE the bad number. That is #2183 and
# #2993's shared symptom, and it is why this fails the lane rather than warning.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SERVER="${GITHUB_SERVER_URL:-https://git.fabledsword.com}"
REPO="${GITHUB_REPOSITORY:-bvandeusen/thoughtsync}"
artifact="${1:?usage: guard-forward.sh <desktop|android> <dev|stable>}"
# True when $1 sorts strictly below $2, comparing NUMERICALLY per dot-segment.
#
# Not a string compare, which is the classic way to get this wrong: `1.0.10` sorts
# below `1.0.9` as text. A missing segment reads as 0, so `1.0` == `1.0.0`.
version_lt() {
_a="$1"; _b="$2"
while [ -n "$_a" ] || [ -n "$_b" ]; do
if [ "${_a%%.*}" = "$_a" ]; then _ah="$_a"; _at=""; else _ah="${_a%%.*}"; _at="${_a#*.}"; fi
if [ "${_b%%.*}" = "$_b" ]; then _bh="$_b"; _bt=""; else _bh="${_b%%.*}"; _bt="${_b#*.}"; fi
[ -n "$_ah" ] || _ah=0
[ -n "$_bh" ] || _bh=0
if [ "$_ah" -lt "$_bh" ]; then return 0; fi
if [ "$_ah" -gt "$_bh" ]; then return 1; fi
_a="$_at"; _b="$_bt"
done
return 1 # equal
}
# Auth if we have it, anonymous if not — the releases are public, but a token costs
# nothing and keeps this working if that ever changes.
#
# MISSING CURL IS FATAL, not empty. Every fetch here ends in `|| true` so a network
# blip reads as "nothing published yet" and passes — which is right for a genuinely
# empty channel and catastrophic for a runner image without curl, where it would
# silently turn the guard into a no-op that reports success on every build.
if ! command -v curl >/dev/null 2>&1; then
echo "guard-forward.sh: curl is not on PATH — refusing to run, because every" >&2
echo " lookup here would read as 'nothing published' and this" >&2
echo " guard would pass without checking anything." >&2
exit 1
fi
fetch() {
if [ -n "${GITHUB_TOKEN:-}" ]; then
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" "$1" 2>/dev/null || true
else
curl -fsSL "$1" 2>/dev/null || true
fi
}
# What the channel is serving, per artifact. ONE definition of where to look, shared
# with `should-build.sh` — the skip decision and the guard must agree about what is
# published, and two readers of one fact is how this repo keeps producing #2181-2183.
published_for() {
case "$1" in
desktop)
# What the UPDATER reads. The manifest is the thing that decides whether a
# client is offered a build, so it is the authority on what is published.
fetch "$SERVER/$REPO/releases/download/$2/latest.json" \
| grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 \
| sed -E 's/.*"([^"]+)"$/\1/'
;;
android)
fetch "$SERVER/$REPO/releases/download/$2/thoughtsync-android.json" \
| grep -oE '"version_code"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 \
| grep -oE '[0-9]+$'
;;
esac
}
# The NAME the channel serves, which is the commit-derived value. Separate from
# `published_for` because the guard compares ordering KEYS and the skip decision
# compares identity — for Android those are different fields, and conflating them
# would make every build look like a change (the code is build-time; it always moves).
published_name() {
case "$1" in
desktop) published_for desktop "$2" ;;
android)
fetch "$SERVER/$REPO/releases/download/$2/thoughtsync-android.json" \
| grep -oE '"version_name"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 \
| sed -E 's/.*"([^"]+)"$/\1/'
;;
esac
}
# An explicit comparison mode, so the ordering logic is testable without a network
# and inspectable without a push. Read-only and bypasses nothing — it is the same
# function the guard itself uses, which is the point: a test of a reimplementation
# would prove nothing about the code that runs.
if [ "$artifact" = "compare" ]; then
a="${2:?usage: guard-forward.sh compare <a> <b>}"
b="${3:?usage: guard-forward.sh compare <a> <b>}"
if version_lt "$a" "$b"; then exit 0; else exit 1; fi
fi
if [ "$artifact" = "published" ]; then
a2="${2:?usage: guard-forward.sh published <artifact> <channel>}"
c2="${3:?usage: guard-forward.sh published <artifact> <channel>}"
published_name "$a2" "$c2"
exit 0
fi
channel="${2:?usage: guard-forward.sh <desktop|android> <dev|stable>}"
case "$artifact" in desktop|android) : ;; *)
echo "guard-forward.sh: unknown artifact '$artifact'" >&2; exit 2 ;;
esac
case "$channel" in dev|stable) : ;; *)
echo "guard-forward.sh: unknown channel '$channel'" >&2; exit 2 ;;
esac
case "$artifact" in
desktop)
derived="$(sh "$ROOT/packaging/version.sh" key desktop)"
# What the UPDATER reads, not what the release happens to hold — the manifest is
# the thing that decides whether a client is offered this build.
published="$(published_for desktop "$channel")"
# COMMIT time, so EQUALITY IS THE ORDINARY CASE: an unchanged source derives
# exactly what it derived last time, and `<=` would fail every no-change build.
# §6.3 says *strictly* less for exactly this reason.
strict=""
;;
android)
derived="$(sh "$ROOT/packaging/version.sh" key android)"
published="$(published_for android "$channel")"
# BUILD time, so equality is NOT ordinary — it means two builds landed in the
# same minute, and Android refuses to install an APK whose versionCode does not
# RISE. So this one requires strictly greater.
#
# If it ever fires, the cheap fix is seconds rather than minutes in version.sh
# (~210M today against Android's 2.1e9 ceiling, so ~60 years of headroom).
# Not done pre-emptively: the concurrency group cancels older runs on a branch,
# so two builds finishing in one minute needs concurrent runs on different
# branches, and the failure is a refused install rather than a stranded channel.
strict="yes"
;;
esac
if [ -z "$published" ]; then
# A channel with nothing on it yet — `stable` before its first merge, or a fresh
# repo. PASS: there is nothing to go backwards from. Failing here would block the
# very first publish to a channel, which is the one case where "lower than what is
# published" is meaningless.
echo "guard: $channel has no published $artifact version yet — nothing to compare."
echo "guard: publishing $derived."
exit 0
fi
echo "guard: $artifact on $channel — derived $derived, published $published"
if version_lt "$derived" "$published"; then
echo "" >&2
echo "GUARD FAILED: $derived is BELOW the published $published on $channel." >&2
echo "" >&2
echo " Publishing it would leave every installed client reporting 'up to date'" >&2
echo " forever, and no later build fixes that until one climbs back above the" >&2
echo " bad number. Do not force past this." >&2
echo "" >&2
echo " Usual causes (note 3127 §6.2, §6.3):" >&2
echo " - a squash or rebase merge rewrote the committer date" >&2
echo " - this build is a rebuild of an older commit" >&2
echo " - clock skew between runners (build-time keys)" >&2
exit 1
fi
if [ -n "$strict" ] && [ "$derived" = "$published" ]; then
echo "" >&2
echo "GUARD FAILED: $derived EQUALS the published $published on $channel." >&2
echo "" >&2
echo " Android requires versionCode to RISE; an equal one cannot be installed" >&2
echo " over what is already out there. Two builds landed in the same minute." >&2
exit 1
fi
echo "guard: ok — $derived may be published."
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env sh
#
# The markdown body for a release: what went live since the previous one.
#
# release-notes.sh <tag>
#
# A release BUILDS NOTHING now (M314 step 7). The merge to `main` already published
# `:latest`, `:<sha>` and both channel feeds, so a tag rebuilding that same source
# would produce identical artifacts and re-push `:<sha>` with different bytes —
# which rule 145 forbids even when the bytes match.
#
# So what is a release FOR? Note 3127 §5 answers it: the changelog. 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 is in it that was not ← this
# in the one I ran last month
#
# DERIVED FROM GIT, not hand-maintained. A CHANGELOG.md drifts into being
# aspirational — it records what someone meant to ship. `git log` records what
# shipped, and cannot say otherwise.
set -eu
cd "$(git rev-parse --show-toplevel)"
tag="${1:?usage: release-notes.sh <tag>}"
# The previous release tag, by DATE rather than by name.
#
# `v*` only: this repo also carries `dev` and `stable` tags, which are the fixed-tag
# pointer releases the updater reads. They move constantly and are not releases in
# this sense; sorting them in would make "the previous release" mean whichever
# channel published most recently.
#
# Excludes the tag being described, so re-running on an existing tag still produces
# the range that tag covers rather than an empty one.
prev="$(git tag -l 'v*' --sort=-creatordate | grep -vxF "$tag" | head -1 || true)"
if [ -n "$prev" ]; then
range="$prev..$tag"
header="Changes since \`$prev\`."
else
# The first release. Everything is new, and listing the entire history would be
# noise — say so instead.
range="$tag"
header="First release."
fi
printf 'ThoughtSync %s\n\n%s\n\n' "$tag" "$header"
# `--no-merges`: a merge commit's subject is "Merge branch ..." and says nothing
# about what shipped. The commits it brought in are listed individually, which is
# what somebody reading this wants.
#
# `%s` alone, not `%s (%h)`: the sha is in the forge's own view of the release and
# a reader chasing a specific change clicks through rather than copying a hash out
# of prose.
# CAPPED, because an unbounded list is not a changelog — it is a wall.
#
# The first dated release spans everything since `v0.1.0` — 181 commits at the
# time of writing: nobody reads that, and burying twelve interesting changes in it is worse
# than not writing one. Later releases will be short and the cap will never bite.
#
# The most RECENT are kept, not the oldest, and the count of what was dropped is
# stated — a truncated list that does not say it is truncated is a lie.
CAP=60
total="$(git log --no-merges --format='%s' "$range" | wc -l | tr -d ' ')"
git log --no-merges --reverse --format='- %s' "$range" | tail -"$CAP"
if [ "$total" -gt "$CAP" ]; then
printf '\n_...and %s earlier commits in this range, omitted for length._\n' \
"$((total - CAP))"
fi
printf '\n'
printf '%s\n' "_No artifacts here. Builds reach users from \`main\`: the desktop and Android"
printf '%s\n' "channels and the server image all publish on merge, with no tag required. This"
printf '%s\n' "release is a bookmark — it names a moment and says what was in it._"
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env sh
#
# Does this artifact need building, or is the channel already serving this exact
# source? Prints `true` or `false`.
#
# should-build.sh <desktop|android> <dev|stable>
#
# Note 3127 §4, skip-if-exists — adapted, because §4 assumes a registry keyed by
# VERSION and rule 145 removed exactly that. There is no `:<version>` tag to ask
# about. What there IS, for both clients, is a channel that publishes the version it
# is serving, and that answers the same question: if the channel already serves what
# this source derives, the artifact would be byte-identical and there is nothing to
# build.
#
# WHAT THIS REPLACES, and why that matters more here than the cost saving: the
# `paths:` filters in the workflows were a SECOND, independent statement of each
# artifact's file set, hand-kept beside the one in `version.sh`. They disagreed
# within a day of the sets being written — `packaging/` was added to the sets and
# not to the filters, so the commit that fixed a derivation bug never ran on the two
# lanes it fixed (85ead4d). §3 warns about exactly this duplication; one definition
# with one reader is the fix, and the cost saving is a bonus.
#
# THE SERVER IS NOT LISTED HERE, DELIBERATELY. Its image build is ~15 seconds against
# 6 and 9 minutes for the clients, so there is little to save — and always building
# it is strictly better for a server that can face the internet, because it picks up
# `python:3.12-slim` base updates on every push. That is also why the base-image
# tension in §4 does not bite this project: the artifact most exposed to it never
# skips. The clients' bases are CI runner images, pinned deliberately.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
artifact="${1:?usage: should-build.sh <desktop|android> <dev|stable>}"
channel="${2:?usage: should-build.sh <desktop|android> <dev|stable>}"
case "$artifact" in desktop|android) : ;; *)
echo "should-build.sh: unknown artifact '$artifact'" >&2; exit 2 ;;
esac
case "$channel" in dev|stable) : ;; *)
echo "should-build.sh: unknown channel '$channel'" >&2; exit 2 ;;
esac
# The value that answers "is this the same code?" — which is not the same as the one
# the guard compares.
#
# desktop the ordering key IS the identity; one value, one clock.
# android the NAME. Its versionCode is build-time and moves every run, so
# comparing that would report a change on every push and never skip.
case "$artifact" in
desktop) derived="$(sh "$ROOT/packaging/version.sh" key desktop)" ;;
android) derived="$(sh "$ROOT/packaging/version.sh" display android)" ;;
esac
published="$(sh "$ROOT/packaging/guard-forward.sh" published "$artifact" "$channel")"
if [ -z "$published" ]; then
echo "should-build: $channel serves no $artifact yet — building." >&2
echo true
exit 0
fi
if [ "$derived" = "$published" ]; then
# UNCHANGED. The channel is already serving this exact source, so a build would
# produce the same artifact under the same name and republish it for nothing.
#
# Skipping is safe here in a way it would not be if anything pinned: there is no
# immutable tag to re-push with different bytes (rule 145 removed version tags),
# so the immutability argument in §4.2 does not apply and this stands on cost
# alone — which is the smaller, honest claim.
echo "should-build: $channel already serves $artifact $derived — skipping." >&2
echo false
exit 0
fi
echo "should-build: $artifact moved $published -> $derived — building." >&2
echo true
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env sh
#
# What version an artifact carries, derived from its OWN shipped files.
#
# Replaces desktop/packaging/build-version.sh, which was one generator feeding the
# desktop bundles AND the Android APK off `GITHUB_RUN_NUMBER`. A Kotlin-only commit
# re-versioned the desktop; a Rust-only commit re-versioned the phone. It read as
# tidy — one definition, no drift — which is exactly why it survived review. One
# definition of HOW to derive is right; one VALUE for unrelated artifacts is not.
# (Note 3127 §3, which cites this repo as its example of the failure.)
#
# Lives at the repo root, not under desktop/, because it now serves three artifacts
# and a shared thing filed under one consumer is how it ends up owned by that one.
#
# version.sh display <artifact> the human-readable version — 2026.08.28.1815
# version.sh key <artifact> the ordering key a comparator reads
# version.sh paths <artifact> the shipped file set (for tests and debugging)
#
# TWO VALUES, NOT ONE, and which you want depends on the question:
#
# "is this the same code?" -> display. A dev build and the main build of one
# commit read identically, because they ARE the
# same bytes (note 3127 §2, reason 4).
# "may this replace that?" -> key. What an updater or an install gate
# compares, and never shown to a person.
#
# The desktop needs both because Tauri's updater parses `latest.json`'s version with
# the semver crate, and `2026.08.28.1815` is not valid semver — four segments where
# the spec allows three, and `08` is a leading zero, which it forbids outright. A
# non-semver string does not sort low: the feed fails to DESERIALIZE and every client
# reports "no update available" forever. So the platform's field takes an opaque key
# and the display version lives beside it. See #3142's spike.
#
# WHY NOT A `-dev.N` PRERELEASE for the dev channel — carried over from the script
# this replaces, because it is a real finding and the reasoning is not obvious:
# a prerelease sorts BELOW the release it qualifies (`0.1.0-dev.5` < `0.1.0`), so a
# dev build could never be offered as an update to a tagged one, and Windows
# installer metadata wants a numeric X.Y.Z anyway. The channel goes in a sibling
# field, never in the version — note 3127 §7, and rule 149.
set -eu
# ANCHOR AT THE REPO ROOT BEFORE ANYTHING ELSE.
#
# `git log -- <paths>` resolves pathspecs relative to the CURRENT DIRECTORY, not to
# the repo root. Callers run from wherever suits them — the desktop build from
# `desktop/src-tauri`, the Android build from `android`, the manifest job from the
# root — so without this the same request answers differently per caller.
#
# It is not a tidy failure. Measured on run 4796, one push produced THREE versions:
# the desktop build (cwd `desktop/src-tauri`) said 1.0.3494522, while the pacman
# packager and the manifest job both said 1.0.3502131. The build's pathspec had
# matched `desktop/src-tauri/Cargo.toml` — a real file — so git returned the newest
# commit touching THAT, six days stale. Non-empty, so the guard below could not fire;
# the manifest then found no bundle matching its own answer and the lane went red for
# a reason two steps removed from the cause.
#
# The Android job failed loudly in the same run only because its pathspec happened to
# match nothing from `android/`. Same bug, louder symptom, pure luck.
cd "$(git rev-parse --show-toplevel)"
# A SHALLOW CLONE IS FATAL, and asked directly rather than inferred.
#
# Landmine §6.1: depth-1 sees one commit, so `git log -- <paths>` answers about
# whatever happens to be in that commit and the result is a too-LOW version — the
# unrecoverable direction, arrived at silently with every lane green.
#
# The empty-result guard below catches only the case where NOTHING matches. It missed
# the worse one: on run 4796 a partial match returned a real, six-days-stale answer.
# `--is-shallow-repository` tests the actual hazard instead of a symptom of it, costs
# no network, and covers every artifact including the ones with no published value to
# compare against.
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
echo "version.sh: this is a SHALLOW clone — any version derived here would be" >&2
echo " too low, silently. Add 'fetch-depth: 0' to the checkout." >&2
echo " (note 3127 §6.1)" >&2
exit 1
fi
# 2020-01-01T00:00:00Z. The counter epoch, and it must NEVER move: shifting it
# renumbers every artifact downwards, which is the one direction you cannot recover
# from (note 3127 §6.4).
EPOCH=1577836800
# --- the shipped file sets ---------------------------------------------------
#
# ONE definition, read by every consumer. The `paths:` filters in the three
# workflows are a second, independent statement of the same fact today; they come
# out in step 6 when skip-if-exists replaces them. Until then, a change here that is
# not mirrored there means a lane that does not fire — check both.
#
# Read off what actually PACKAGES each artifact, not off intuition. Miss a file and
# a stale build keeps its version; include one that does not ship and you re-version
# for nothing.
#
# THE BUILD DEFINITION IS IN THE SET, and it is the part that is easy to leave out.
# A workflow file is not "shipped" — but change a Gradle flag or a `cargo tauri
# build` argument and the bytes change while the source does not. Once step 6 skips
# a build whose version already exists, that combination serves the OLD artifact on
# a green run: exactly the "miss a file and a stale build keeps its version" failure,
# arriving through the build recipe rather than the source. Same reason `packaging/`
# is in every set: this script decides identity, so a change to it is a change to
# what each artifact claims to be.
paths_for() {
case "$1" in
# tauri's generate_context! embeds the BUILT frontend in the binary, so a
# frontend commit is a desktop change even though nothing under desktop/ moved.
desktop) echo "desktop core frontend Cargo.toml Cargo.lock .forgejo/workflows/desktop.yml packaging" ;;
# The .so is cross-compiled from core/ through uniffi.
android) echo "android core Cargo.toml Cargo.lock .forgejo/workflows/android.yml packaging" ;;
# BUNDLED ARTIFACT: the image bakes in the Android client (ci.yml fetches the APK
# from the channel release and copies it into the package). So the image's set
# must contain the APK's set — an APK-only change genuinely changes what this
# image ships. Note 3127 §3 names this trap; FC's web image embeds the extension
# the same way.
#
# The base images are NOT listed and do not need to be: `Dockerfile` is in the
# set, so pinning `FROM` by digest (step 6) puts the base inside the set for
# free. Resolving a digest at derive time would work too and is WRONG — it is an
# external lookup, which §7's corollary forbids because it makes the value depend
# on when it was computed.
server) echo "src frontend alembic alembic.ini Dockerfile pyproject.toml .forgejo/workflows/ci.yml android core Cargo.toml Cargo.lock .forgejo/workflows/android.yml packaging" ;;
*) echo "version.sh: unknown artifact '$1'" >&2; exit 2 ;;
esac
}
# Sets TS to the newest commit timestamp touching this artifact's files, or exits.
#
# EMPTY IS FATAL, deliberately. A shallow clone sees one commit and derives a
# too-low value with every lane green — the failure landmine §6.1 exists for, and
# the unrecoverable direction. Every job that calls this needs `fetch-depth: 0`;
# this is what turns forgetting it into a red lane instead of a stranded channel.
#
# SETS A GLOBAL RATHER THAN ECHOING, and that is not a style preference. Written as
# `$(commit_ts desktop)` the function runs in a SUBSHELL, so its `exit` ends only
# that subshell and the caller continues with an empty string. Measured before this
# was fixed: `key desktop` on a repo with no matching history printed the error to
# stderr and then emitted `1.0.-26297280` and exited ZERO. A guard that reports a
# problem and does not stop is worse than none — it looks like it is working.
resolve_ts() {
# Unquoted on purpose: the path list is several words.
# shellcheck disable=SC2046
TS="$(git log --format=%ct -1 HEAD -- $(paths_for "$1"))"
if [ -z "$TS" ]; then
echo "version.sh: no commit touches $1's file set — is this a shallow clone?" >&2
echo " (needs fetch-depth: 0; see note 3127 §6.1)" >&2
exit 1
fi
}
minutes_since_epoch() { echo $(( ($1 - EPOCH) / 60 )); }
what="${1:?usage: version.sh <display|key|paths> <desktop|android|server>}"
artifact="${2:?usage: version.sh <display|key|paths> <desktop|android|server>}"
# VALIDATED HERE, in the parent shell, and not left to `paths_for`'s default arm.
#
# Third instance of one trap in this script, so it is worth stating plainly: `exit`
# inside a function called as `$(...)` ends the SUBSHELL, not the script. `paths_for`
# is reached through `$(paths_for "$1")`, so its `exit 2` printed the error and
# returned an EMPTY pathspec — and an empty pathspec matches everything, so
# `version.sh display nope` answered `2026.08.28.0900` and exited 0. A confident
# version for an artifact that does not exist.
#
# The other two were the shallow-clone guard on the `key` path (emitted
# `1.0.-26297280`, exit 0) and the same guard on `display` (which failed only because
# `date` then choked on an empty string — luck, not design). Each was found by a
# different mechanism; none by reading the code. If you add a guard to this file,
# make sure it runs where the script does.
case "$artifact" in
desktop|android|server) : ;;
*)
echo "version.sh: unknown artifact '$artifact' (want desktop, android or server)" >&2
exit 2
;;
esac
case "$what" in
paths)
paths_for "$artifact"
;;
display)
# One shape for every human-readable version in this repo, and for the release
# tag: YYYY.MM.DD.HHMM, zero-padded, UTC (note 3127 §1). Padded so it sorts as
# text as well as numerically, and so two lanes cannot emit forms one character
# apart.
resolve_ts "$artifact"
date -u -d "@$TS" +%Y.%m.%d.%H%M
;;
key)
case "$artifact" in
desktop)
# COMMIT time. The desktop is a one-value system to Tauri — its comparator
# reads the version name — so this key is also what lands in bundle
# filenames and .deb metadata. Commit time buys the property in §2 reason
# (4): the last dev build before a PR and the main build from it are the
# same bytes and derive the same key, so the artifact is reused rather than
# rebuilt and re-signed under a new name.
#
# Commit time CAN go backwards (rebuild an older commit). The backwards
# guard in step 5 is the whole mitigation, and the desktop's failure there
# is soft: an update is not offered. Contrast Android below.
#
# `1.0.` and not `0.0.`: the minor must clear the installed `0.2.<run>` line
# or every dev user is stranded on "up to date" permanently. Checked against
# the live feed (0.2.466), not against what we thought we had published.
resolve_ts desktop
echo "1.0.$(minutes_since_epoch "$TS")"
;;
android)
# BUILD time, and the asymmetry with the desktop is deliberate. Android
# HARD-FAILS an install on a downgrade (INSTALL_FAILED_VERSION_DOWNGRADE)
# and leaves a channel you cannot get out of, so its key must be monotonic
# BY CONSTRUCTION rather than by a guard that runs in CI. Build time cannot
# go backwards; commit time can.
#
# An Int, which is what Android compares. ~3.5M today against a 2.1e9
# ceiling — roughly four thousand years of headroom.
minutes_since_epoch "$(date -u +%s)"
;;
server)
# NO ORDERING KEY. Nothing compares the server image: no updater, no install
# gate, and `:latest` is moved by the registry rather than chosen by a
# client. §2 is explicit that an artifact with nothing to compare needs only
# a name — do not add one because the other two have one.
echo "version.sh: the server has no ordering key; use 'display'" >&2
exit 2
;;
*) echo "version.sh: unknown artifact '$artifact'" >&2; exit 2 ;;
esac
;;
*)
echo "version.sh: unknown request '$what' (want display, key or paths)" >&2
exit 2
;;
esac
+7
View File
@@ -1,3 +1,10 @@
"""ThoughtSync — self-hosted personal thought-capture web app (FabledSword family).""" """ThoughtSync — self-hosted personal thought-capture web app (FabledSword family)."""
# The FALLBACK version, used only when APP_VERSION is absent from the environment —
# i.e. running from a checkout rather than from an image. A built image always has
# it, derived from the server's own shipped file set (packaging/version.sh), so this
# string never reaches a deployed instance and bumping it changes nothing a user
# sees. Kept because a package needs a version and "unknown" is not a valid one for
# packaging metadata; the honest "I cannot say" for a running server is APP_VERSION
# being missing, which app.py already handles.
__version__ = "0.2.0" __version__ = "0.2.0"
+28 -8
View File
@@ -1,16 +1,36 @@
from __future__ import annotations from __future__ import annotations
from .models.note import NOTE_COLORS # The colour palette, and the one place that clamps to it.
#
# Notes and labels share one colour palette (their sets were identical). NOTE_COLORS # It lived on `models/note.py` until M315, when a note stopped having a colour. A
# is the canonical vocabulary (defined on the model); this module is the single home # palette defined on the model that lost one would be a standing invitation to put the
# for the "clamp to the palette" normalizer so notes.py, labels.py and sync.py stop # column back; here it reads as what it now is — a LABEL's vocabulary, shared with the
# each carrying their own copy. # saved-filter and import paths that still name a colour.
#
# Keys, not tints. The actual colours live in each client (frontend/src/notes/colors.ts
# and NoteTint.kt), so they can be retuned without a schema migration — which M315 spent
# two steps doing.
NOTE_COLORS = {
"default",
"red",
"orange",
"yellow",
"green",
"teal",
"blue",
"purple",
"pink",
"gray",
}
__all__ = ["NOTE_COLORS", "normalize_color"] __all__ = ["NOTE_COLORS", "normalize_color"]
def normalize_color(color: object) -> str: def normalize_color(color: object) -> str:
"""Return `color` if it's a known palette key, else the default. One definition """Return `color` if it's a known palette key, else the default.
for both notes and labels."""
`default` is no longer something anybody can CHOOSE — nothing offers a colour
picker since M315 — but it is still where unrecognised input has to land, so this
fallback is unreachable by choice rather than dead.
"""
return color if color in NOTE_COLORS else "default" return color if color in NOTE_COLORS else "default"
+1 -1
View File
@@ -2,7 +2,7 @@
labels, leave the tag-sourced ones alone" logic was duplicated line-for-line between labels, leave the tag-sourced ones alone" logic was duplicated line-for-line between
the labels-picker API (notes.set_note_labels) and sync push (sync._apply_note_manual_labels). the labels-picker API (notes.set_note_labels) and sync push (sync._apply_note_manual_labels).
Single home so both stay in lockstep. via_tag=True rows track the body #tags and are Single home so both stay in lockstep. via_tag=True rows track the body #tags and are
governed by _reconcile_tags — this function never touches them.""" governed by _lift_and_reconcile_tags — this function never touches them."""
from __future__ import annotations from __future__ import annotations
from sqlalchemy import select from sqlalchemy import select
-1
View File
@@ -9,7 +9,6 @@ from . import ( # noqa: F401
label, label,
note, note,
note_attachment, note_attachment,
note_item,
note_link_preview, note_link_preview,
note_revision, note_revision,
saved_filter, saved_filter,
-19
View File
@@ -10,22 +10,6 @@ from sqlalchemy.orm import Mapped, mapped_column
from . import Base from . import Base
from ..common import iso from ..common import iso
# The Keep-style palette. Stored as a key string, so the actual tints live in the
# frontend and can change without a schema migration.
NOTE_COLORS = {
"default",
"red",
"orange",
"yellow",
"green",
"teal",
"blue",
"purple",
"pink",
"gray",
}
class Note(Base): class Note(Base):
__tablename__ = "notes" __tablename__ = "notes"
__table_args__ = ( __table_args__ = (
@@ -45,8 +29,6 @@ class Note(Base):
# the full-text vector can weight it above the rest of the body. # the full-text vector can weight it above the rest of the body.
display_title: Mapped[str] = mapped_column(Text(), nullable=False, server_default="") display_title: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="") body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
color: Mapped[str] = mapped_column(Text(), nullable=False, server_default="default")
# 'text' (freeform body) or 'list' (a checklist of note_items).
# Manual drag order (higher = earlier); 0 until the user reorders. # Manual drag order (higher = earlier); 0 until the user reorders.
position: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0") position: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0")
pinned: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false()) pinned: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
@@ -75,7 +57,6 @@ class Note(Base):
"id": str(self.id), "id": str(self.id),
"display_title": self.display_title, "display_title": self.display_title,
"body": self.body, "body": self.body,
"color": self.color,
"position": self.position, "position": self.position,
"pinned": self.pinned, "pinned": self.pinned,
"archived": self.archived, "archived": self.archived,
-29
View File
@@ -1,29 +0,0 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from . import Base
class NoteItem(Base):
"""A single checklist item on a note.
Any note can have them. There is no note "kind" gating this — a checklist is
something a note HAS, not something a note IS (M13 step 2).
"""
__tablename__ = "note_items"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
note_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("notes.id", ondelete="CASCADE"), nullable=False
)
text: Mapped[str] = mapped_column(Text(), nullable=False)
checked: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
position: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+3 -2
View File
@@ -12,8 +12,9 @@ from . import Base
class SavedFilter(Base): class SavedFilter(Base):
"""A named, saved facet combination (a 'view'/lens) the user can re-apply in one """A named, saved facet combination (a 'view'/lens) the user can re-apply in one
click — e.g. "Yellow + #ideas". `params` is a JSON-encoded facet dict matching the click — e.g. "#ideas with a reminder". `params` is a JSON-encoded facet dict
GET /api/notes query (q/color/labels/has_reminder/has_attachment/date range).""" matching the GET /api/notes query (q/labels/has_reminder/has_attachment/date
range). Colour was a facet until M315; 0029 swept the key out of stored rows."""
__tablename__ = "saved_filters" __tablename__ = "saved_filters"
+75 -90
View File
@@ -23,7 +23,7 @@ from sqlalchemy import func, literal_column, select
from ..acl import visible_to_user from ..acl import visible_to_user
from ..auth import login_required from ..auth import login_required
from ..colors import NOTE_COLORS, normalize_color from ..colors import normalize_color
from ..common import coerce_bool, iso, parse_dt from ..common import coerce_bool, iso, parse_dt
from ..config import Config from ..config import Config
from ..db import session_scope from ..db import session_scope
@@ -31,9 +31,16 @@ from ..labeling import reconcile_manual_labels, resolve_owned_label_ids
from ..models.label import Label, NoteLabel from ..models.label import Label, NoteLabel
from ..models.note import Note from ..models.note import Note
from ..models.note_attachment import NoteAttachment from ..models.note_attachment import NoteAttachment
from ..models.note_item import NoteItem
from ..models.note_link_preview import NoteLinkPreview from ..models.note_link_preview import NoteLinkPreview
from ..models.note_revision import NoteRevision from ..models.note_revision import NoteRevision
from ..revisions import should_snapshot
from .checklist import (
append_item,
parse_items,
remove_item,
set_item_checked,
set_item_text,
)
from ..responses import json_error, not_found, parse_uuid from ..responses import json_error, not_found, parse_uuid
from ..retention import purge_note from ..retention import purge_note
from ..settings import get_setting from ..settings import get_setting
@@ -65,11 +72,11 @@ from .import_export import (
_usec_to_dt, _usec_to_dt,
) )
from .tags import ( from .tags import (
_reconcile_tags, _lift_and_reconcile_tags,
parse_tags, parse_tags,
) )
from .recurrence import REMINDER_RECURRENCES, next_occurrence, normalize_recurrence from .recurrence import REMINDER_RECURRENCES, next_occurrence, normalize_recurrence
from .serialize import _items_for_notes, _labels_for_notes, _serialize_note, _serialize_notes from .serialize import _labels_for_notes, _serialize_note, _serialize_notes
__all__ = [ __all__ = [
"bp", "bp",
@@ -80,7 +87,7 @@ __all__ = [
"normalize_color", "normalize_color",
"normalize_recurrence", "normalize_recurrence",
"next_occurrence", "next_occurrence",
"_reconcile_tags", "_lift_and_reconcile_tags",
"_serialize_notes", "_serialize_notes",
"_safe_filename", "_safe_filename",
"_attachment_ext", "_attachment_ext",
@@ -101,7 +108,6 @@ async def list_notes():
# Combinable facet filters (all optional, AND-ed together) — the rich-search / # Combinable facet filters (all optional, AND-ed together) — the rich-search /
# saved-filter lens. Multiple ?label= narrow to notes carrying ALL of them. # saved-filter lens. Multiple ?label= narrow to notes carrying ALL of them.
label_params = request.args.getlist("label") label_params = request.args.getlist("label")
color = request.args.get("color")
has_reminder = coerce_bool(request.args.get("has_reminder")) has_reminder = coerce_bool(request.args.get("has_reminder"))
has_attachment = coerce_bool(request.args.get("has_attachment")) has_attachment = coerce_bool(request.args.get("has_attachment"))
query_text = (request.args.get("q") or "").strip() query_text = (request.args.get("q") or "").strip()
@@ -121,10 +127,6 @@ async def list_notes():
if lid is None: if lid is None:
return json_error("invalid label", 400) return json_error("invalid label", 400)
stmt = stmt.where(Note.id.in_(select(NoteLabel.note_id).where(NoteLabel.label_id == lid))) stmt = stmt.where(Note.id.in_(select(NoteLabel.note_id).where(NoteLabel.label_id == lid)))
if color is not None:
if color not in NOTE_COLORS:
return json_error("invalid color", 400)
stmt = stmt.where(Note.color == color)
if has_reminder: if has_reminder:
stmt = stmt.where(Note.remind_at.is_not(None)) stmt = stmt.where(Note.remind_at.is_not(None))
if has_attachment: if has_attachment:
@@ -224,7 +226,6 @@ async def export_notes():
).all() ).all()
ids = [n.id for n in notes_list] ids = [n.id for n in notes_list]
labels_map = await _labels_for_notes(db, ids) labels_map = await _labels_for_notes(db, ids)
items_map = await _items_for_notes(db, ids)
att_rows = ( att_rows = (
(await db.scalars(select(NoteAttachment).where(NoteAttachment.note_id.in_(ids)))).all() if ids else [] (await db.scalars(select(NoteAttachment).where(NoteAttachment.note_id.in_(ids)))).all() if ids else []
) )
@@ -246,7 +247,6 @@ async def export_notes():
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for n in notes_list: for n in notes_list:
labels = labels_map.get(n.id, []) labels = labels_map.get(n.id, [])
items = items_map.get(n.id, [])
atts = att_by_note.get(n.id, []) atts = att_by_note.get(n.id, [])
short = str(n.id)[:8] short = str(n.id)[:8]
payload["notes"].append( payload["notes"].append(
@@ -254,7 +254,6 @@ async def export_notes():
"id": str(n.id), "id": str(n.id),
"display_title": n.display_title, "display_title": n.display_title,
"body": n.body, "body": n.body,
"color": n.color,
"pinned": n.pinned, "pinned": n.pinned,
"archived": n.archived, "archived": n.archived,
"remind_at": n.remind_at.isoformat() if n.remind_at else None, "remind_at": n.remind_at.isoformat() if n.remind_at else None,
@@ -262,13 +261,12 @@ async def export_notes():
"created_at": n.created_at.isoformat() if n.created_at else None, "created_at": n.created_at.isoformat() if n.created_at else None,
"updated_at": n.updated_at.isoformat() if n.updated_at else None, "updated_at": n.updated_at.isoformat() if n.updated_at else None,
"labels": [lb["name"] for lb in labels], "labels": [lb["name"] for lb in labels],
"items": [{"text": it["text"], "checked": it["checked"]} for it in items],
"attachments": [ "attachments": [
{"file": f"attachments/{short}/{os.path.basename(a.path)}", "mime": a.mime} for a in atts {"file": f"attachments/{short}/{os.path.basename(a.path)}", "mime": a.mime} for a in atts
], ],
} }
) )
zf.writestr(f"notes/{_slugify(n.display_title)}-{short}.md", _note_markdown(n, labels, items)) zf.writestr(f"notes/{_slugify(n.display_title)}-{short}.md", _note_markdown(n, labels))
for a in atts: for a in atts:
src = Config.media_root() / a.path src = Config.media_root() / a.path
if src.is_file(): if src.is_file():
@@ -384,24 +382,6 @@ async def reorder_notes():
return jsonify({"ok": True}) return jsonify({"ok": True})
async def _name_for(db, note: Note, item_texts: list[str] | None = None) -> str:
"""The note's display name, consulting its checklist only when the body is silent.
`item_texts` short-circuits the query for callers that already hold the items
(create, import). Everyone else pays one narrow SELECT, and only when the body
produced nothing — which is the uncommon case.
"""
name = derive_display_title(note.body)
if name:
return name
if item_texts is not None:
return derive_display_title("", item_texts[0] if item_texts else None)
first = await db.scalar(
select(NoteItem.text).where(NoteItem.note_id == note.id).order_by(NoteItem.position).limit(1)
)
return derive_display_title("", first)
@bp.post("") @bp.post("")
@login_required @login_required
async def create_note(): async def create_note():
@@ -418,18 +398,20 @@ async def create_note():
Note.owner_id == g.user_id, Note.deleted_at.is_(None) Note.owner_id == g.user_id, Note.deleted_at.is_(None)
) )
) )
# Items still arrive separately — a client holds a list, not a blob — but they
# are folded into the body, which is where a checklist lives now (M304).
for text in item_texts:
body = append_item(body, text)
note = Note( note = Note(
owner_id=g.user_id, owner_id=g.user_id,
display_title=derive_display_title(body, item_texts[0] if item_texts else None), display_title=derive_display_title(body),
body=body, body=body,
color=normalize_color(data.get("color")),
position=int(max_pos) + 1, position=int(max_pos) + 1,
) )
db.add(note) db.add(note)
await db.flush() # assign note.id before writing items/links await db.flush() # assign note.id before writing links
for pos, text in enumerate(item_texts): # The FOLDED body: an item can carry a #tag too.
db.add(NoteItem(note_id=note.id, text=text, position=pos)) await _lift_and_reconcile_tags(db, note)
await _reconcile_tags(db, note)
await db.commit() await db.commit()
await db.refresh(note) await db.refresh(note)
# After the commit, never before it: the note is saved and the response is # After the commit, never before it: the note is saved and the response is
@@ -464,8 +446,6 @@ async def update_note(note_id: str):
old_body = note.body old_body = note.body
if "body" in data and isinstance(data["body"], str): if "body" in data and isinstance(data["body"], str):
note.body = data["body"] note.body = data["body"]
if "color" in data:
note.color = normalize_color(data["color"])
if "pinned" in data: if "pinned" in data:
note.pinned = bool(data["pinned"]) note.pinned = bool(data["pinned"])
if "archived" in data: if "archived" in data:
@@ -483,10 +463,12 @@ async def update_note(note_id: str):
if "recurrence" in data: if "recurrence" in data:
note.recurrence = normalize_recurrence(data["recurrence"]) note.recurrence = normalize_recurrence(data["recurrence"])
if "body" in data: if "body" in data:
note.display_title = await _name_for(db, note) note.display_title = derive_display_title(note.body)
await _reconcile_tags(db, note) await _lift_and_reconcile_tags(db, note)
# Version history: snapshot the PRE-edit body whenever it changed. # Version history: snapshot the PRE-edit body, once per editing session
if note.body != old_body: # rather than once per write — see revisions.should_snapshot. Writing often
# is what lets a client autosave instead of hoarding text until it closes.
if await should_snapshot(db, note.id, old_body, note.body):
db.add(NoteRevision(note_id=note.id, body=old_body)) db.add(NoteRevision(note_id=note.id, body=old_body))
await db.commit() await db.commit()
await db.refresh(note) await db.refresh(note)
@@ -540,8 +522,8 @@ async def restore_revision(note_id: str, rev_id: str):
# the revision — with the same body ripple as a normal edit. # the revision — with the same body ripple as a normal edit.
db.add(NoteRevision(note_id=note.id, body=note.body)) db.add(NoteRevision(note_id=note.id, body=note.body))
note.body = rev.body note.body = rev.body
note.display_title = await _name_for(db, note) note.display_title = derive_display_title(note.body)
await _reconcile_tags(db, note) await _lift_and_reconcile_tags(db, note)
await db.commit() await db.commit()
await db.refresh(note) await db.refresh(note)
return jsonify(await _serialize_note(db, note)) return jsonify(await _serialize_note(db, note))
@@ -572,11 +554,35 @@ async def set_note_labels(note_id: str):
return jsonify(await _serialize_note(db, note)) return jsonify(await _serialize_note(db, note))
async def _get_item(db, note: Note, item_id: str) -> NoteItem | None: def _item_index(item_id: str) -> int | None:
iid = parse_uuid(item_id) """An item's id is its ordinal (see serialize.items_of). Anything else is a stale
if iid is None: id from a client that has not reloaded, and the answer to those is 404."""
try:
index = int(item_id)
except (TypeError, ValueError):
return None return None
return await db.scalar(select(NoteItem).where(NoteItem.id == iid, NoteItem.note_id == note.id)) return index if index >= 0 else None
async def _rewrite_body(db, note: Note, body: str):
"""Every item mutation is a body edit, so all of them land here.
One place means one place that snapshots a revision, re-derives `#tags`, recomputes
the name and queues link unfurls — rather than three routes each remembering to.
Deliberately the same sequence the PATCH route runs for a body change, because it
IS a body change.
"""
old_body = note.body
if await should_snapshot(db, note.id, old_body, body):
db.add(NoteRevision(note_id=note.id, body=old_body))
note.body = body
note.display_title = derive_display_title(body)
await _lift_and_reconcile_tags(db, note)
await db.commit()
await db.refresh(note)
if note.body != old_body:
schedule_unfurls(note.id, note.body)
return jsonify(await _serialize_note(db, note))
@bp.post("/<note_id>/items") @bp.post("/<note_id>/items")
@@ -588,70 +594,49 @@ async def add_item(note_id: str):
note = await _get_owned(db, note_id) note = await _get_owned(db, note_id)
if note is None: if note is None:
return not_found() return not_found()
max_pos = await db.scalar( response = await _rewrite_body(db, note, append_item(note.body, text))
select(func.coalesce(func.max(NoteItem.position), -1)).where(NoteItem.note_id == note.id) return response, 201
)
db.add(NoteItem(note_id=note.id, text=text, position=int(max_pos) + 1))
await db.commit()
return jsonify(await _serialize_note(db, note)), 201
@bp.patch("/<note_id>/items/<item_id>") @bp.patch("/<note_id>/items/<item_id>")
@login_required @login_required
async def update_item(note_id: str, item_id: str): async def update_item(note_id: str, item_id: str):
data = await request.get_json(silent=True) or {} data = await request.get_json(silent=True) or {}
index = _item_index(item_id)
if index is None:
return not_found()
async with session_scope() as db: async with session_scope() as db:
note = await _get_owned(db, note_id) note = await _get_owned(db, note_id)
if note is None: if note is None:
return not_found() return not_found()
item = await _get_item(db, note, item_id) if index >= len(parse_items(note.body)):
if item is None:
return not_found() return not_found()
body = note.body
if "text" in data and isinstance(data["text"], str): if "text" in data and isinstance(data["text"], str):
item.text = data["text"] body = set_item_text(body, index, data["text"])
if "checked" in data: if "checked" in data:
item.checked = bool(data["checked"]) body = set_item_checked(body, index, bool(data["checked"]))
await db.commit() return await _rewrite_body(db, note, body)
return jsonify(await _serialize_note(db, note))
@bp.delete("/<note_id>/items/<item_id>") @bp.delete("/<note_id>/items/<item_id>")
@login_required @login_required
async def delete_item(note_id: str, item_id: str): async def delete_item(note_id: str, item_id: str):
index = _item_index(item_id)
if index is None:
return not_found()
async with session_scope() as db: async with session_scope() as db:
note = await _get_owned(db, note_id) note = await _get_owned(db, note_id)
if note is None: if note is None:
return not_found() return not_found()
item = await _get_item(db, note, item_id) if index >= len(parse_items(note.body)):
if item is None:
return not_found() return not_found()
await db.delete(item) return await _rewrite_body(db, note, remove_item(note.body, index))
await db.commit()
return jsonify(await _serialize_note(db, note))
@bp.post("/<note_id>/items/reorder") # The reorder route is gone with M304. Reordering a checklist is moving a line, which
@login_required # is something a text editor already does and no client ever called this for — the
async def reorder_items(note_id: str): # only reference to it in the tree was a test asserting the route existed.
data = await request.get_json(silent=True) or {}
order = data.get("item_ids")
if not isinstance(order, list):
return json_error("item_ids must be a list", 400)
async with session_scope() as db:
note = await _get_owned(db, note_id)
if note is None:
return not_found()
existing = {
str(i.id): i for i in (await db.scalars(select(NoteItem).where(NoteItem.note_id == note.id))).all()
}
pos = 0
for iid in order:
item = existing.get(str(iid))
if item is not None:
item.position = pos
pos += 1
await db.commit()
return jsonify(await _serialize_note(db, note))
@bp.post("/<note_id>/attachments") @bp.post("/<note_id>/attachments")
+150
View File
@@ -0,0 +1,150 @@
"""Checklist items — a note's body IS its checklist (M304).
A `- [ ] milk` line is the item. There is no `note_items` table beside the body any
more, which is what lets a list sit BETWEEN two paragraphs: rows had a position in a
table and no position in the text, so a separate list could only ever render after
the prose no matter how it was styled.
The same shape as `tags.py`, one strength further along. Tags are derived from the
body too, but they MATERIALISE into `note_labels` 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 `parse_items` is the whole
storage layer for a checklist, and the rewriters below are how one is edited.
THE GRAMMAR IS SHARED. Three implementations exist and they have to agree, because a
difference between any two of them is a checklist that changes shape when it syncs:
core/src/local/derive.rs the native clients (desktop + Android)
src/thoughtsync/notes/checklist.py this file, the server
frontend/src/notes/markdown.ts the browser
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 takes it for a plain bullet, and a rule
that allowed `* item` but not `* [ ] item` would be one nobody could guess. `- [ ]`
with nothing after it IS an item with empty text — that is what pressing Enter on a
list leaves behind, and refusing to parse it would make a half-typed list stop being
a list. `- [X]` parses as checked and renders back lowercase, so one canonical form
survives a round trip.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
# Anchored at both ends: a `[ ]` mid-sentence is prose, and `- [ ]x` (no space after
# the brackets) is a sentence that happens to start with brackets, not a marker.
_TASK_RE = re.compile(r"^(?P<indent>\s*)(?P<bullet>[-*]) +\[(?P<mark>[ xX])\](?: +(?P<text>.*))?$")
@dataclass(frozen=True)
class Item:
"""One checklist item. Its position in the parsed list is its identity — the same
thing `position` meant when these were rows, and all the wire ever carried."""
text: str
checked: bool
def parse_items(body: str | None) -> list[Item]:
"""Every checklist item in `body`, in the order they appear."""
out: list[Item] = []
for line in (body or "").split("\n"):
match = _TASK_RE.match(line)
if match:
out.append(Item(text=match.group("text") or "", checked=match.group("mark") in "xX"))
return out
def render_item(text: str, checked: bool, indent: str = "", bullet: str = "-") -> str:
"""One item as the line that stores it.
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.
"""
mark = "x" if checked else " "
if not text:
return f"{indent}{bullet} [{mark}]"
return f"{indent}{bullet} [{mark}] {text}"
def strip_marker(line: str) -> str:
"""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.
"""
match = _TASK_RE.match(line)
return (match.group("text") or "") if match else line
def _rewrite(body: str, index: int, replace) -> str:
"""Rewrite the `index`-th task line with `replace`, or drop it when `replace`
returns None.
A body with fewer task lines than that is returned UNCHANGED rather than raising:
the index comes from a client that may be a moment behind the server, and a stale
request should do nothing rather than 500.
"""
lines = body.split("\n")
target = None
seen = 0
for n, line in enumerate(lines):
if _TASK_RE.match(line):
if seen == index:
target = n
break
seen += 1
if target is None:
return body
match = _TASK_RE.match(lines[target])
replacement = replace(match)
if replacement is None:
del lines[target]
else:
lines[target] = replacement
return "\n".join(lines)
def set_item_checked(body: str, index: int, checked: bool) -> str:
"""Tick or untick the `index`-th item, keeping its text, indent and bullet."""
return _rewrite(
body,
index,
lambda m: render_item(m.group("text") or "", checked, m.group("indent"), m.group("bullet")),
)
def set_item_text(body: str, index: int, text: str) -> str:
"""Replace the text of the `index`-th item, keeping its state and its bullet."""
return _rewrite(
body,
index,
lambda m: render_item(text.strip(), m.group("mark") in "xX", m.group("indent"), m.group("bullet")),
)
def remove_item(body: str, index: int) -> str:
"""Delete the `index`-th item, line and all."""
return _rewrite(body, index, lambda _m: None)
def append_item(body: str, text: str, checked: bool = False) -> str:
"""Add an item at the end of the body.
A blank line between prose and the list, nothing between consecutive items —
the layout `import_export._note_markdown` has always used when writing a checklist
out. That is not cosmetic: it is what the Alembic migration folds existing
`note_items` rows into AND what `derive::append_item` produces on every client, so
all three land on identical bodies. An export taken before the migration and one
taken after therefore differ in nothing.
"""
line = render_item(text.strip(), checked)
trimmed = body.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}"
+22 -13
View File
@@ -3,6 +3,8 @@ board-filter narrowing, the owner-scoped fetch, and filename/slug sanitizers use
both the attachment routes and the importer.""" both the attachment routes and the importer."""
from __future__ import annotations from __future__ import annotations
from .checklist import strip_marker
import os import os
import re import re
@@ -19,29 +21,36 @@ VALID_FILTERS = {"active", "archived", "trash"}
DISPLAY_TITLE_CAP = 200 DISPLAY_TITLE_CAP = 200
def derive_display_title(body: str | None, first_item: str | None = None) -> str: def derive_display_title(body: str | None) -> str:
"""The note's display NAME: the first non-empty line of the body, else the first """The note's display NAME: the first line of the body that says anything.
checklist item's text (both trimmed and length-capped).
There is no explicit title to prefer any more (M13 step 3) — a note is a body plus There is no explicit title to prefer any more (M13 step 3) — a note is a body, and
optional items, and its name is simply the first thing written in it. Persisted as its name is simply the first thing written in it. Persisted as notes.display_title
notes.display_title so search results and export filenames have something to say. so search results and export filenames have something to say.
The item fallback is what step 2 bought: a note that is only a checklist would The old `first_item` fallback is gone with M304: items ARE body lines now, so a
otherwise have no name at all, which is exactly the hole that made removing the list-only note is named by its first item without anyone having to arrange it. What
title unsafe before checklists stopped being their own kind of thing. replaced the fallback is stripping the task marker — calling that note "- [ ] milk"
would show someone the storage instead of the note — and skipping an EMPTY item, so
a half-typed list does not leave a note with no name.
Deterministic — a literal first line, never generated. Mirrors `display_title` in core/src/local/store.rs. Deterministic — a literal first
line, never generated.
""" """
for line in (body or "").splitlines(): for line in (body or "").splitlines():
stripped = line.strip() stripped = strip_marker(line.strip()).strip()
if stripped: if stripped:
return stripped[:DISPLAY_TITLE_CAP] return stripped[:DISPLAY_TITLE_CAP]
return (first_item or "").strip()[:DISPLAY_TITLE_CAP] return ""
def is_empty_note(body: str | None, items: list | None = None) -> bool: def is_empty_note(body: str | None, items: list | None = None) -> bool:
"""Nothing worth keeping: no body text and no checklist items.""" """Nothing worth keeping: no body text and no checklist items.
`items` is still a separate argument because create still accepts them separately —
the importer holds a list, not a blob — and they are folded into the body only
after this check has decided the note is worth making at all.
"""
return not (body or "").strip() and not items return not (body or "").strip() and not items
+18 -40
View File
@@ -14,13 +14,11 @@ from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from ..colors import normalize_color
from ..common import parse_dt from ..common import parse_dt
from ..config import Config from ..config import Config
from ..models.label import NoteLabel from ..models.label import NoteLabel
from ..models.note import Note from ..models.note import Note
from ..models.note_attachment import NoteAttachment from ..models.note_attachment import NoteAttachment
from ..models.note_item import NoteItem
from .helpers import ( from .helpers import (
ALLOWED_IMAGE_MIMES, ALLOWED_IMAGE_MIMES,
_attachment_ext, _attachment_ext,
@@ -28,18 +26,18 @@ from .helpers import (
derive_display_title, derive_display_title,
is_empty_note, is_empty_note,
) )
from .tags import _find_or_create_label, _reconcile_tags from .checklist import append_item
from .tags import _find_or_create_label, _lift_and_reconcile_tags
from .recurrence import normalize_recurrence from .recurrence import normalize_recurrence
def _note_markdown(note: Note, labels: list, items: list) -> str: def _note_markdown(note: Note, labels: list) -> str:
"""One note as a human-readable Markdown file with a small frontmatter block. """One note as a human-readable Markdown file with a small frontmatter block.
The authoritative machine format is notes.json; this is for reading/portability.""" The authoritative machine format is notes.json; this is for reading/portability."""
fm = ["---"] fm = ["---"]
fm.append(f"display_name: {note.display_title}") fm.append(f"display_name: {note.display_title}")
if labels: if labels:
fm.append("labels: [" + ", ".join(lb["name"] for lb in labels) + "]") fm.append("labels: [" + ", ".join(lb["name"] for lb in labels) + "]")
fm.append(f"color: {note.color}")
if note.pinned: if note.pinned:
fm.append("pinned: true") fm.append("pinned: true")
if note.archived: if note.archived:
@@ -54,34 +52,14 @@ def _note_markdown(note: Note, labels: list, items: list) -> str:
# are written, body first, with a blank line between them when there is. # are written, body first, with a blank line between them when there is.
if note.body: if note.body:
fm.append(note.body) fm.append(note.body)
if items: # No separate items block any more. The body already ends with those exact lines
if note.body: # (M304) — this function is where their layout was decided, and appending them a
fm.append("") # second time would double every checklist in an export.
for it in items:
fm.append(f"- [{'x' if it['checked'] else ' '}] {it['text']}")
return "\n".join(fm) + "\n" return "\n".join(fm) + "\n"
# --- Import: ThoughtSync's own export (round-trip) OR a Google Keep Takeout zip --- # --- Import: ThoughtSync's own export (round-trip) OR a Google Keep Takeout zip ---
# Google Keep (Takeout) color enum → our palette. Keep has a few hues we don't
# (BROWN/DARKBLUE/CERULEAN); map each to the nearest. Unknowns fall back to default.
_KEEP_COLOR_MAP = {
"DEFAULT": "default",
"RED": "red",
"ORANGE": "orange",
"YELLOW": "yellow",
"GREEN": "green",
"TEAL": "teal",
"CERULEAN": "teal",
"BLUE": "blue",
"DARKBLUE": "blue",
"PURPLE": "purple",
"PINK": "pink",
"BROWN": "orange",
"GRAY": "gray",
}
# Reverse of ALLOWED_IMAGE_MIMES, for inferring an attachment's mime from its # Reverse of ALLOWED_IMAGE_MIMES, for inferring an attachment's mime from its
# filename when the source didn't record one (Keep usually does; be defensive). # filename when the source didn't record one (Keep usually does; be defensive).
_EXT_MIME = {ext: mime for mime, ext in ALLOWED_IMAGE_MIMES.items()} _EXT_MIME = {ext: mime for mime, ext in ALLOWED_IMAGE_MIMES.items()}
@@ -102,7 +80,6 @@ def _native_spec(n: dict) -> dict:
return { return {
"title": n.get("title"), "title": n.get("title"),
"body": n.get("body") or "", "body": n.get("body") or "",
"color": n.get("color"),
"pinned": bool(n.get("pinned")), "pinned": bool(n.get("pinned")),
"archived": bool(n.get("archived")), "archived": bool(n.get("archived")),
"trashed": False, # export only includes live notes "trashed": False, # export only includes live notes
@@ -158,7 +135,6 @@ def _keep_spec(kn: dict, keep_dir: str) -> dict:
return { return {
"title": kn.get("title"), "title": kn.get("title"),
"body": body, "body": body,
"color": _KEEP_COLOR_MAP.get(str(kn.get("color") or "DEFAULT").upper(), "default"),
"pinned": bool(kn.get("isPinned")), "pinned": bool(kn.get("isPinned")),
"archived": bool(kn.get("isArchived")), "archived": bool(kn.get("isArchived")),
"trashed": bool(kn.get("isTrashed")), "trashed": bool(kn.get("isTrashed")),
@@ -294,11 +270,18 @@ async def _create_imported_note(
if is_empty_note(body, item_texts): if is_empty_note(body, item_texts):
return False return False
# Items fold into the body, which is where a checklist lives now (M304). Done
# before the Note is built so display_title and _lift_and_reconcile_tags both see the
# finished text — an imported item can carry a #tag like any other line.
for it in items:
text = (it.get("text") or "").strip()
if text:
body = append_item(body, text, bool(it.get("checked")))
note = Note( note = Note(
owner_id=owner_id, owner_id=owner_id,
display_title=derive_display_title(body, item_texts[0] if item_texts else None), display_title=derive_display_title(body),
body=body, body=body,
color=normalize_color(spec.get("color")),
pinned=bool(spec.get("pinned")), pinned=bool(spec.get("pinned")),
archived=bool(spec.get("archived")), archived=bool(spec.get("archived")),
position=position, position=position,
@@ -316,15 +299,10 @@ async def _create_imported_note(
if spec.get("updated_at"): if spec.get("updated_at"):
note.updated_at = spec["updated_at"] note.updated_at = spec["updated_at"]
db.add(note) db.add(note)
await db.flush() # assign note.id before items/labels/attachments/links await db.flush() # assign note.id before labels/attachments/links
for pos, it in enumerate(items):
text = (it.get("text") or "").strip()
if text:
db.add(NoteItem(note_id=note.id, text=text, checked=bool(it.get("checked")), position=pos))
# Explicit (picker-style) labels are manual — via_tag=False. Inline #tags in the # Explicit (picker-style) labels are manual — via_tag=False. Inline #tags in the
# body are handled by _reconcile_tags below, same as a normal create. # body are handled by _lift_and_reconcile_tags below, same as a normal create.
for name in spec.get("labels") or []: for name in spec.get("labels") or []:
name = (name or "").strip() name = (name or "").strip()
if not name: if not name:
@@ -340,5 +318,5 @@ async def _create_imported_note(
if isinstance(att, dict): if isinstance(att, dict):
_import_attachment(db, note, zf, att, budget) _import_attachment(db, note, zf, att, budget)
await _reconcile_tags(db, note) await _lift_and_reconcile_tags(db, note)
return True return True
+19 -20
View File
@@ -8,8 +8,8 @@ from sqlalchemy import select
from ..models.label import Label, NoteLabel from ..models.label import Label, NoteLabel
from ..models.note import Note from ..models.note import Note
from ..models.note_attachment import NoteAttachment from ..models.note_attachment import NoteAttachment
from ..models.note_item import NoteItem
from ..models.note_link_preview import NoteLinkPreview from ..models.note_link_preview import NoteLinkPreview
from .checklist import parse_items
async def _labels_for_notes(db, note_ids: list) -> dict: async def _labels_for_notes(db, note_ids: list) -> dict:
@@ -30,23 +30,23 @@ async def _labels_for_notes(db, note_ids: list) -> dict:
return result return result
def _serialize_item(item: NoteItem) -> dict: def items_of(body: str | None) -> list[dict]:
return {"id": str(item.id), "text": item.text, "checked": item.checked, "position": item.position} """The note's checklist, read out of its body. No query, because there is no table.
Still emitted in the payload after M304, and that is not a second source of truth:
it is DERIVED on the way out, so it cannot disagree with the body it came from. It
saves every consumer that only wants to draw checkboxes from carrying a parser, and
the ones that do carry one (the native clients, the browser) are free to ignore it
and read the body.
async def _items_for_notes(db, note_ids: list) -> dict: The id is the item's ORDINAL, which is what the rewriters in `checklist.py` take,
"""Map note_id -> [checklist items] in one query, ordered by position.""" so a client holding one can act on it directly. It also shifts when an item is
result: dict = {} removed — every mutation returns the reloaded note for exactly that reason.
if not note_ids: """
return result return [
items = ( {"id": str(i), "text": item.text, "checked": item.checked, "position": i}
await db.scalars( for i, item in enumerate(parse_items(body))
select(NoteItem).where(NoteItem.note_id.in_(note_ids)).order_by(NoteItem.position, NoteItem.created_at) ]
)
).all()
for item in items:
result.setdefault(item.note_id, []).append(_serialize_item(item))
return result
def _attachment_url(note_id, att_id) -> str: def _attachment_url(note_id, att_id) -> str:
@@ -108,8 +108,7 @@ async def _serialize_note(db, note: Note) -> dict:
data = note.serialize() data = note.serialize()
labels = await _labels_for_notes(db, [note.id]) labels = await _labels_for_notes(db, [note.id])
data["labels"] = labels.get(note.id, []) data["labels"] = labels.get(note.id, [])
items = await _items_for_notes(db, [note.id]) data["items"] = items_of(note.body)
data["items"] = items.get(note.id, [])
attachments = await _attachments_for_notes(db, [note.id]) attachments = await _attachments_for_notes(db, [note.id])
data["attachments"] = attachments.get(note.id, []) data["attachments"] = attachments.get(note.id, [])
previews = await _previews_for_notes(db, [note.id]) previews = await _previews_for_notes(db, [note.id])
@@ -120,14 +119,14 @@ async def _serialize_note(db, note: Note) -> dict:
async def _serialize_notes(db, notes: list) -> list: async def _serialize_notes(db, notes: list) -> list:
ids = [n.id for n in notes] ids = [n.id for n in notes]
labels_map = await _labels_for_notes(db, ids) labels_map = await _labels_for_notes(db, ids)
items_map = await _items_for_notes(db, ids)
attach_map = await _attachments_for_notes(db, ids) attach_map = await _attachments_for_notes(db, ids)
preview_map = await _previews_for_notes(db, ids) preview_map = await _previews_for_notes(db, ids)
out = [] out = []
for n in notes: for n in notes:
data = n.serialize() data = n.serialize()
data["labels"] = labels_map.get(n.id, []) data["labels"] = labels_map.get(n.id, [])
data["items"] = items_map.get(n.id, []) data["items"] = items_of(n.body)
data["attachments"] = attach_map.get(n.id, []) data["attachments"] = attach_map.get(n.id, [])
data["previews"] = preview_map.get(n.id, []) data["previews"] = preview_map.get(n.id, [])
out.append(data) out.append(data)
+148 -31
View File
@@ -1,6 +1,11 @@
"""#tags — parsing note bodies and keeping the derived tag-sourced note_labels rows """#tags — parsing note bodies, LIFTING the standalone ones out of the text, and
in sync with the text. Manual (picker) labels are NOT touched here (see the labeling keeping the still-in-text ones in sync with the note_labels rows. Manual (picker)
module). labels are NOT touched here (see the labeling module).
A tag used to be shown twice: once as the `#todo` you typed and once as a chip. The
chip moved to the top of the card (M311) and the text now goes, but only when the tag
was standing on its own — see `split_body_tags` for the rule and why it is the
conservative one.
Was `links.py`, and also owned `[[wiki-links]]` until they were removed (note 2897): Was `links.py`, and also owned `[[wiki-links]]` until they were removed (note 2897):
this app is an intermediary surface for capture and recall, and a linking system is this app is an intermediary surface for capture and recall, and a linking system is
@@ -15,6 +20,7 @@ from sqlalchemy import func, select
from ..models.label import Label, NoteLabel from ..models.label import Label, NoteLabel
from ..models.note import Note from ..models.note import Note
from .helpers import derive_display_title
# A #tag: `#` at the start of the body or after whitespace, then a word char and # A #tag: `#` at the start of the body or after whitespace, then a word char and
# word chars/hyphens. A URL fragment (foo#bar) or mid-word `#` is not preceded by # word chars/hyphens. A URL fragment (foo#bar) or mid-word `#` is not preceded by
@@ -22,24 +28,93 @@ from ..models.note import Note
_TAG_RE = re.compile(r"(?:^|(?<=\s))#(\w[\w-]*)") _TAG_RE = re.compile(r"(?:^|(?<=\s))#(\w[\w-]*)")
def parse_tags(body: str | None) -> list[str]: # A fence opens or closes a code block. A `#tag` inside one is CODE — the shell
"""Distinct #hashtags from a note body, in order, deduped case-insensitively. # comment in a snippet someone pasted — and lifting it would delete a line of their
A tag must contain a letter, so #2024 or #_ are ignored (avoids numeric noise).""" # example. It still becomes a label, because it always has and that is a separate
if not body: # question from whether the text may be touched.
return [] _FENCE_RE = re.compile(r"^\s*(?:```|~~~)")
def _is_tag(name: str) -> bool:
"""A tag must contain a letter, so #2024 and #_ are ignored (avoids numeric noise)."""
return any(c.isalpha() for c in name)
def _dedupe(names: list[str]) -> list[str]:
"""First-seen order, deduped case-insensitively — tags are case-insensitive."""
out: list[str] = [] out: list[str] = []
seen: set[str] = set() seen: set[str] = set()
for match in _TAG_RE.finditer(body): for name in names:
tag = match.group(1) norm = name.lower()
if not any(c.isalpha() for c in tag):
continue
norm = tag.lower()
if norm not in seen: if norm not in seen:
seen.add(norm) seen.add(norm)
out.append(tag) out.append(name)
return out return out
def parse_tags(body: str | None) -> list[str]:
"""Distinct #hashtags from a note body, in order, deduped case-insensitively."""
if not body:
return []
return _dedupe([m.group(1) for m in _TAG_RE.finditer(body) if _is_tag(m.group(1))])
def split_body_tags(body: str | None) -> tuple[list[str], list[str], str]:
"""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.
That is deliberately the conservative reading of "standalone". The looser one —
also stripping a trailing tag off a prose line — was rejected because 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". Mangling a sentence to save a duplicate chip is a bad
trade. A tag sharing a line with words keeps its words.
`standalone` tags become ORDINARY labels — the text no longer backs them, so
nothing can derive them any more, and the way to remove one becomes the chip's ×
rather than deleting the text. `inline` tags stay derived exactly as before. That
is the whole meaning of `via_tag` after this change: backed by text still present.
"""
if not body:
return [], [], body or ""
standalone: list[str] = []
inline: 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))]
names = [m.group(1) for m in matches]
# Cutting the tags out and finding nothing left is what "standalone" means.
remainder = line
for m in reversed(matches):
remainder = remainder[: m.start()] + remainder[m.end() :]
if in_fence or not matches or remainder.strip():
inline.extend(names)
kept.append(line)
else:
standalone.extend(names)
lifted = re.sub(r"\n{3,}", "\n\n", "\n".join(kept)).strip("\n")
if body.strip() and not lifted.strip():
# 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 and let its tags
# stay derived.
return [], _dedupe(standalone + inline), body
# A tag that ALSO appears in prose stays derived: the prose copy still backs it,
# so deleting that copy should still detach the label.
inline_names = _dedupe(inline)
inline_lower = {n.lower() for n in inline_names}
standalone_names = [n for n in _dedupe(standalone) if n.lower() not in inline_lower]
return standalone_names, inline_names, lifted
async def _find_or_create_label(db, owner_id, name: str): async def _find_or_create_label(db, owner_id, name: str):
"""Owner's label id for `name` (case-insensitive match), creating it if absent.""" """Owner's label id for `name` (case-insensitive match), creating it if absent."""
existing = await db.scalar( existing = await db.scalar(
@@ -53,23 +128,65 @@ async def _find_or_create_label(db, owner_id, name: str):
return label.id return label.id
async def _reconcile_tags(db, note: Note) -> None: async def _lift_and_reconcile_tags(db, note: Note) -> None:
"""Sync tag-sourced labels (via_tag=True) with the #hashtags in the note body: """Attach the note's tag labels, LIFT its standalone tags out of the body, and
attach labels for current tags, detach tag-labels whose #tag was removed. Manual re-derive display_title if the body moved.
picker labels (via_tag=False) are never touched."""
tag_label_ids: set = set() NAMED FOR THE MUTATION. It used to be `_reconcile_tags` and only touched rows; it
for name in parse_tags(note.body): now rewrites `note.body`, and a caller that does not expect that will compute a
tag_label_ids.add(await _find_or_create_label(db, note.owner_id, name)) display_title from text this function is about to delete.
Which is why the lift and the re-derivation both live HERE rather than at the
seven call sites that would each have to remember. Spreading a derived-value
update across every place a body can be written is precisely the failure #2965
named about label minting: "easy to miss, and it is the common one".
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 (via_tag=False). Nothing
derives it any more because nothing is left to derive it from, and
the way to remove it becomes the chip's × — which both editors
already offer for exactly this class of label.
inline left in place, attached via_tag=True, and still detached when its
text goes. Unchanged from before.
"""
standalone, inline, lifted = split_body_tags(note.body)
standalone_ids: set = set()
for name in standalone:
standalone_ids.add(await _find_or_create_label(db, note.owner_id, name))
inline_ids: set = set()
for name in inline:
inline_ids.add(await _find_or_create_label(db, note.owner_id, name))
rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all() rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
attached_ids = {r.label_id for r in rows} attached: set = set()
# Detach tag-labels no longer backed by a #tag in the body.
for r in rows: for r in rows:
if r.via_tag and r.label_id not in tag_label_ids: if not r.via_tag:
await db.delete(r) attached.add(r.label_id) # manual already: a #tag of the same name changes nothing
attached_ids.discard(r.label_id) elif r.label_id in standalone_ids:
# Attach new tags — skip labels already attached (in any form) to respect the PK # It GRADUATED. The text backing it is about to be deleted, so the row has
# and leave a manually-added label of the same name as-is. # to become the record instead. This must run BEFORE the detach below, or
for lid in tag_label_ids: # the same row would be dropped for no longer being in the body — which is
if lid not in attached_ids: # the bug that makes a naive lift delete every tag it touches.
r.via_tag = False
attached.add(r.label_id)
elif r.label_id in inline_ids:
attached.add(r.label_id)
else:
await db.delete(r) # its #tag was deleted from the text
# Skip anything already attached in ANY form: it respects the PK, and it leaves a
# manually-added label of the same name as the manual row it already is.
for lid in standalone_ids:
if lid not in attached:
db.add(NoteLabel(note_id=note.id, label_id=lid, via_tag=False))
attached.add(lid)
for lid in inline_ids:
if lid not in attached:
db.add(NoteLabel(note_id=note.id, label_id=lid, via_tag=True)) db.add(NoteLabel(note_id=note.id, label_id=lid, via_tag=True))
attached_ids.add(lid) attached.add(lid)
if lifted != note.body:
note.body = lifted
note.display_title = derive_display_title(note.body)
-2
View File
@@ -32,7 +32,6 @@ from .db import session_scope
from .models.label import NoteLabel from .models.label import NoteLabel
from .models.note import Note from .models.note import Note
from .models.note_attachment import NoteAttachment from .models.note_attachment import NoteAttachment
from .models.note_item import NoteItem
from .models.note_link_preview import NoteLinkPreview from .models.note_link_preview import NoteLinkPreview
from .models.note_revision import NoteRevision from .models.note_revision import NoteRevision
from .settings import get_setting from .settings import get_setting
@@ -85,7 +84,6 @@ async def purge_note(db, note: Note, edited_at: datetime | None = None) -> None:
# place would make the note reappear whole on the next sweep. # place would make the note reappear whole on the next sweep.
logger.warning("couldn't remove attachment file %s during purge", a.path, exc_info=True) logger.warning("couldn't remove attachment file %s during purge", a.path, exc_info=True)
await db.execute(sa_delete(NoteAttachment).where(NoteAttachment.note_id == note.id)) await db.execute(sa_delete(NoteAttachment).where(NoteAttachment.note_id == note.id))
await db.execute(sa_delete(NoteItem).where(NoteItem.note_id == note.id))
await db.execute(sa_delete(NoteLabel).where(NoteLabel.note_id == note.id)) await db.execute(sa_delete(NoteLabel).where(NoteLabel.note_id == note.id))
await db.execute(sa_delete(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id)) await db.execute(sa_delete(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))
await db.execute(sa_delete(NoteRevision).where(NoteRevision.note_id == note.id)) await db.execute(sa_delete(NoteRevision).where(NoteRevision.note_id == note.id))
+55
View File
@@ -0,0 +1,55 @@
"""When a body change is worth keeping a version of.
A note's body used to snapshot into `note_revisions` on EVERY write, which made a
write expensive — and the clients compensated by writing as rarely as they could
get away with, saving only when an editor closed. That is durability paying for
version history: a crash mid-session lost everything typed, so that the revision
list would stay tidy. The safety property is worth more than the feature it was
subsidising.
The rule here breaks that trade. A body change earns a snapshot only if it is the
first one of an editing session, so a client may write as often as it likes.
Session granularity falls out of the window rather than being declared. A snapshot
stores the body as it was BEFORE the edit, so the first write of a sitting captures
the note as you found it and every write after it inside the window adds nothing —
one revision per sitting, with no "commit" flag for a client to send and no wire
surface to carry it. That last part is why this is a time rule and not a protocol
one: `sync.py` applies pushed bodies through the same check, so a client autosaving
every second cannot make the server snapshot every second either.
Deliberately NOT applied to restoring a revision. That is a considered act rather
than a keystroke, and it snapshots unconditionally so restoring is itself undoable.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from .models.note_revision import NoteRevision
# How long one editing session is assumed to last. A constant rather than a setting:
# it is not a preference anyone holds, and the value only has to be longer than a
# sitting and shorter than the gap between two of them. Promote it to the settings
# registry (rule 25) if that ever stops being true.
REVISION_WINDOW_MINUTES = 10
async def should_snapshot(db, note_id: uuid.UUID, old_body: str, new_body: str) -> bool:
"""Whether `old_body` should be kept as a revision before `new_body` replaces it.
False when the text did not actually change — re-saving identical bytes is not a
version of anything — and False when this note already has a revision from the
current session.
"""
if old_body == new_body:
return False
cutoff = datetime.now(timezone.utc) - timedelta(minutes=REVISION_WINDOW_MINUTES)
recent = await db.scalar(
select(NoteRevision.id)
.where(NoteRevision.note_id == note_id, NoteRevision.created_at >= cutoff)
.limit(1)
)
return recent is None
+3 -1
View File
@@ -16,9 +16,11 @@ bp = Blueprint("saved_filters", __name__, url_prefix="/api/saved-filters")
NAME_CAP = 100 NAME_CAP = 100
# Facet keys allowed in a saved view (must match the GET /api/notes query surface). # Facet keys allowed in a saved view (must match the GET /api/notes query surface).
# `color` was here until M315. A note has no colour to filter on, and `clean_params`
# drops the key on the way in — the migration that dropped the column sweeps it out of
# the views already stored.
_ALLOWED_PARAM_KEYS = { _ALLOWED_PARAM_KEYS = {
"q", "q",
"color",
"label", # matches the repeatable ?label= query param (stored as an array) "label", # matches the repeatable ?label= query param (stored as an array)
"has_reminder", "has_reminder",
"has_attachment", "has_attachment",
+29 -54
View File
@@ -24,10 +24,10 @@ from .db import session_scope
from .labeling import reconcile_manual_labels, resolve_owned_label_ids from .labeling import reconcile_manual_labels, resolve_owned_label_ids
from .models.label import Label, NoteLabel from .models.label import Label, NoteLabel
from .models.note import Note from .models.note import Note
from .models.note_item import NoteItem
from .models.note_revision import NoteRevision from .models.note_revision import NoteRevision
from .revisions import should_snapshot
from .notes import ( from .notes import (
_reconcile_tags, _lift_and_reconcile_tags,
_serialize_notes, _serialize_notes,
derive_display_title, derive_display_title,
normalize_color, normalize_color,
@@ -62,8 +62,17 @@ MAX_PUSH = 1000 # per-batch change cap
# #
# One bump for the pair: they landed in the same protocol generation, and nothing ever # One bump for the pair: they landed in the same protocol generation, and nothing ever
# ran against a half-applied v2. # ran against a half-applied v2.
SYNC_PROTOCOL_VERSION = 2 # v4 (M315): `color` left the note. The FLOOR DELIBERATELY DOES NOT MOVE, and v2 is
MIN_CLIENT_PROTOCOL_VERSION = 2 # the precedent that makes saying so worthwhile — it dropped `kind` and `title` and did
# raise the floor, on the rule that dropping a field a client sends and expects back is
# breaking. `color` fails the second half of that: a v3 client reading a v4 note gets
# `"default"` from its own serde default and draws the colour it derives locally, which
# is the board it drew yesterday; a v3 client PUSHING `color` has the key ignored, since
# `_assign_note_fields` reads its payload key by key and never validates the shape.
# Neither direction errors and neither loses anything visible. `title` was the note's
# NAME; this is a field that no longer renders anywhere.
SYNC_PROTOCOL_VERSION = 4
MIN_CLIENT_PROTOCOL_VERSION = 3
# Named capabilities beyond the base protocol. An ADDITIVE change earns a name # Named capabilities beyond the base protocol. An ADDITIVE change earns a name
# here rather than a min-version bump, so a newer client meeting an older server # here rather than a min-version bump, so a newer client meeting an older server
@@ -198,7 +207,6 @@ def _assign_note_fields(note: Note, ch: dict) -> None:
"""Overwrite a note's scalar fields from a client's FULL-state change (sync is """Overwrite a note's scalar fields from a client's FULL-state change (sync is
whole-note, not a partial patch — the client sends its authoritative version).""" whole-note, not a partial patch — the client sends its authoritative version)."""
note.body = ch["body"] if isinstance(ch.get("body"), str) else "" note.body = ch["body"] if isinstance(ch.get("body"), str) else ""
note.color = normalize_color(ch.get("color"))
note.pinned = bool(ch.get("pinned")) note.pinned = bool(ch.get("pinned"))
note.archived = bool(ch.get("archived")) note.archived = bool(ch.get("archived"))
if ch.get("trashed"): if ch.get("trashed"):
@@ -212,54 +220,17 @@ def _assign_note_fields(note: Note, ch: dict) -> None:
note.position = ch["position"] note.position = ch["position"]
def _first_item_text(ch: dict) -> str: # `_first_item_text` and `_apply_note_items` lived here until M304. Both existed for
"""The first non-blank checklist item in a pushed change, or "". # one reason — a checklist was a table beside the body — and both are gone with it. A
# pushed change carries its items as `- [ ] ` lines inside `body`, so applying them is
Read straight from the payload rather than the database because the note's name is # applying the body, and naming the note is reading its first line. A client that still
computed BEFORE `_apply_note_items` has written anything — and a note whose body is # sends an `items` array is a v2 client, and the version floor below turns it away
empty is named by its first item (M13 step 3). # before any of this runs.
"""
items = ch.get("items")
if not isinstance(items, list):
return ""
for it in items:
if isinstance(it, dict):
text = (it.get("text") or "").strip()
if text:
return text
return ""
async def _apply_note_items(db, note: Note, ch: dict) -> None:
"""Replace the note's checklist items with the client's (items sync inline).
Applies to ANY note. This used to delete every item when the note wasn't
`kind == "list"`, which was survivable only because nothing could produce a note
holding both a body and items. M13 makes that the normal shape — a checklist is
something a note HAS, not something a note IS — and against that shape the old
guard was a data-loss path: the first sync after adding a checklist to a note
would have wiped it.
Removed ahead of the UI that can create the state, deliberately, so there is no
window in which the two disagree.
"""
items = ch.get("items")
if not isinstance(items, list):
# Absent means "not telling us", not "empty". A client that omits the key
# leaves what the server has; only an explicit [] clears it.
return
await db.execute(sa_delete(NoteItem).where(NoteItem.note_id == note.id))
for pos, it in enumerate(items):
if not isinstance(it, dict):
continue
text = (it.get("text") or "").strip()
if text:
db.add(NoteItem(note_id=note.id, text=text, checked=bool(it.get("checked")), position=pos))
async def _apply_note_manual_labels(db, note: Note, ch: dict) -> None: async def _apply_note_manual_labels(db, note: Note, ch: dict) -> None:
"""Set the note's MANUAL (picker) label memberships from client label_ids, leaving """Set the note's MANUAL (picker) label memberships from client label_ids, leaving
tag-sourced (via_tag) rows to _reconcile_tags. Only labels the caller owns count.""" tag-sourced (via_tag) rows to _lift_and_reconcile_tags. Only labels the caller owns count."""
raw = ch.get("label_ids") raw = ch.get("label_ids")
if not isinstance(raw, list): if not isinstance(raw, list):
return return
@@ -314,15 +285,19 @@ async def _apply_note(db, ch: dict) -> dict:
old_body = note.body old_body = note.body
_assign_note_fields(note, ch) _assign_note_fields(note, ch)
note.display_title = derive_display_title(note.body, _first_item_text(ch)) note.display_title = derive_display_title(note.body)
if edited_at is not None: if edited_at is not None:
note.updated_at = edited_at note.updated_at = edited_at
# Non-destructive LWW: snapshot the overwritten server body into history. # Non-destructive LWW: snapshot the overwritten server body into history
if not creating and note.body != old_body: # subject to the same session window as a direct edit (revisions.should_snapshot).
# This path is why the window is a time rule rather than a flag on the wire: a
# client autosaving every second pushes a body change every second, and without
# the check the SERVER would snapshot each one no matter how restrained the
# client's own store was being.
if not creating and await should_snapshot(db, note.id, old_body, note.body):
db.add(NoteRevision(note_id=note.id, body=old_body)) db.add(NoteRevision(note_id=note.id, body=old_body))
await db.flush() # assign note.id before items/labels/links await db.flush() # assign note.id before items/labels/links
await _apply_note_items(db, note, ch) await _lift_and_reconcile_tags(db, note)
await _reconcile_tags(db, note)
await _apply_note_manual_labels(db, note, ch) await _apply_note_manual_labels(db, note, ch)
await db.flush() await db.flush()
await db.refresh(note, ["sync_revision"]) await db.refresh(note, ["sync_revision"])
+178 -49
View File
@@ -16,6 +16,7 @@ deployment has ever seen.
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from datetime import datetime, timedelta, timezone
import pytest import pytest
import pytest_asyncio import pytest_asyncio
@@ -24,20 +25,23 @@ from sqlalchemy import select, text
from thoughtsync import ratelimit from thoughtsync import ratelimit
from thoughtsync.app import create_app from thoughtsync.app import create_app
from thoughtsync.db import dispose_engine, session_scope from thoughtsync.db import dispose_engine, session_scope
from thoughtsync.models.label import NoteLabel
from thoughtsync.models.note import Note from thoughtsync.models.note import Note
from thoughtsync.models.note_item import NoteItem
from thoughtsync.models.user import User from thoughtsync.models.user import User
from thoughtsync.notes.tags import _lift_and_reconcile_tags
from thoughtsync.settings import get_setting, live, refresh_live, reset_live, set_settings from thoughtsync.settings import get_setting, live, refresh_live, reset_live, set_settings
from thoughtsync.notes.checklist import parse_items, set_item_checked
from thoughtsync.notes.helpers import derive_display_title from thoughtsync.notes.helpers import derive_display_title
from thoughtsync.models.note_link_preview import NoteLinkPreview from thoughtsync.models.note_link_preview import NoteLinkPreview
from thoughtsync.sync import _apply_note_items from thoughtsync.models.note_revision import NoteRevision
from thoughtsync.revisions import REVISION_WINDOW_MINUTES, should_snapshot
from thoughtsync.unfurl_queue import _unfurl_new_urls, detect_urls from thoughtsync.unfurl_queue import _unfurl_new_urls, detect_urls
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
# Every table the tests touch, child-first so FKs never block the truncate. # Every table the tests touch, child-first so FKs never block the truncate.
# RESTART IDENTITY + CASCADE keeps this honest if a table gains children later. # RESTART IDENTITY + CASCADE keeps this honest if a table gains children later.
_TABLES = "notes, note_items, note_revisions, note_labels, note_link_previews, labels, users" _TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, users"
@pytest_asyncio.fixture @pytest_asyncio.fixture
@@ -157,65 +161,123 @@ async def test_the_search_vector_was_rebuilt_over_the_name(db, owner):
assert body_only == 1 assert body_only == 1
async def test_a_note_keeps_both_its_body_and_its_items(db, owner): async def test_a_note_keeps_its_prose_on_both_sides_of_its_list(db, owner):
"""The shape M13 step 2 made normal: a note HAS a checklist, it isn't one.""" """The shape M304 made expressible at all.
note = Note(owner_id=owner.id, body="weekend shop", display_title="weekend shop")
db.add(note)
await db.flush()
db.add_all(
[
NoteItem(note_id=note.id, text="milk", position=0),
NoteItem(note_id=note.id, text="eggs", position=1),
]
)
await db.commit()
items = ( The old model could not hold this: a row had a position in a table and none in the
await db.scalars(select(NoteItem).where(NoteItem.note_id == note.id).order_by(NoteItem.position)) text, so a checklist could only ever render AFTER the body. Prose, list, prose is
).all() the case that proves the storage changed, not just the styling.
assert [i.text for i in items] == ["milk", "eggs"]
assert (await db.scalar(select(Note.body).where(Note.id == note.id))) == "weekend shop"
async def test_sync_no_longer_deletes_items_from_a_note_with_a_body(db, owner):
"""The data-loss path step 2 removed, pinned against a real database.
`_apply_note_items` used to delete every item when the note wasn't `kind = "list"`.
Nothing can produce that state any more, but this is the regression that would
have silently eaten a checklist, and it deserves a test that would catch its
return.
""" """
note = Note(owner_id=owner.id, body="packing", display_title="packing") body = "weekend shop\n\n- [ ] milk\n- [x] eggs\n\nback before six"
note = Note(owner_id=owner.id, body=body, display_title=derive_display_title(body))
db.add(note)
await db.commit()
stored = await db.scalar(select(Note.body).where(Note.id == note.id))
assert [(i.text, i.checked) for i in parse_items(stored)] == [("milk", False), ("eggs", True)]
assert stored.splitlines()[0] == "weekend shop"
assert stored.splitlines()[-1] == "back before six"
async def test_ticking_an_item_is_a_body_edit(db, owner):
"""What replaced `_apply_note_items`: there is no separate thing left to apply.
The regression that function guarded against — a sync silently eating a checklist
off a note that also had a body — cannot recur, because there is nothing to delete.
A pushed body either has the lines or it does not.
"""
body = "packing\n\n- [ ] socks"
note = Note(owner_id=owner.id, body=body, display_title="packing")
db.add(note)
await db.commit()
note.body = set_item_checked(note.body, 0, True)
await db.commit()
stored = await db.scalar(select(Note.body).where(Note.id == note.id))
assert stored == "packing\n\n- [x] socks"
assert parse_items(stored)[0].checked
# The prose is untouched — a tick rewrites one line, not the note.
assert stored.splitlines()[0] == "packing"
async def test_a_standalone_tag_leaves_the_body_and_becomes_an_ordinary_label(db, owner):
"""M311. The tag was being shown twice — as text and as a chip — so the text goes.
`via_tag=False` is the load-bearing half. It is what makes the chip's × appear in
both editors (they gate it on exactly this), which matters because deleting the
text is no longer a way to remove the tag: there is no text.
"""
note = Note(owner_id=owner.id, body="#todo\nreorganize the homepage", display_title="#todo")
db.add(note) db.add(note)
await db.flush() await db.flush()
db.add(NoteItem(note_id=note.id, text="socks", position=0)) await _lift_and_reconcile_tags(db, note)
await db.commit() await db.commit()
# A change that says nothing about items must LEAVE them alone — absent means assert note.body == "reorganize the homepage"
# "not telling us", not "empty". # Re-derived by the lift itself. Every caller sets display_title BEFORE calling,
await _apply_note_items(db, note, {"body": "packing"}) # so if the function did not do this the note would be named after a line it had
await db.commit() # just deleted.
assert (await db.scalar(select(NoteItem.text).where(NoteItem.note_id == note.id))) == "socks" assert note.display_title == "reorganize the homepage"
# An explicit list replaces them. rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
await _apply_note_items(db, note, {"items": [{"text": "charger", "checked": True}]}) assert len(rows) == 1
await db.commit() assert rows[0].via_tag is False
rows = (await db.scalars(select(NoteItem).where(NoteItem.note_id == note.id))).all()
assert [(r.text, r.checked) for r in rows] == [("charger", True)]
async def test_a_note_with_only_items_still_has_a_name(db, owner): async def test_a_tag_moved_onto_its_own_line_graduates_instead_of_vanishing(db, owner):
"""The hole that made removing the title unsafe until step 2 closed it.""" """The bug a naive lift has, pinned.
note = Note(owner_id=owner.id, body="", display_title="")
A tag that is still in prose stays derived. Move it to its own line and it must
become an ordinary label — NOT be detached for no longer appearing in the body,
which is what happens if the row is dropped before it is graduated.
"""
note = Note(owner_id=owner.id, body="call #mom tomorrow", display_title="call #mom tomorrow")
db.add(note) db.add(note)
await db.flush() await db.flush()
db.add(NoteItem(note_id=note.id, text="milk", position=0)) await _lift_and_reconcile_tags(db, note)
await db.commit() await db.commit()
first = await db.scalar( rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
select(NoteItem.text).where(NoteItem.note_id == note.id).order_by(NoteItem.position).limit(1) assert len(rows) == 1
) assert rows[0].via_tag is True
note.display_title = derive_display_title(note.body, first) assert note.body == "call #mom tomorrow", "a tag inside a sentence is left alone"
note.body = "#mom\ncall tomorrow"
await _lift_and_reconcile_tags(db, note)
await db.commit()
rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
assert len(rows) == 1, "the label survived the move"
assert rows[0].via_tag is False
assert note.body == "call tomorrow"
async def test_deleting_an_inline_tag_still_detaches_it(db, owner):
"""The old behaviour, unchanged where the text is unchanged. A tag still living in
prose is still owned by that prose."""
note = Note(owner_id=owner.id, body="call #mom tomorrow", display_title="call #mom tomorrow")
db.add(note)
await db.flush()
await _lift_and_reconcile_tags(db, note)
await db.commit()
assert len((await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()) == 1
note.body = "call tomorrow"
await _lift_and_reconcile_tags(db, note)
await db.commit()
assert (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all() == []
async def test_a_note_with_only_a_list_still_has_a_name(db, owner):
"""The hole that made removing the title unsafe, still closed — by a different
mechanism. There is no item table to fall back to any more; the name comes from
the first line with its marker stripped, because calling the note "- [ ] milk"
would show someone the storage instead of the note.
"""
body = "- [ ] milk\n- [ ] eggs"
note = Note(owner_id=owner.id, body=body, display_title=derive_display_title(body))
db.add(note)
await db.commit() await db.commit()
assert (await db.scalar(select(Note.display_title).where(Note.id == note.id))) == "milk" assert (await db.scalar(select(Note.display_title).where(Note.id == note.id))) == "milk"
@@ -414,3 +476,70 @@ async def test_the_security_group_reaches_the_admin_ui(app_client, db):
assert row["type"] == "int" assert row["type"] == "int"
assert row["minimum"] is not None and row["maximum"] is not None assert row["minimum"] is not None and row["maximum"] is not None
assert row["description"], f"{row['key']} has no description to explain itself" assert row["description"], f"{row['key']} has no description to explain itself"
async def _revision_count(db, note_id) -> int:
rows = (await db.scalars(select(NoteRevision.id).where(NoteRevision.note_id == note_id))).all()
return len(rows)
async def test_a_session_of_edits_costs_one_revision(db, owner):
"""The change that makes autosave affordable.
Version history used to snapshot on EVERY body write, so the clients saved as
rarely as they could — only when an editor closed — and a crash mid-session lost
everything typed. Durability was paying for history. Now a sitting earns one
revision no matter how many times it is written, so a client can write whenever
it likes.
"""
note = Note(owner_id=owner.id, body="one", display_title="one")
db.add(note)
await db.commit()
# A session's worth of autosaves.
for text_ in ("one two", "one two three", "one two three four"):
if await should_snapshot(db, note.id, note.body, text_):
db.add(NoteRevision(note_id=note.id, body=note.body))
note.body = text_
await db.commit()
assert await _revision_count(db, note.id) == 1
# And it is the body as it was BEFORE the sitting, not some midpoint — which is
# what makes one-per-session the useful granularity rather than an arbitrary one.
kept = (await db.scalars(select(NoteRevision.body).where(NoteRevision.note_id == note.id))).all()
assert kept == ["one"]
async def test_rewriting_the_same_text_is_not_a_version(db, owner):
note = Note(owner_id=owner.id, body="unchanged", display_title="unchanged")
db.add(note)
await db.commit()
assert await should_snapshot(db, note.id, note.body, "unchanged") is False
assert await _revision_count(db, note.id) == 0
async def test_a_later_sitting_earns_its_own_revision(db, owner):
"""The window has to REOPEN, or a note edited daily would keep only its first
version forever — which would be a worse history than the one we replaced."""
note = Note(owner_id=owner.id, body="today", display_title="today")
db.add(note)
await db.flush()
# A revision from longer ago than one session: the clock is not mocked, the row
# is simply written with an older timestamp, which is what the query reads.
stale = datetime.now(timezone.utc) - timedelta(minutes=REVISION_WINDOW_MINUTES + 1)
db.add(NoteRevision(note_id=note.id, body="yesterday", created_at=stale))
await db.commit()
assert await should_snapshot(db, note.id, note.body, "tomorrow") is True
async def test_a_revision_inside_the_window_blocks_another(db, owner):
note = Note(owner_id=owner.id, body="draft", display_title="draft")
db.add(note)
await db.flush()
db.add(NoteRevision(note_id=note.id, body="earlier", created_at=datetime.now(timezone.utc)))
await db.commit()
assert await should_snapshot(db, note.id, note.body, "draft revised") is False
+286 -20
View File
@@ -4,13 +4,23 @@ import pytest
from thoughtsync.app import create_app from thoughtsync.app import create_app
from thoughtsync.common import coerce_bool, parse_dt from thoughtsync.common import coerce_bool, parse_dt
from thoughtsync.models.note import NOTE_COLORS, Note from thoughtsync.colors import NOTE_COLORS
from thoughtsync.models.note import Note
from thoughtsync.notes.checklist import (
append_item,
parse_items,
remove_item,
set_item_checked,
set_item_text,
strip_marker,
)
from thoughtsync.unfurl_queue import detect_urls from thoughtsync.unfurl_queue import detect_urls
from thoughtsync.notes import ( from thoughtsync.notes import (
_attachment_ext, _attachment_ext,
_header_filename, _header_filename,
_keep_spec, _keep_spec,
_native_spec, _native_spec,
_note_markdown,
_safe_filename, _safe_filename,
_slugify, _slugify,
_usec_to_dt, _usec_to_dt,
@@ -22,6 +32,7 @@ from thoughtsync.notes import (
parse_list_items, parse_list_items,
parse_tags, parse_tags,
) )
from thoughtsync.notes.tags import split_body_tags
@pytest.fixture @pytest.fixture
@@ -42,7 +53,7 @@ def test_all_note_routes_registered(app):
"reorder_notes", "create_note", "reorder_notes", "create_note",
"get_note", "update_note", "list_revisions", "restore_revision", "get_note", "update_note", "list_revisions", "restore_revision",
"set_note_labels", "add_item", "update_item", "delete_item", "set_note_labels", "add_item", "update_item", "delete_item",
"reorder_items", "upload_attachment", "get_attachment", "upload_attachment", "get_attachment",
"delete_attachment", "unfurl_link", "delete_preview", "trash_note", "delete_attachment", "unfurl_link", "delete_preview", "trash_note",
"restore_note", "delete_note", "restore_note", "delete_note",
) )
@@ -66,16 +77,16 @@ def test_normalize_color():
def test_palette_has_core_colors(): def test_palette_has_core_colors():
# A LABEL's vocabulary since M315 — a note has no colour to be one of these.
for c in ("default", "red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray"): for c in ("default", "red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray"):
assert c in NOTE_COLORS assert c in NOTE_COLORS
def test_serialize_shape(): def test_serialize_shape():
n = Note(body="b", color="blue", pinned=True, archived=False) n = Note(body="b", pinned=True, archived=False)
s = n.serialize() s = n.serialize()
assert "title" not in s # there is no title field any more (M13 step 3) assert "title" not in s # there is no title field any more (M13 step 3)
assert s["body"] == "b" assert s["body"] == "b"
assert s["color"] == "blue"
assert s["pinned"] is True assert s["pinned"] is True
assert s["archived"] is False assert s["archived"] is False
assert s["trashed"] is False assert s["trashed"] is False
@@ -131,27 +142,127 @@ def test_derive_display_title_is_the_first_body_line():
assert derive_display_title("\n \nreal line\nmore") == "real line" assert derive_display_title("\n \nreal line\nmore") == "real line"
def test_derive_display_title_falls_back_to_the_first_item(): def test_derive_display_title_names_a_list_only_note():
# What step 2 bought: a note that is only a checklist still has a name. Without # The same property the old `first_item` fallback protected — a note that is only
# this it would have none at all, which is why the title could not go first. # a checklist still has a name — reached a different way. Items ARE body lines now
assert derive_display_title("", "milk") == "milk" # (M304), so the first one is simply the first line, with its marker stripped:
assert derive_display_title(" \n ", " eggs ") == "eggs" # calling the note "- [ ] milk" would show someone the storage instead of the note.
# The body still wins when it has anything to say. assert derive_display_title("- [ ] milk\n- [ ] eggs") == "milk"
assert derive_display_title("shopping", "milk") == "shopping" assert derive_display_title(" * [x] eggs ") == "eggs"
# Prose still wins when it comes first, because it IS the first line.
assert derive_display_title("shopping\n\n- [ ] milk") == "shopping"
# An EMPTY item does not name the note "" — a half-typed list still has a name.
assert derive_display_title("- [ ]\n- [ ] eggs") == "eggs"
def test_derive_display_title_empty(): def test_derive_display_title_empty():
assert derive_display_title(None) == "" assert derive_display_title(None) == ""
assert derive_display_title("") == "" assert derive_display_title("") == ""
assert derive_display_title(" \n ", None) == "" assert derive_display_title(" \n ") == ""
assert derive_display_title(" \n ", " ") == "" # A list of nothing but empty items is still a note with no name.
assert derive_display_title("- [ ]\n- [ ]") == ""
def test_derive_display_title_caps_length(): def test_derive_display_title_caps_length():
long = "x" * 300 long = "x" * 300
assert derive_display_title(long) == "x" * 200 assert derive_display_title(long) == "x" * 200
# the item fallback is capped on the same rule # A first line that happens to be an item is capped on the same rule.
assert derive_display_title("", long) == "x" * 200 assert derive_display_title(f"- [ ] {long}") == "x" * 200
def test_split_body_tags_lifts_only_a_line_that_is_nothing_else():
"""The rule, in the cases it exists to get right.
A tag on a line of its own is filing and the line can go. A tag sharing a line
with words is part of what was written, and taking it out would leave "remember to
call" — so the line is left exactly as typed. The trailing-tag case (`buy milk
#grocery`) is deliberately on the conservative side of the line: it reads like
filing, but nothing in the text distinguishes it from `remember to call #mom`, and
guessing wrong mangles a sentence to save a duplicate chip.
"""
assert split_body_tags("#todo\nreorganize the homepage") == (["todo"], [], "reorganize the homepage")
assert split_body_tags("needs a tauri app\n#todo") == (["todo"], [], "needs a tauri app")
assert split_body_tags("#todo #work\nreal text") == (["todo", "work"], [], "real text")
unchanged = "remember to call #mom tomorrow"
assert split_body_tags(unchanged) == ([], ["mom"], unchanged)
trailing = "buy milk #grocery"
assert split_body_tags(trailing) == ([], ["grocery"], trailing)
def test_split_body_tags_leaves_the_note_readable():
# Removing a line must not leave a hole where it was.
assert split_body_tags("foo\n\n#todo\n\nbar") == (["todo"], [], "foo\n\nbar")
# A note that is NOTHING but tags would be blanked. A duplicated chip beats an
# empty card, so it keeps its text and its tags stay derived.
assert split_body_tags("#todo") == ([], ["todo"], "#todo")
assert split_body_tags("#todo #work") == ([], ["todo", "work"], "#todo #work")
# A tag in a code fence is CODE — a shell comment in somebody's snippet. It still
# becomes a label, because it always has, but the line is never touched.
fenced = "code:\n```\n#!/bin/sh\n#deploy\n```\ndone"
assert split_body_tags(fenced) == ([], ["deploy"], fenced)
# Not a tag at all (no letter), so not a tag-only line either.
assert split_body_tags("#2024\nreal") == ([], [], "#2024\nreal")
assert split_body_tags("") == ([], [], "")
assert split_body_tags(None) == ([], [], "")
def test_split_body_tags_keeps_a_tag_derived_when_prose_still_carries_it():
"""Appearing on its own line does NOT lift a tag that is also written in a
sentence — the sentence still backs it, so deleting that sentence should still
detach the label. Standalone and inline are not both true of one tag."""
standalone, inline, body = split_body_tags("#todo\nremember the #todo list")
assert standalone == []
assert inline == ["todo"]
assert body == "remember the #todo list"
def test_migration_0028_lifts_exactly_what_the_app_lifts_today():
"""The 0028 data migration rewrites note bodies, and that is not undoable.
It carries its OWN frozen copy of the rule rather than importing
`split_body_tags`, on 0027's principle that a migration must keep producing what
it produced the day it ran. This does not assert the two agree — they are allowed
to diverge later, which is the entire point of freezing one. It pins the frozen
copy against fixed expectations, so nobody can "tidy" it into eating prose.
"""
import importlib.util
from pathlib import Path
path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0028_lift_standalone_tags.py"
spec = importlib.util.spec_from_file_location("migration_0028", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
# Lifted: the tag was the whole line.
assert mod._split("#todo\nreorganize the homepage") == (["todo"], "reorganize the homepage")
assert mod._split("needs a tauri app\n#todo") == (["todo"], "needs a tauri app")
assert mod._split("#todo #work\nreal text") == (["todo", "work"], "real text")
assert mod._split("foo\n\n#todo\n\nbar") == (["todo"], "foo\n\nbar")
# Untouched: prose. Getting any of these wrong destroys somebody's words.
for prose in ("remember to call #mom tomorrow", "buy milk #grocery", "#2024\nreal"):
assert mod._split(prose) == ([], prose), prose
# Untouched: a tag inside a fence is a shell comment in somebody's snippet.
fenced = "code:\n```\n#!/bin/sh\n#deploy\n```\ndone"
assert mod._split(fenced) == ([], fenced)
# Untouched: a note that is nothing but tags would be blanked.
assert mod._split("#todo") == ([], "#todo")
# Not flipped: the tag is still written in prose, so its text still backs it and
# it must stay derived — flipping it would be claiming otherwise.
assert mod._split("#todo\nremember the #todo list") == ([], "remember the #todo list")
# The name has to move with the body, or a note is titled after a deleted line.
assert mod._display_title("reorganize the homepage") == "reorganize the homepage"
assert mod._display_title("- [ ] milk\n- [ ] eggs") == "milk"
assert mod._display_title("") == ""
def test_parse_tags(): def test_parse_tags():
@@ -332,6 +443,8 @@ def test_keep_spec_list_note_keeps_its_text_too():
"textContent": "for the weekend", "textContent": "for the weekend",
"listContent": [{"text": "Milk", "isChecked": False}, {"text": "Eggs", "isChecked": True}], "listContent": [{"text": "Milk", "isChecked": False}, {"text": "Eggs", "isChecked": True}],
"labels": [{"name": "shopping"}], "labels": [{"name": "shopping"}],
# Keep's own colour, which the importer now reads past: there is nothing on a
# note for it to land on, and a spec carrying a key nobody applies is a lie.
"color": "TEAL", "color": "TEAL",
"isPinned": True, "isPinned": True,
"isArchived": False, "isArchived": False,
@@ -341,7 +454,7 @@ def test_keep_spec_list_note_keeps_its_text_too():
} }
spec = _keep_spec(kn, "Takeout/Keep") spec = _keep_spec(kn, "Takeout/Keep")
assert spec["body"] == "for the weekend" assert spec["body"] == "for the weekend"
assert spec["color"] == "teal" assert "color" not in spec
assert spec["pinned"] is True assert spec["pinned"] is True
assert spec["archived"] is False assert spec["archived"] is False
assert spec["trashed"] is False assert spec["trashed"] is False
@@ -350,16 +463,18 @@ def test_keep_spec_list_note_keeps_its_text_too():
assert spec["created_at"].year == 2020 assert spec["created_at"].year == 2020
def test_keep_spec_text_note_folds_annotation_urls_and_maps_color(): def test_keep_spec_text_note_folds_annotation_urls_and_drops_color():
kn = { kn = {
"textContent": "Read this later", "textContent": "Read this later",
"annotations": [{"url": "https://example.com"}], "annotations": [{"url": "https://example.com"}],
"color": "BROWN", # no brown in our palette → nearest (orange) "color": "BROWN",
"attachments": [{"filePath": "img.jpg", "mimetype": "image/jpeg"}], "attachments": [{"filePath": "img.jpg", "mimetype": "image/jpeg"}],
} }
spec = _keep_spec(kn, "Takeout/Keep") spec = _keep_spec(kn, "Takeout/Keep")
assert "https://example.com" in spec["body"] assert "https://example.com" in spec["body"]
assert spec["color"] == "orange" # BROWN used to map to the nearest hue we had. There is no hue to map TO now, so a
# Keep import brings across everything except the one thing this app stopped having.
assert "color" not in spec
# attachment path is resolved relative to the note JSON's folder # attachment path is resolved relative to the note JSON's folder
assert spec["attachments"] == [{"file": "Takeout/Keep/img.jpg", "mime": "image/jpeg"}] assert spec["attachments"] == [{"file": "Takeout/Keep/img.jpg", "mime": "image/jpeg"}]
@@ -368,6 +483,8 @@ def test_native_spec_roundtrip_fields():
n = { n = {
"title": "T", "title": "T",
"body": "b", "body": "b",
# An export taken before M315 still carries this. Reading past it rather than
# rejecting the file is the whole point — old exports must still import.
"color": "blue", "color": "blue",
"pinned": True, "pinned": True,
"archived": False, "archived": False,
@@ -381,7 +498,7 @@ def test_native_spec_roundtrip_fields():
# _create_imported_note folds it into the body rather than dropping it. # _create_imported_note folds it into the body rather than dropping it.
assert spec["title"] == "T" assert spec["title"] == "T"
assert spec["body"] == "b" assert spec["body"] == "b"
assert spec["color"] == "blue" assert "color" not in spec
assert spec["pinned"] is True assert spec["pinned"] is True
assert spec["trashed"] is False # exports only carry live notes assert spec["trashed"] is False # exports only carry live notes
assert spec["created_at"].year == 2026 assert spec["created_at"].year == 2026
@@ -406,3 +523,152 @@ def test_detect_urls_ignores_non_http():
assert detect_urls("ftp://example.com and mailto:a@b.c and bare example.com") == [] assert detect_urls("ftp://example.com and mailto:a@b.c and bare example.com") == []
assert detect_urls(None) == [] assert detect_urls(None) == []
assert detect_urls("") == [] assert detect_urls("") == []
# --- checklist items: the body IS the checklist (M304) -----------------------
#
# The same table of cases as core/src/local/derive.rs. Deliberately duplicated
# rather than shared: the point of three implementations is that each is checked
# against the same grammar, and a test that only ran once would not catch the two
# drifting apart.
def test_parse_items_reads_a_list_out_of_prose():
body = "shopping\n\n- [ ] milk\n- [x] eggs"
assert [(i.text, i.checked) for i in parse_items(body)] == [("milk", False), ("eggs", True)]
def test_parse_items_between_paragraphs():
# The case a side table could not express, which is the whole reason for M304.
assert [i.text for i in parse_items("before\n- [ ] middle\nafter")] == ["middle"]
@pytest.mark.parametrize(
"body",
[
"-[ ] no space after the dash",
"- [] empty brackets",
"- [ ]no space after the brackets",
"- [y] not a mark",
"a [ ] mid sentence",
"[ ] no bullet at all",
],
)
def test_parse_items_rejects_near_misses(body):
assert parse_items(body) == []
def test_parse_items_accepts_star_bullets_and_indentation():
# `*` because markdown.ts already takes it for a plain bullet.
body = "* [ ] star\n - [x] indented"
assert [(i.text, i.checked) for i in parse_items(body)] == [("star", False), ("indented", True)]
def test_an_empty_item_is_still_an_item():
# What pressing Enter on a list leaves behind.
assert [i.text for i in parse_items("- [ ]")] == [""]
assert [i.text for i in parse_items("- [ ] ")] == [""]
def test_uppercase_x_parses_and_normalises_on_rewrite():
assert parse_items("- [X] done")[0].checked
assert set_item_checked("- [X] done", 0, True) == "- [x] done"
def test_rewriters_preserve_indent_bullet_and_neighbours():
assert set_item_checked(" * [ ] milk", 0, True) == " * [x] milk"
assert set_item_text("- [x] old", 0, "new") == "- [x] new"
assert remove_item("keep\n- [ ] drop\n- [ ] stay", 0) == "keep\n- [ ] stay"
# Addressed by ITEM, not by line.
assert set_item_checked("note\n- [ ] a\nprose\n- [ ] b", 1, True) == "note\n- [ ] a\nprose\n- [x] b"
def test_a_stale_index_does_nothing():
# The index comes from a client that may be a moment behind. A late request
# should be inert, not a 500.
body = "- [ ] only"
assert set_item_checked(body, 7, True) == body
assert remove_item(body, 7) == body
assert set_item_text(body, 7, "x") == body
def test_a_plain_body_is_returned_unchanged():
body = "just prose\nwith two lines"
assert set_item_checked(body, 0, True) == body
assert remove_item(body, 0) == body
def test_append_item_spacing():
# Prose, blank line, list — the layout _note_markdown has always exported, and
# what the migration folds existing rows into.
assert append_item("a note", "milk") == "a note\n\n- [ ] milk"
# Nothing between consecutive items.
assert append_item("a note\n\n- [ ] milk", "eggs") == "a note\n\n- [ ] milk\n- [ ] eggs"
# A list-only note starts at the first line.
assert append_item("", "milk") == "- [ ] milk"
assert append_item("\n\n", "milk") == "- [ ] milk"
# Carries state, which is what the migrations need of it.
assert append_item("", "done", True) == "- [x] done"
def test_strip_marker():
assert strip_marker("- [x] milk") == "milk"
assert strip_marker("just prose") == "just prose"
assert strip_marker("- [ ]") == ""
def test_the_migration_folds_exactly_like_the_app():
"""0027 inlines its own copy of append_item, deliberately — a migration has to keep
producing what it produced the day it ran, so it must not follow the app if the
app's spacing ever changes. This is what keeps the copy honest until then."""
import importlib.util
from pathlib import Path
path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0027_checklist_items_into_body.py"
spec = importlib.util.spec_from_file_location("_m0027", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
for body, text, checked in [
("a note", "milk", False),
("a note\n\n- [ ] milk", "eggs", True),
("", "milk", False),
("\n\n", "milk", False),
("prose\n", " padded ", True),
]:
assert module._append_item(body, text, checked) == append_item(body, text, checked)
# --- import/export: the body already carries the list ------------------------
def test_note_markdown_writes_a_checklist_once():
"""The export used to append the items after the body. The body IS them now
(M304), so the old branch would have doubled every checklist in an export — and
doubled it again on the next re-import."""
note = Note(
display_title="shopping",
body="shopping\n\n- [ ] milk\n- [x] eggs",
pinned=False,
archived=False,
)
out = _note_markdown(note, [])
assert out.count("- [ ] milk") == 1
assert out.count("- [x] eggs") == 1
# And in place, under the note's own first line rather than in a block of its own.
assert out.rstrip().endswith("shopping\n\n- [ ] milk\n- [x] eggs")
def test_native_spec_of_a_current_export_folds_nothing():
# Today's export carries no `items` key, because the body has the lines. An empty
# list is what stops _insert_note folding them in a second time.
assert _native_spec({"body": "a\n\n- [ ] milk"})["items"] == []
def test_native_spec_of_a_pre_m304_export_still_carries_its_items():
# An export taken BEFORE this milestone has a body with no task lines and a
# separate items array. Importing one has to put the checklist back — which is
# the same fold the Keep importer does, and the reason _insert_note still accepts
# items at all.
old = {"body": "shopping", "items": [{"text": "milk", "checked": True}]}
assert _native_spec(old)["items"] == [{"text": "milk", "checked": True}]
+4 -1
View File
@@ -12,13 +12,16 @@ def app():
def test_clean_params_whitelists_facet_keys(): def test_clean_params_whitelists_facet_keys():
raw = { raw = {
"q": "hi", "q": "hi",
# A facet until M315. It is junk now, and has to be dropped like any other —
# a stored view that still filtered on a field the app lost would return
# nothing and never say why.
"color": "yellow", "color": "yellow",
"label": ["a"], "label": ["a"],
"has_reminder": True, "has_reminder": True,
"junk": 1, "junk": 1,
"__proto__": 2, "__proto__": 2,
} }
assert clean_params(raw) == {"q": "hi", "color": "yellow", "label": ["a"], "has_reminder": True} assert clean_params(raw) == {"q": "hi", "label": ["a"], "has_reminder": True}
assert clean_params("nope") == {} assert clean_params("nope") == {}
assert clean_params(None) == {} assert clean_params(None) == {}
+306
View File
@@ -0,0 +1,306 @@
"""The version derivation — `packaging/version.sh`.
These tests build their OWN git repo in a tmpdir rather than reading this one's
history. Two reasons, and the second is the important one:
* They then need no `fetch-depth: 0` on the test lane, and cannot start passing or
failing because somebody pushed.
* They can commit to ONE artifact's file set at a time, which is the only way to
assert the property this whole change exists for: that a Kotlin-only commit
leaves the desktop's version alone. Against real history you can only observe
whatever the last commits happened to touch.
Note 3127 §3 cites this repo as its example of the failure being fixed here — one
generator feeding three artifacts, so a Rust-only commit re-versioned the phone.
"""
from __future__ import annotations
import os
import re
import subprocess
from pathlib import Path
import pytest
SCRIPT = Path(__file__).resolve().parent.parent / "packaging" / "version.sh"
GUARD = Path(__file__).resolve().parent.parent / "packaging" / "guard-forward.sh"
# 2020-01-01T00:00:00Z, the counter epoch. Duplicated from the script deliberately:
# a test that imported the value could not catch the value being changed, and moving
# this epoch renumbers every artifact downwards (note 3127 §6.4).
EPOCH = 1577836800
def git(repo: Path, *args: str) -> str:
return subprocess.run(
["git", "-C", str(repo), *args],
check=True, capture_output=True, text=True,
).stdout.strip()
def commit(repo: Path, path: str, when: int) -> None:
"""Write a file and commit it with a FIXED committer date.
`%ct` is the committer date, so both GIT_AUTHOR_DATE and GIT_COMMITTER_DATE have
to be pinned or the test is timing-dependent.
"""
f = repo / path
f.parent.mkdir(parents=True, exist_ok=True)
f.write_text(f"{when}\n")
git(repo, "add", "-A")
subprocess.run(
["git", "-C", str(repo), "-c", "user.email=t@t", "-c", "user.name=t",
"commit", "-q", "-m", f"touch {path}"],
check=True, capture_output=True, text=True,
# EXTEND the environment rather than replacing it: a minimal env is enough
# for git here but not necessarily inside the CI container, and a test that
# fails only there is worse than no test.
env={**os.environ,
"GIT_AUTHOR_DATE": f"@{when} +0000", "GIT_COMMITTER_DATE": f"@{when} +0000"},
)
def version(repo: Path, what: str, artifact: str, *, subdir: str = "") -> str:
return subprocess.run(
["sh", str(SCRIPT), what, artifact],
cwd=repo / subdir if subdir else repo,
check=True, capture_output=True, text=True,
).stdout.strip()
@pytest.fixture
def repo(tmp_path: Path) -> Path:
"""A repo with one commit per artifact area, at three known instants."""
git(tmp_path, "init", "-q", "-b", "dev")
# 2026-08-28 in UTC, an hour apart so each is distinguishable.
commit(tmp_path, "core/lib.rs", 1787900400) # 2026-08-28 07:00 — shared
commit(tmp_path, "android/app/build.gradle.kts", 1787904000) # 08:00 — android only
commit(tmp_path, "desktop/src-tauri/main.rs", 1787907600) # 09:00 — desktop only
return tmp_path
# --- shape -------------------------------------------------------------------
def test_display_is_zero_padded_calver(repo: Path) -> None:
"""`YYYY.MM.DD.HHMM`, padded. Padding is what makes it sort as text as well as
numerically, and what keeps two lanes from emitting forms one character apart."""
for artifact in ("desktop", "android", "server"):
assert re.fullmatch(r"\d{4}\.\d{2}\.\d{2}\.\d{4}", version(repo, "display", artifact))
def test_display_is_the_commit_instant_in_utc(repo: Path) -> None:
# The desktop's newest commit is 09:00 UTC on 2026-08-28.
assert version(repo, "display", "desktop") == "2026.08.28.0900"
# Android's is an hour earlier, and it does not see the desktop commit at all.
assert version(repo, "display", "android") == "2026.08.28.0800"
# --- the property the whole change exists for --------------------------------
def test_a_desktop_commit_does_not_move_android(repo: Path) -> None:
before = version(repo, "display", "android")
commit(repo, "desktop/src-tauri/other.rs", 1787911200) # 10:00
assert version(repo, "display", "desktop") == "2026.08.28.1000"
assert version(repo, "display", "android") == before
def test_an_android_commit_does_not_move_the_desktop(repo: Path) -> None:
before = version(repo, "display", "desktop")
commit(repo, "android/app/src/Main.kt", 1787911200) # 10:00
assert version(repo, "display", "android") == "2026.08.28.1000"
assert version(repo, "display", "desktop") == before
def test_a_shared_core_commit_moves_both(repo: Path) -> None:
"""`core/` is genuinely in both sets — the .so and the desktop binary are built
from it — so this is correct rather than a leak between them."""
commit(repo, "core/src/sync.rs", 1787911200) # 10:00
assert version(repo, "display", "desktop") == "2026.08.28.1000"
assert version(repo, "display", "android") == "2026.08.28.1000"
def test_the_server_set_contains_the_android_set(repo: Path) -> None:
"""The image BAKES IN the APK, so an APK-only change changes what the image
ships. Note 3127 §3's bundled-artifact trap; FC's web image embeds the extension
the same way, and Roundtable needed a bespoke workflow for want of modelling it."""
commit(repo, "android/app/src/Main.kt", 1787911200) # 10:00
assert version(repo, "display", "server") == "2026.08.28.1000"
assert "android" in version(repo, "paths", "server")
def test_the_build_recipe_is_in_the_set(repo: Path) -> None:
"""A workflow file is not shipped, but change a build flag and the bytes change
while the source does not. Once step 6 skips a build whose version already
exists, that combination serves the OLD artifact on a green run."""
commit(repo, ".forgejo/workflows/desktop.yml", 1787911200) # 10:00
assert version(repo, "display", "desktop") == "2026.08.28.1000"
# --- where it is called from -------------------------------------------------
@pytest.mark.parametrize("subdir", ["", "desktop/src-tauri", "android", "core"])
def test_the_answer_does_not_depend_on_the_caller_s_directory(repo: Path, subdir: str) -> None:
"""`git log -- <paths>` resolves pathspecs relative to the CURRENT DIRECTORY.
Every caller runs from somewhere different — the desktop build from
`desktop/src-tauri`, the Android build from `android`, the manifest from the root
— so without an anchor the same request answers differently per caller.
This is not hypothetical and it is not a loud failure. Run 4796 produced THREE
versions from one push: the desktop build said 1.0.3494522 while the manifest and
the pacman packager said 1.0.3502131, because the build's pathspec matched
`desktop/src-tauri/Cargo.toml` — a real file, six days stale. Non-empty, so the
shallow-clone guard could not fire. The Android job failed loudly in the same run
only because ITS pathspec happened to match nothing; same bug, luckier symptom."""
(repo / "desktop/src-tauri").mkdir(parents=True, exist_ok=True)
(repo / "core").mkdir(parents=True, exist_ok=True)
assert version(repo, "display", "desktop", subdir=subdir) == "2026.08.28.0900"
assert version(repo, "key", "desktop", subdir=subdir) == version(repo, "key", "desktop")
def test_every_artifact_agrees_across_directories(repo: Path) -> None:
"""The property the manifest job actually depends on: the value the bundle was
built with and the value the manifest looks for must be the same string, and they
are computed by different jobs in different directories."""
for artifact in ("desktop", "android", "server"):
root = version(repo, "display", artifact)
assert version(repo, "display", artifact, subdir="desktop/src-tauri") == root
assert version(repo, "display", artifact, subdir="android") == root
# --- the ordering keys -------------------------------------------------------
def test_the_desktop_key_is_valid_semver(repo: Path) -> None:
"""THE test that keeps the update channel alive. Tauri parses `latest.json`'s
version with the semver crate AT DESERIALIZATION — a string it cannot parse does
not sort low, it makes the whole feed fail to load and every client report "no
update available" forever. Exactly three numeric segments, no leading zeros."""
key = version(repo, "key", "desktop")
assert re.fullmatch(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", key), key
def test_the_desktop_key_and_display_describe_one_build(repo: Path) -> None:
"""They are allowed to look unrelated. They are not allowed to disagree about
WHICH build — so both come from one timestamp."""
key = version(repo, "key", "desktop")
minutes = int(key.split(".")[2])
assert EPOCH + minutes * 60 == 1787907600 # the desktop's newest commit, 09:00
def test_the_desktop_key_clears_what_is_already_installed(repo: Path) -> None:
"""`0.0.<minutes>` reads best as "not a version" and would have stranded every
dev user: minor 0 < 2 puts it below the installed `0.2.466` line, and "up to
date" forever is the direction you cannot recover from."""
key = tuple(int(p) for p in version(repo, "key", "desktop").split("."))
assert key > (0, 2, 466)
assert key > (0, 2, 999999) # and above any run number that line could reach
def test_the_android_key_is_an_int_android_will_accept(repo: Path) -> None:
"""Build time, not commit time: Android HARD-FAILS a downgrade with
INSTALL_FAILED_VERSION_DOWNGRADE and leaves a channel you cannot get out of, so
the key must be monotonic by construction rather than by a CI guard."""
code = int(version(repo, "key", "android"))
assert code > 1_000_000 # far above the run numbers it replaces (~470)
assert code < 2_100_000_000 # Android's Int ceiling
def test_the_server_has_no_ordering_key(repo: Path) -> None:
"""Nothing compares a server image — no updater, no install gate. §2: do not add
an ordering key because the other artifacts have one."""
r = subprocess.run(["sh", str(SCRIPT), "key", "server"],
cwd=repo, capture_output=True, text=True)
assert r.returncode != 0
assert "no ordering key" in r.stderr
# --- failing loudly ----------------------------------------------------------
@pytest.mark.parametrize("what", ["display", "key", "paths"])
def test_an_unknown_artifact_is_rejected(repo: Path, what: str) -> None:
"""It was not. `paths_for`'s `exit 2` ran inside `$(paths_for ...)`, so it printed
the error, returned an EMPTY pathspec — which matches everything — and answered
`2026.08.28.0900` with exit 0. A confident version for an artifact that does not
exist, which is the worst kind of wrong for a value nothing else can contradict.
Asserting on stdout as well as the exit code, because the exit code alone passed
for `display` in the first version of this file while stdout carried a lie."""
r = subprocess.run(["sh", str(SCRIPT), what, "nope"],
cwd=repo, capture_output=True, text=True)
assert r.returncode != 0, f"{what} exited 0 with stdout={r.stdout!r}"
assert r.stdout.strip() == "", f"{what} emitted a value for a bogus artifact: {r.stdout!r}"
@pytest.mark.parametrize("what", ["display", "key"])
def test_no_matching_history_fails_rather_than_guessing(tmp_path: Path, what: str) -> None:
"""The shallow-clone failure (note 3127 §6.1), which is the one that matters:
depth-1 sees one commit, `git log -- <paths>` finds nothing for most artifacts,
and a script that shrugged would emit a too-LOW version with the lane green.
Too-low is unrecoverable — every installed client is stranded.
BOTH REQUESTS, and the parametrize is the point rather than thoroughness. The
first version of this guard `exit 1`-ed inside a function called as `$(...)`,
which ends the SUBSHELL and not the script. `display` still failed — but only
because `date` then choked on an empty string. `key` printed the error, emitted
`1.0.-26297280`, and exited ZERO. One path was covered and the other was broken
in exactly the way the guard existed to prevent."""
git(tmp_path, "init", "-q", "-b", "dev")
commit(tmp_path, "README.md", 1787900400) # in no artifact's set
r = subprocess.run(["sh", str(SCRIPT), what, "desktop"],
cwd=tmp_path, capture_output=True, text=True)
assert r.returncode != 0, f"{what} exited 0 with stdout={r.stdout!r}"
assert "shallow" in r.stderr
assert r.stdout.strip() == "", f"{what} emitted a value anyway: {r.stdout!r}"
# --- the backwards guard's comparison ----------------------------------------
#
# Exercised through the guard's own `compare` mode rather than a reimplementation
# here: a test of a copy proves nothing about the code that runs. No network — the
# comparison is pure, and the fetch/compare halves are separable for exactly this.
def compare(a: str, b: str) -> bool:
"""True when the guard considers `a` to sort strictly below `b`."""
return subprocess.run(["sh", str(GUARD), "compare", a, b],
capture_output=True, text=True).returncode == 0
@pytest.mark.parametrize("a,b,expect_lt", [
# THE trap: as text, "1.0.9" > "1.0.10". The comparison must be numeric
# per dot-segment, which is what note 3127 §1 spells out and what a naive
# `[ "$a" \< "$b" ]` would get exactly backwards.
("1.0.9", "1.0.10", True),
("1.0.10", "1.0.9", False),
# Equal is NOT less. Under commit time an unchanged source derives what it
# derived last time, so this is the ordinary no-change build.
("1.0.5", "1.0.5", False),
# A missing segment reads as zero.
("1.0", "1.0.0", False),
("1.0.0", "1.0", False),
("1.0", "1.0.1", True),
# The real transition this milestone performs, on both channels: dev was
# publishing 0.2.<run>, stable was on the bare 0.2.0 from Cargo.toml.
("0.2.466", "1.0.3502151", True),
("0.2.0", "1.0.3502151", True),
("1.0.3502151", "0.2.466", False),
# Android codes are bare integers.
("3502151", "3502152", True),
("3502152", "3502151", False),
# Zero-padded display versions compare correctly despite the leading zeros
# (which is why they are stripped on parse rather than compared as text).
("2026.08.29.0110", "2026.08.29.0111", True),
("2026.08.29.0111", "2026.08.29.0110", False),
("2026.08.09.0111", "2026.08.10.0111", True),
("2026.12.31.2359", "2027.01.01.0000", True),
])
def test_the_guard_orders_versions_numerically(a: str, b: str, expect_lt: bool) -> None:
assert compare(a, b) is expect_lt
@pytest.mark.parametrize("args", [["compare"], ["compare", "1.0.0"], ["nope", "dev"],
["desktop", "nope"]])
def test_the_guard_rejects_bad_invocations(args: list[str]) -> None:
"""Including the two-place validation the version script needed twice — a guard
that answers confidently for input it does not understand is worse than none."""
r = subprocess.run(["sh", str(GUARD), *args], capture_output=True, text=True)
assert r.returncode != 0